From f32fee7da862bc7790929169cf3f6d6ced5e05ec Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:43:00 +0900 Subject: [PATCH 1/3] ci: build release artifacts in a tag-scoped workflow Release Please now only creates the draft release and dispatches the new Release Artifacts workflow on the tag, so a later push replacing a pending run can no longer strand the draft behind a ref/SHA mismatch. Automatic and manual runs share that single tag-scoped path. The artifact workflow refuses to run unless the release is still a draft, because --clobber deletes assets before re-uploading and would break a published release on failure. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release_artifacts.yml | 173 ++++++++++++++++++++++++ .github/workflows/release_please.yml | 156 +-------------------- CONTRIBUTING.md | 15 +- 3 files changed, 188 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/release_artifacts.yml diff --git a/.github/workflows/release_artifacts.yml b/.github/workflows/release_artifacts.yml new file mode 100644 index 0000000..cd7911b --- /dev/null +++ b/.github/workflows/release_artifacts.yml @@ -0,0 +1,173 @@ +name: Release Artifacts + +on: + workflow_dispatch: + inputs: + tag_name: + description: Draft release tag to build artifacts for; run the workflow on the same tag + required: true + type: string + +permissions: {} + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag_name }} + cancel-in-progress: false + +jobs: + release-artifacts: + runs-on: macos-15 + timeout-minutes: 30 + permissions: + contents: write + id-token: write + attestations: write + env: + CARGO_DIST_VERSION: 0.30.2 + TARGET: aarch64-apple-darwin + TAG_NAME: ${{ inputs.tag_name }} + steps: + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + ref: ${{ env.TAG_NAME }} + + - name: Verify release target + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Provenance must name the tagged commit, so the workflow runs on the tag. + if [[ "$GITHUB_REF" != "refs/tags/$TAG_NAME" ]]; then + echo "Run this workflow on the release tag itself:" + echo " gh workflow run release_artifacts.yml --ref $TAG_NAME -f tag_name=$TAG_NAME" + exit 1 + fi + + head_sha="$(git rev-parse HEAD)" + if [[ "$head_sha" != "$GITHUB_SHA" ]]; then + echo "$TAG_NAME points to $head_sha, but this run is for $GITHUB_SHA." + exit 1 + fi + + # Re-uploading with --clobber deletes assets first, so never touch a published release. + is_draft="$(gh release view "$TAG_NAME" --json isDraft --jq .isDraft)" + if [[ "$is_draft" != "true" ]]; then + echo "$TAG_NAME is already published; ship fixes as a new release instead." + exit 1 + fi + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: 1.98.1 + target: ${{ env.TARGET }} + rustflags: "" + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + + - name: Install cargo-dist + run: | + curl --proto '=https' --tlsv1.2 -LsSf \ + "https://github.com/axodotdev/cargo-dist/releases/download/v${CARGO_DIST_VERSION}/cargo-dist-installer.sh" \ + | sh + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Run tests + run: cargo test --locked + + - name: Install JXA check dependencies + run: pnpm install --frozen-lockfile + + - name: Check embedded JXA + run: pnpm run check + + - name: Build release artifacts + run: | + set -euo pipefail + + dist build \ + --tag "$TAG_NAME" \ + --target "$TARGET" \ + --artifacts=all \ + --output-format=json \ + > dist-manifest.json + dist print-upload-files-from-manifest \ + --manifest dist-manifest.json \ + > dist-upload-files.txt + + - name: Smoke test packaged archive + run: | + set -euo pipefail + + archive_path="$( + find target/distrib -maxdepth 1 -type f \ + \( -name "apple-mail-cli-${TARGET}.tar.xz" -o -name "apple-mail-cli-${TARGET}.tar.gz" \) \ + | head -n 1 + )" + + if [[ -z "$archive_path" ]]; then + echo "apple-mail-cli archive for ${TARGET} was not produced." + exit 1 + fi + + tmpdir="$(mktemp -d)" + + case "$archive_path" in + *.tar.xz) + tar -xJf "$archive_path" -C "$tmpdir" --strip-components=1 + ;; + *.tar.gz) + tar -xzf "$archive_path" -C "$tmpdir" --strip-components=1 + ;; + *) + echo "unsupported archive format: $archive_path" + exit 1 + ;; + esac + + "$tmpdir/apple-mail" --version + "$tmpdir/apple-mail" --help + + - name: Attest release artifacts + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: target/distrib/* + + - name: Upload release artifacts + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # macOS runners ship Bash 3.2, which has no mapfile. + artifact_paths=() + while IFS= read -r artifact_path; do + artifact_paths+=("$artifact_path") + done < dist-upload-files.txt + if ((${#artifact_paths[@]} == 0)); then + echo "cargo-dist did not report uploadable files." + exit 1 + fi + + gh release upload "$TAG_NAME" "${artifact_paths[@]}" --clobber + + - name: Publish release + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: gh release edit "$TAG_NAME" --draft=false diff --git a/.github/workflows/release_please.yml b/.github/workflows/release_please.yml index 4aeddd9..88f8af5 100644 --- a/.github/workflows/release_please.yml +++ b/.github/workflows/release_please.yml @@ -4,12 +4,6 @@ on: push: branches: - main - workflow_dispatch: - inputs: - tag_name: - description: Existing release tag to rebuild artifacts for; run the workflow on the same tag - required: true - type: string permissions: {} @@ -23,16 +17,13 @@ concurrency: jobs: release-please: - if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: + actions: write contents: write issues: write pull-requests: write - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} steps: - name: Check release token env: @@ -53,145 +44,10 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - release-artifacts: - needs: release-please - if: ${{ always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }} - runs-on: macos-15 - timeout-minutes: 30 - permissions: - contents: write - id-token: write - attestations: write - env: - CARGO_DIST_VERSION: 0.30.2 - TARGET: aarch64-apple-darwin - TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || needs.release-please.outputs.tag_name }} - steps: - - name: Checkout release tag - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - persist-credentials: false - ref: ${{ env.TAG_NAME }} - - - name: Verify release ref - run: | - set -euo pipefail - - # Provenance must name the tagged commit, so manual rebuilds run on the tag. - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$GITHUB_REF" != "refs/tags/$TAG_NAME" ]]; then - echo "Run manual rebuilds on the tag itself:" - echo " gh workflow run release_please.yml --ref $TAG_NAME -f tag_name=$TAG_NAME" - exit 1 - fi - - head_sha="$(git rev-parse HEAD)" - if [[ "$head_sha" != "$GITHUB_SHA" ]]; then - echo "$TAG_NAME points to $head_sha, but this run is for $GITHUB_SHA." - exit 1 - fi - - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - toolchain: 1.98.1 - target: ${{ env.TARGET }} - rustflags: "" - - - name: Setup pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22 - - - name: Install cargo-dist - run: | - curl --proto '=https' --tlsv1.2 -LsSf \ - "https://github.com/axodotdev/cargo-dist/releases/download/v${CARGO_DIST_VERSION}/cargo-dist-installer.sh" \ - | sh - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Run tests - run: cargo test --locked - - - name: Install JXA check dependencies - run: pnpm install --frozen-lockfile - - - name: Check embedded JXA - run: pnpm run check - - - name: Build release artifacts - run: | - set -euo pipefail - - dist build \ - --tag "$TAG_NAME" \ - --target "$TARGET" \ - --artifacts=all \ - --output-format=json \ - > dist-manifest.json - dist print-upload-files-from-manifest \ - --manifest dist-manifest.json \ - > dist-upload-files.txt - - - name: Smoke test packaged archive - run: | - set -euo pipefail - - archive_path="$( - find target/distrib -maxdepth 1 -type f \ - \( -name "apple-mail-cli-${TARGET}.tar.xz" -o -name "apple-mail-cli-${TARGET}.tar.gz" \) \ - | head -n 1 - )" - - if [[ -z "$archive_path" ]]; then - echo "apple-mail-cli archive for ${TARGET} was not produced." - exit 1 - fi - - tmpdir="$(mktemp -d)" - - case "$archive_path" in - *.tar.xz) - tar -xJf "$archive_path" -C "$tmpdir" --strip-components=1 - ;; - *.tar.gz) - tar -xzf "$archive_path" -C "$tmpdir" --strip-components=1 - ;; - *) - echo "unsupported archive format: $archive_path" - exit 1 - ;; - esac - - "$tmpdir/apple-mail" --version - "$tmpdir/apple-mail" --help - - - name: Attest release artifacts - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: target/distrib/* - - - name: Upload release artifacts - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - # macOS runners ship Bash 3.2, which has no mapfile. - artifact_paths=() - while IFS= read -r artifact_path; do - artifact_paths+=("$artifact_path") - done < dist-upload-files.txt - if ((${#artifact_paths[@]} == 0)); then - echo "cargo-dist did not report uploadable files." - exit 1 - fi - - gh release upload "$TAG_NAME" "${artifact_paths[@]}" --clobber - - - name: Publish release + - name: Build artifacts on the release tag + if: ${{ steps.release.outputs.release_created == 'true' }} env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - run: gh release edit "$TAG_NAME" --draft=false + TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: gh workflow run release_artifacts.yml --ref "$TAG_NAME" -f tag_name="$TAG_NAME" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85a0dec..2a331aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,18 +54,21 @@ dependency review, and CodeQL. Releases are managed by Release Please. Merging the release PR updates `Cargo.toml`, `Cargo.lock`, `.release-please-manifest.json`, and `CHANGELOG.md`, -tags the merge commit, and creates a draft GitHub Release. The release workflow -then tests and builds the `aarch64-apple-darwin` cargo-dist archive, attests its +tags the merge commit, and creates a draft GitHub Release. The `Release Please` +workflow then starts the `Release Artifacts` workflow on that tag, which tests +and builds the `aarch64-apple-darwin` cargo-dist archive, attests its provenance, and uploads the archive, its checksum, and a shell installer. The release is published only after every step succeeds; if one fails, it stays a draft. -To rebuild the assets for an existing tag, run the workflow on that tag so the -attestation records the tagged commit: +To resume a draft release whose artifact run failed, start the same workflow on +the tag so the attestation records the tagged commit: ```bash tag=apple-mail-cli-v0.1.1 -gh workflow run release_please.yml --ref "$tag" -f tag_name="$tag" +gh workflow run release_artifacts.yml --ref "$tag" -f tag_name="$tag" ``` -The workflow fails before building when it is started from any other ref. +The workflow fails before building when it is started from any other ref or +when the release is already published. A published release is never rebuilt in +place; ship a fix as a new release. From ef186606246cadb0af4d98862c5eb9b25eaefda7 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:42:31 +0900 Subject: [PATCH 2/3] ci: let GitHub choose the latest release when publishing Per-tag concurrency lets an older tag's artifact run finish after a newer release is published. Publishing with gh release edit implies make_latest=true, so the older release would take latest and the README installer would serve it. Publish through the REST API with make_latest=legacy so GitHub picks latest by date and semantic version. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release_artifacts.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_artifacts.yml b/.github/workflows/release_artifacts.yml index cd7911b..c61715f 100644 --- a/.github/workflows/release_artifacts.yml +++ b/.github/workflows/release_artifacts.yml @@ -170,4 +170,10 @@ jobs: env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - run: gh release edit "$TAG_NAME" --draft=false + run: | + set -euo pipefail + + # Publishing without make_latest marks the release latest. An older tag's run + # can finish last, so let GitHub pick latest by date and semantic version. + release_id="$(gh release view "$TAG_NAME" --json databaseId --jq .databaseId)" + gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -f draft=false -f make_latest=legacy From 814caa95c250077affeedc173a7d71947caae8ad Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:49:34 +0900 Subject: [PATCH 3/3] fix(ci): send draft as a boolean when publishing a release gh api -f sends draft=false as the JSON string "false", but the Release API requires a boolean. Use the typed -F flag for draft and keep make_latest as a string field. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release_artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_artifacts.yml b/.github/workflows/release_artifacts.yml index c61715f..5ea8d65 100644 --- a/.github/workflows/release_artifacts.yml +++ b/.github/workflows/release_artifacts.yml @@ -176,4 +176,4 @@ jobs: # Publishing without make_latest marks the release latest. An older tag's run # can finish last, so let GitHub pick latest by date and semantic version. release_id="$(gh release view "$TAG_NAME" --json databaseId --jq .databaseId)" - gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -f draft=false -f make_latest=legacy + gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false -f make_latest=legacy