diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 00000000..c8692a58 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,78 @@ +name: Release-plz + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + release-plz-release: + name: Publish crate and create GitHub release + if: github.repository == 'ScriptedAlchemy/tracedecay' + runs-on: ubuntu-latest + environment: crates-io + permissions: + contents: write + pull-requests: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Build dashboard assets + working-directory: dashboard + run: | + npm ci + npm run build + + - uses: dtolnay/rust-toolchain@stable + + - name: Run release-plz release + uses: release-plz/action@v0.5 + with: + command: release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + release-plz-pr: + name: Open or update release PR + if: github.repository == 'ScriptedAlchemy/tracedecay' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + + - name: Run release-plz release-pr + uses: release-plz/action@v0.5 + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e6cd1e2..c378aa9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ on: permissions: contents: write + id-token: write + attestations: write env: CARGO_TERM_COLOR: always @@ -67,8 +69,15 @@ jobs: id: version shell: bash run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + TAG="dry-run-${GITHUB_SHA::7}" + VERSION="${TAG#v}" + else + TAG="${GITHUB_REF_NAME}" + VERSION="${GITHUB_REF_NAME#v}" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" # --- Binary archive (all platforms) --- @@ -76,63 +85,45 @@ jobs: if: matrix.archive == 'tar.gz' run: | cd target/${{ matrix.target }}/release - tar czf ../../../tracedecay-${{ github.ref_name }}-${{ matrix.name }}.tar.gz tracedecay + tar czf ../../../tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.tar.gz tracedecay cd ../../.. + - name: Generate archive attestation (unix) + if: matrix.archive == 'tar.gz' && github.event_name == 'release' + uses: actions/attest@v4 + with: + subject-path: tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} + - name: Upload binary archive (unix) if: matrix.archive == 'tar.gz' && github.event_name == 'release' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash - run: gh release upload ${{ github.ref_name }} tracedecay-${{ github.ref_name }}-${{ matrix.name }}.${{ matrix.archive }} --clobber - - # --- Windows: Authenticode signing via SignPath --- - # The unsigned .exe is uploaded as a workflow artifact (upload-artifact wraps the - # single file in a ZIP), submitted to SignPath, and the signed .exe is repackaged - # into the release zip. Signing only runs for real releases, not workflow_dispatch. + run: gh release upload ${{ github.ref_name }} tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} --clobber - - name: Upload unsigned binary for signing - if: matrix.archive == 'zip' && github.event_name == 'release' - id: upload-unsigned - uses: actions/upload-artifact@v4 - with: - name: tracedecay-windows-unsigned - path: target/${{ matrix.target }}/release/tracedecay.exe - if-no-files-found: error + - name: Package binary (windows) + if: matrix.archive == 'zip' + shell: pwsh + run: | + Compress-Archive -Path target/${{ matrix.target }}/release/tracedecay.exe -DestinationPath tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.zip -Force - - name: Submit signing request to SignPath + - name: Generate archive attestation (windows) if: matrix.archive == 'zip' && github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v2 + uses: actions/attest@v4 with: - api-token: ${{ secrets.SIGNPATH_API_TOKEN }} - organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} - # NOTE: SignPath is an external service — the project must also be - # renamed tokensave -> tracedecay in the SignPath dashboard, or - # signing will fail with an unknown project-slug error. - project-slug: tracedecay - # Test certificate while bootstrapping; switch to release-signing for production. - signing-policy-slug: test-signing - github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} - wait-for-completion: true - output-artifact-directory: signed - - - name: Package signed binary (windows) - if: matrix.archive == 'zip' && github.event_name == 'release' - shell: pwsh - run: | - Compress-Archive -Path signed/tracedecay.exe -DestinationPath tracedecay-${{ github.ref_name }}-${{ matrix.name }}.zip -Force + subject-path: tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} - - name: Upload signed binary archive + - name: Upload binary archive (windows) if: matrix.archive == 'zip' && github.event_name == 'release' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash - run: gh release upload ${{ github.ref_name }} tracedecay-${{ github.ref_name }}-${{ matrix.name }}.${{ matrix.archive }} --clobber + run: gh release upload ${{ github.ref_name }} tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} --clobber # --- Homebrew bottle (only for platforms with bottle_tag) --- - name: Package Homebrew bottle - if: matrix.bottle_tag && github.event_name == 'release' + if: matrix.bottle_tag run: | VERSION="${{ steps.version.outputs.version }}" BOTTLE_TAG="${{ matrix.bottle_tag }}" @@ -141,9 +132,15 @@ jobs: chmod +x tracedecay/${VERSION}/bin/tracedecay tar czf "tracedecay-${VERSION}.${BOTTLE_TAG}.bottle.tar.gz" tracedecay/ + - name: Generate bottle attestation + if: matrix.bottle_tag && github.event_name == 'release' + uses: actions/attest@v4 + with: + subject-path: tracedecay-${{ steps.version.outputs.version }}.${{ matrix.bottle_tag }}.bottle.tar.gz + - name: Upload bottle artifact if: matrix.bottle_tag && github.event_name == 'release' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: bottle-${{ matrix.bottle_tag }} path: "tracedecay-*.bottle.tar.gz" @@ -163,7 +160,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Download bottle artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: bottles merge-multiple: true @@ -178,7 +175,6 @@ jobs: - name: Download source tarball run: | - VERSION="${{ steps.version.outputs.version }}" curl -sL "https://github.com/${{ github.repository }}/archive/refs/tags/${GITHUB_REF_NAME}.tar.gz" -o "source.tar.gz" - name: Compute SHA256 hashes @@ -207,6 +203,7 @@ jobs: X86_64_LINUX_SHA="${{ steps.hashes.outputs.x86_64_linux_sha }}" git clone "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/ScriptedAlchemy/homebrew-tap.git" tap + mkdir -p tap/Formula cat > tap/Formula/tracedecay.rb << EOF class Tracedecay < Formula @@ -243,37 +240,6 @@ jobs: git diff --cached --quiet || git commit -m "tracedecay ${VERSION}" git push - publish-crate: - name: Publish to crates.io - if: github.event_name == 'release' && !github.event.release.prerelease - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: dashboard/package-lock.json - - # The crate package ships the prebuilt dashboard dist bundles (see - # `package.include` in Cargo.toml), so they must exist before publish. - - name: Build dashboard assets - working-directory: dashboard - run: | - npm ci - npm run build - - - uses: dtolnay/rust-toolchain@stable - - # NOTE: publishes the renamed `tracedecay` crate — a new crate name on - # crates.io; the legacy `tokensave` crate is not updated by this job. - - name: Publish - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - update-scoop: name: Update Scoop bucket if: github.event_name == 'release' && !github.event.release.prerelease && !cancelled() diff --git a/docs/RELEASE-AUTOMATION.md b/docs/RELEASE-AUTOMATION.md new file mode 100644 index 00000000..c45981cd --- /dev/null +++ b/docs/RELEASE-AUTOMATION.md @@ -0,0 +1,55 @@ +# Release Automation + +TraceDecay uses two workflows for stable releases: + +1. `Release-plz` runs on pushes to `master`. + - Opens or updates a release PR. + - Bumps `Cargo.toml` and `Cargo.lock`. + - Updates `CHANGELOG.md`. + - Publishes the `tracedecay` crate to crates.io when the release PR is merged. + - Creates the `vX.Y.Z` tag and GitHub Release. +2. `Release` runs after a GitHub Release is published. + - Builds platform binaries. + - Uploads release assets. + - Updates the Homebrew tap, Scoop bucket, and `server.json`. + +`release.yml` intentionally does not run `cargo publish`; crates.io publishing belongs to `release-plz.yml`. + +## Required GitHub Setup + +Set repository Actions workflow permissions to allow write access: + +```bash +gh api \ + --method PUT \ + repos/ScriptedAlchemy/tracedecay/actions/permissions/workflow \ + -f default_workflow_permissions=write \ + -F can_approve_pull_request_reviews=true +``` + +Add these repository secrets: + +- `RELEASE_PLZ_TOKEN`: fine-grained PAT or GitHub App token with read/write `Contents` and `Pull requests` access. This token is important because releases created with the default `GITHUB_TOKEN` do not trigger the follow-up `release.yml` workflow. +- `CARGO_REGISTRY_TOKEN`: crates.io token with publish access for `tracedecay`. This is used as a bootstrap fallback until crates.io Trusted Publishing is configured after `release-plz.yml` lands on `master`. +- `TAP_GITHUB_TOKEN`: token that can push to `ScriptedAlchemy/homebrew-tap` and `ScriptedAlchemy/scoop-bucket`. + +## Crates.io Setup + +The `tracedecay` crate should use crates.io Trusted Publishing once `release-plz.yml` exists on `master`. Configure the trusted publisher as GitHub Actions for `ScriptedAlchemy/tracedecay`, workflow `release-plz.yml`, environment `crates-io`. + +The first version of a crate must exist before trusted publishing can be configured. `tracedecay` already exists on crates.io, so after this PR is merged crates.io can be configured for OIDC publishing and `CARGO_REGISTRY_TOKEN` can be removed from `.github/workflows/release-plz.yml`. + +After that, release-plz detects unpublished changes from crates.io, opens a release PR, and publishes on merge. + +## Normal Release Flow + +1. Merge feature/fix PRs into `master`. +2. `Release-plz` opens or updates a release PR. +3. Review the generated version and changelog. +4. Merge the release PR. +5. `Release-plz` publishes the crate and creates the GitHub Release. +6. The GitHub Release triggers `release.yml`, which builds and uploads binaries and updates package-manager manifests. + +## Manual Recovery + +If release-plz publishes the crate but the binary artifact workflow does not run, check whether `RELEASE_PLZ_TOKEN` was configured. Then manually dispatch `Release` from the Actions tab against the release tag. diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 00000000..d307170f --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,19 @@ +[workspace] +allow_dirty = true +dependencies_update = false +repo_url = "https://github.com/ScriptedAlchemy/tracedecay" +release_always = false +git_release_enable = true +git_release_name = "v{{ version }}" +git_release_body = "{{ changelog }}" +git_release_type = "prod" +git_release_draft = false +git_release_latest = true +git_tag_enable = true +git_tag_name = "v{{ version }}" +pr_branch_prefix = "release-plz-" +pr_labels = ["release"] +publish = true +publish_allow_dirty = true +publish_timeout = "1h" +semver_check = false