From af6e64945f329e9a148dc709604d48c95ab96486 Mon Sep 17 00:00:00 2001 From: Alexey <4681325+qqrm@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:27:52 +0300 Subject: [PATCH] chore: rebuild release automation --- .github/workflows/ci.yml | 92 ++------- .github/workflows/release-pr.yml | 46 ----- .github/workflows/release.yml | 268 ++++++++------------------ .github/workflows/tag-and-release.yml | 60 ++++++ Justfile | 73 +++---- 5 files changed, 192 insertions(+), 347 deletions(-) delete mode 100644 .github/workflows/release-pr.yml create mode 100644 .github/workflows/tag-and-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad47642..105e8aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,89 +1,25 @@ -name: CI +name: ci on: - push: - branches: - - main - - dev pull_request: - branches: - - main - - dev - workflow_dispatch: + branches: [dev, main] + push: + branches: [dev, main] permissions: contents: read -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - RUSTFLAGS: "-D warnings" - CARGO_TERM_COLOR: always - jobs: - lint: + checks: runs-on: ubuntu-latest steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up Rust toolchain (nightly) - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: nightly - components: rustfmt, clippy - - - name: Cache cargo registry and build - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Run clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - test: - needs: lint - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up Rust toolchain (nightly) - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: nightly - - - uses: Swatinem/rust-cache@v2 - - - name: Run tests - run: cargo test --workspace --all-features --all-targets --locked - - security: - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up Rust toolchain (nightly) - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: nightly - - - uses: Swatinem/rust-cache@v2 - - - name: Run cargo-deny - uses: EmbarkStudios/cargo-deny-action@v1 - with: - arguments: "--all-features" - + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + - name: Cargo fmt + run: cargo fmt --check + - name: Cargo clippy + run: cargo clippy --all-targets -- -D warnings + - name: Cargo test + run: cargo test diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index db71a2b..0000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Release PR - -# This workflow uses Google's release-please action to automate creation of -# release pull requests for the crate. It runs on pushes to the dev -# branch, inspects commits since the last release, and opens a PR that -# bumps the version and generates changelog entries. When the PR is -# merged, release-please will tag the commit and create a GitHub release. - -on: - push: - branches: - - dev - workflow_dispatch: - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Release-please - uses: google-github-actions/release-please-action@v4 - with: - # Specify the type of project. Rust crates use the 'rust' release type, - # which will update Cargo.toml and Cargo.lock and generate release - # notes. See https://github.com/googleapis/release-please for details. - release-type: rust - # Name of the crate as published to crates.io. This should match - # package.name in Cargo.toml. - package-name: rust-switcher - # The branch on which release pull requests are opened. Use - # the development branch of your repository. - default-branch: dev - # Path to the version file. release-please will update this file - # with the new version. Cargo.toml is standard for Rust crates. - version-file: Cargo.toml - # Additional files to update. Including Cargo.lock ensures the - # locked dependencies reflect the bumped version. - extra-files: Cargo.lock - # Enable release creation. When the PR is merged, release-please - # will tag the release and create a GitHub release with notes. - create-release: true - # Use the GitHub token provided by Actions. The token needs - # permission to create PRs and releases. - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd5b9c0..ac961f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,218 +1,108 @@ -name: Release +name: release on: push: tags: - "v*.*.*" - workflow_dispatch: - inputs: - tag: - description: "Existing tag (e.g. v1.2.3) to build release from when manually triggered." - required: true - type: string permissions: - contents: read - -concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} - cancel-in-progress: false - -env: - CARGO_TERM_COLOR: always - APP_NAME: rust-switcher - TARGET_WINDOWS: x86_64-pc-windows-msvc + contents: write + pull-requests: write jobs: build-windows: - name: Build (Windows) runs-on: windows-latest - permissions: - contents: read - env: - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} steps: - - name: Checkout code (tag) - uses: actions/checkout@v4.3.1 - with: - fetch-depth: 1 - ref: ${{ env.RELEASE_TAG }} - - - name: Set up Rust (nightly) - uses: dtolnay/rust-toolchain@v1 + - name: Checkout + uses: actions/checkout@v4 with: - toolchain: nightly - components: rustfmt, clippy - targets: ${{ env.TARGET_WINDOWS }} - - - name: Cache cargo builds - uses: Swatinem/rust-cache@v2.8.2 - - - name: Verify version matches tag - shell: powershell + fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + - name: Build release + run: cargo build --release + - name: Package artifacts + id: package + shell: pwsh run: | - $tag = $env.RELEASE_TAG - if (-not $tag.StartsWith('v')) { throw "Release tag must start with 'v' (got: $tag)" } - $tagVersion = $tag.Substring(1) - - $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json - $pkg = $meta.packages | Where-Object { $_.name -eq $env.APP_NAME } | Select-Object -First 1 - if (-not $pkg) { throw "Package '$env.APP_NAME' not found in cargo metadata" } - - if ($pkg.version -ne $tagVersion) { - throw "Version mismatch: tag=$tagVersion, Cargo.toml=$($pkg.version). Fix Cargo.toml version before tagging." + $ErrorActionPreference = 'Stop' + $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $package = $metadata.packages | Where-Object { $_.name -eq 'rust-switcher' } + if (-not $package) { + throw 'Package rust-switcher not found in cargo metadata.' } - - - name: cargo fmt (check) - run: cargo fmt --all -- --check - - - name: cargo clippy (deny warnings) - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - - - name: cargo test - run: cargo test --workspace --all-features --locked - - - name: Build release binary - run: cargo build --release --locked --target ${{ env.TARGET_WINDOWS }} --bin ${{ env.APP_NAME }} - - - name: Package release (zip) - shell: powershell + $binTarget = $package.targets | Where-Object { $_.kind -contains 'bin' } | Select-Object -First 1 + if (-not $binTarget) { + throw 'Binary target not found in cargo metadata.' + } + $binName = $binTarget.name + $exePath = "target\\release\\$binName.exe" + if (-not (Test-Path $exePath)) { + throw "Executable not found at $exePath" + } + $artifactsDir = "artifacts" + New-Item -ItemType Directory -Force -Path $artifactsDir | Out-Null + Copy-Item $exePath -Destination $artifactsDir + $zipPath = Join-Path $artifactsDir "$binName-${{ github.ref_name }}-windows-x64.zip" + $zipItems = @($exePath) + foreach ($item in @('README.md', 'LICENSE')) { + if (Test-Path $item) { + $zipItems += $item + } + } + if (Test-Path "config.example.json") { + $zipItems += "config.example.json" + } + Compress-Archive -Path $zipItems -DestinationPath $zipPath -Force + $exeSha = Join-Path $artifactsDir "$binName.exe.sha256" + $zipSha = Join-Path $artifactsDir "$(Split-Path $zipPath -Leaf).sha256" + (certutil -hashfile $exePath SHA256)[1] | Set-Content -Path $exeSha + (certutil -hashfile $zipPath SHA256)[1] | Set-Content -Path $zipSha + "exe_path=$exePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "zip_path=$zipPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + - name: Upload assets to GitHub Release + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + shell: pwsh run: | $ErrorActionPreference = 'Stop' + $tag = "${{ github.ref_name }}" + gh release upload $tag artifacts\* --clobber - $outDir = Join-Path $PWD "dist" - if (Test-Path $outDir) { Remove-Item -Recurse -Force $outDir } - New-Item -ItemType Directory -Path $outDir | Out-Null - - $exe = Join-Path $PWD "target/${{ env.TARGET_WINDOWS }}/release/${{ env.APP_NAME }}.exe" - if (-not (Test-Path $exe)) { throw "Expected binary not found: $exe" } - - Copy-Item $exe (Join-Path $outDir "${{ env.APP_NAME }}.exe") - if (Test-Path "README.md") { Copy-Item "README.md" $outDir } - if (Test-Path "LICENSE") { Copy-Item "LICENSE" $outDir } - - $zipName = "${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}-${{ env.TARGET_WINDOWS }}.zip" - if (Test-Path $zipName) { Remove-Item -Force $zipName } - Compress-Archive -Path (Join-Path $outDir '*') -DestinationPath $zipName - - - name: Upload artifact - uses: actions/upload-artifact@v4.6.2 - with: - name: windows-${{ env.TARGET_WINDOWS }} - path: "${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}-${{ env.TARGET_WINDOWS }}.zip" - if-no-files-found: error - - github-release: - name: Create GitHub Release - needs: build-windows + publish-crates: runs-on: ubuntu-latest - permissions: - contents: write - env: - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + needs: build-windows steps: - - name: Download artifacts - uses: actions/download-artifact@v4.3.0 - with: - path: ./artifacts - - - name: Generate checksums (SHA256SUMS) - shell: bash - run: | - set -euo pipefail - cd artifacts - find . -type f -maxdepth 2 -name "*.zip" -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS - - - name: Create / Update GitHub Release - uses: softprops/action-gh-release@v2.5.0 - with: - tag_name: ${{ env.RELEASE_TAG }} - name: Release ${{ env.RELEASE_TAG }} - generate_release_notes: true - fail_on_unmatched_files: true - files: | - artifacts/**/${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}-*.zip - artifacts/SHA256SUMS + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked - publish-crates-io: - name: Publish to crates.io (Trusted Publishing) - needs: github-release + sync-main-to-dev: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - env: - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + needs: publish-crates steps: - - name: Checkout code (tag) - uses: actions/checkout@v4.3.1 - with: - fetch-depth: 1 - ref: ${{ env.RELEASE_TAG }} - - - name: Set up Rust (nightly) - uses: dtolnay/rust-toolchain@v1 + - name: Checkout + uses: actions/checkout@v4 with: - toolchain: nightly - - - name: Cache cargo builds - uses: Swatinem/rust-cache@v2.8.2 - - - name: Verify version matches tag - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import json, os, subprocess, sys - - app = os.environ["APP_NAME"] - tag = os.environ["RELEASE_TAG"] - if not tag.startswith("v"): - print(f"Release tag must start with 'v' (got: {tag})", file=sys.stderr) - sys.exit(1) - tag_version = tag[1:] - - meta_raw = subprocess.check_output( - ["cargo", "metadata", "--no-deps", "--format-version", "1"], - text=True - ) - meta = json.loads(meta_raw) - pkgs = [p for p in meta.get("packages", []) if p.get("name") == app] - if not pkgs: - print(f"Package '{app}' not found in cargo metadata", file=sys.stderr) - sys.exit(1) - - cargo_version = pkgs[0]["version"] - if cargo_version != tag_version: - print(f"Version mismatch: tag={tag_version}, Cargo.toml={cargo_version}", file=sys.stderr) - sys.exit(1) - PY - - - name: Authenticate to crates.io (OIDC) - id: cratesio - uses: rust-lang/crates-io-auth-action@v1.0.3 - - - name: cargo publish (idempotent) - shell: bash + fetch-depth: 0 + persist-credentials: false + - name: Create sync PR env: - CARGO_REGISTRY_TOKEN: ${{ steps.cratesio.outputs.token }} + GH_TOKEN: ${{ secrets.RELEASE_PAT }} run: | set -euo pipefail - - set +e - output=$(cargo publish --locked --no-verify 2>&1) - status=$? - set -e - - echo "$output" - - if [[ $status -eq 0 ]]; then - exit 0 - fi - - if echo "$output" | grep -qiE "already\s+uploaded|already\s+exists|previously\s+published"; then - echo "Crate version already published; skipping." + tag="${GITHUB_REF_NAME}" + title="Sync main back to dev (${tag})" + existing=$(gh pr list --base dev --head main --state open --json url -q '.[0].url') + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "$title" + gh pr merge "$existing" --auto --squash exit 0 fi - - exit $status + pr=$(gh pr create --base dev --head main --title "$title" --body "Sync main back to dev for ${tag}.") + gh pr merge "$pr" --auto --squash diff --git a/.github/workflows/tag-and-release.yml b/.github/workflows/tag-and-release.yml new file mode 100644 index 0000000..b8a72ba --- /dev/null +++ b/.github/workflows/tag-and-release.yml @@ -0,0 +1,60 @@ +name: tag-and-release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + tag_and_release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Read version + id: version + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + + data = tomllib.loads(Path('Cargo.toml').read_text(encoding='utf-8')) + version = data['package']['version'] + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as fh: + fh.write(f"version={version}\n") + fh.write(f"tag=v{version}\n") + PY + - name: Configure git + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + - name: Create tag if missing + env: + RELEASE_PAT: ${{ secrets.RELEASE_PAT }} + run: | + set -euo pipefail + tag="${{ steps.version.outputs.tag }}" + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists." + exit 0 + fi + git tag -a "${tag}" -m "Release ${tag}" + git push "https://x-access-token:${RELEASE_PAT}@github.com/${GITHUB_REPOSITORY}.git" "${tag}" + - name: Create GitHub Release if missing + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + run: | + set -euo pipefail + tag="${{ steps.version.outputs.tag }}" + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists." + exit 0 + fi + gh release create "${tag}" --title "${tag}" --notes "" diff --git a/Justfile b/Justfile index 7dbe113..a136fd1 100644 --- a/Justfile +++ b/Justfile @@ -9,13 +9,13 @@ default: # ----------------------------- fmt: - cargo fmt --all -- --check + cargo fmt --check clippy: - cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo clippy --all-targets --all-features --locked -- -D warnings test: - cargo test --workspace --all-features --all-targets --locked + cargo test --locked check: fmt clippy test @@ -23,34 +23,39 @@ check: fmt clippy test # Release helpers # ----------------------------- -# Dry-run: shows next version only. -next bump="patch": - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump {{bump}} -DryRun - -# Main entry: bump + checks + commit + push + PR -release bump="patch": - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump {{bump}} -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr - -# Aliases (no nesting, always explicit bump) -release-patch: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump patch -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr - -release-minor: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump minor -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr - -release-major: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump major -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr - -# Full auto: also tries merge + tag (may fail due to branch protection) -release-full bump="patch": - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump {{bump}} -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr -MergePr -Tag -TagOnMain - -# Full auto aliases (recommended) -release-full-patch: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump patch -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr -MergePr -Tag -TagOnMain - -release-full-minor: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump minor -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr -MergePr -Tag -TagOnMain - -release-full-major: - pwsh -NoLogo -NoProfile -File ./scripts/release.ps1 -Bump major -Branch dev -Main main -Remote origin -Package rust-switcher -RunChecks -Commit -Push -CreatePr -MergePr -Tag -TagOnMain +bump VERSION: + $ErrorActionPreference = 'Stop' + $version = '{{VERSION}}' + if (-not $version) { + throw 'VERSION is required (example: just bump 1.2.3)' + } + $toml = Get-Content Cargo.toml -Raw + $updated = [regex]::Replace($toml, '(?m)^version\s*=\s*"[^"]+"', "version = \"$version\"", 1) + if ($toml -eq $updated) { + throw 'Failed to update version in Cargo.toml' + } + Set-Content -Path Cargo.toml -Value $updated + if (git ls-files --error-unmatch Cargo.lock) { + cargo update -p rust-switcher --precise $version + } + git add Cargo.toml + if (git ls-files --error-unmatch Cargo.lock) { + git add Cargo.lock + } + git commit -m "chore: bump version to $version" + +publish: + $ErrorActionPreference = 'Stop' + $branch = git rev-parse --abbrev-ref HEAD + if ($branch -ne 'dev') { + throw "Must be on dev branch (current: $branch)" + } + if (git status --porcelain) { + throw 'Working tree must be clean' + } + $version = rg -m 1 '^version\s*=\s*"(?[^"]+)"' Cargo.toml --replace '$ver' + if (-not $version) { + throw 'Unable to read version from Cargo.toml' + } + $prUrl = gh pr create --base main --head dev --title "Release v$version" --body "Release v$version" --json url -q '.url' + gh pr merge $prUrl --auto --squash