From f74090e072e298f8a93e91a10757fd9818f0bf90 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 10:13:19 +0200 Subject: [PATCH 1/8] ci(release): isolate signing from publishing Refactor the tagged release pipeline around environment-scoped Apple credentials and verified artifact handoff, and align the maintainer release documentation with the automated path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 95 +++++++++++++----- .github/workflows/smoke-test.yml | 2 +- AGENTS.md | 11 ++- README.md | 15 ++- RELEASE_CHECKLIST.md | 164 ++++++++++++++++++++----------- scripts/setup_notarization.sh | 57 +++++++---- 6 files changed, 230 insertions(+), 114 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5292f8..ca63d02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: type: string permissions: - contents: write + contents: read # One release run per tag at a time: two overlapping runs could test one set of # assets while the other replaces them before the publish job runs. @@ -21,11 +21,17 @@ concurrency: cancel-in-progress: false jobs: - macos-release: + sign-and-notarize: name: Build signed and notarized macOS artifacts - runs-on: macos-latest + runs-on: macos-15 + timeout-minutes: 90 + environment: release + permissions: + contents: read outputs: tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + artifact_name: ${{ steps.release.outputs.artifact_name }} steps: - name: Resolve release metadata id: release @@ -42,13 +48,16 @@ jobs: exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + version="${tag#v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "artifact_name=OpenWritr-v${version}-macOS-arm64" >> "$GITHUB_OUTPUT" - name: Checkout uses: actions/checkout@v7 with: ref: ${{ steps.release.outputs.tag }} fetch-depth: 0 + persist-credentials: false # Gate: nothing is built, signed, or published for a tag that cannot # describe itself. Fails before the signing certificate is imported. @@ -89,7 +98,8 @@ jobs: echo "CHANGELOG.md has no entry for $RELEASE_VERSION." >&2 exit 1 fi - echo "RELEASE_NOTES_FILE=$RUNNER_TEMP/release-notes.md" >> "$GITHUB_ENV" + mkdir -p dist + cp "$RUNNER_TEMP/release-notes.md" dist/release-notes.md - name: Import Developer ID certificate shell: bash @@ -136,7 +146,14 @@ jobs: security import "$ca_file" -k "$keychain_file" -T /usr/bin/codesign security import "$cert_file" -P "$MACOS_CERTIFICATE_PWD" -A -t cert -f pkcs12 -k "$keychain_file" security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_file" - security list-keychains -d user -s "$keychain_file" $(security list-keychains -d user | tr -d '"') + existing_keychains=() + while IFS= read -r existing_keychain; do + [[ -n "$existing_keychain" ]] && existing_keychains+=("$existing_keychain") + done < <( + security list-keychains -d user | + sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' + ) + security list-keychains -d user -s "$keychain_file" "${existing_keychains[@]}" identity="$( security find-identity -v -p codesigning "$keychain_file" | @@ -147,10 +164,12 @@ jobs: exit 1 fi - echo "CODE_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "OPENWRITR_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "KEYCHAIN_FILE=$keychain_file" >> "$GITHUB_ENV" - echo "SIGNING_DIR=$signing_dir" >> "$GITHUB_ENV" + { + echo "CODE_SIGN_IDENTITY=$identity" + echo "OPENWRITR_SIGNING_IDENTITY=$identity" + echo "KEYCHAIN_FILE=$keychain_file" + echo "SIGNING_DIR=$signing_dir" + } >> "$GITHUB_ENV" import_succeeded=true - name: Build Developer ID signed app @@ -264,7 +283,7 @@ jobs: - name: Upload workflow artifact uses: actions/upload-artifact@v7 with: - name: OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64 + name: ${{ steps.release.outputs.artifact_name }} if-no-files-found: error path: | dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.zip @@ -272,22 +291,37 @@ jobs: dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg.sha256 dist/OpenWritr-${{ steps.release.outputs.version }}.dmg + dist/release-notes.md + + create-draft: + name: Create or update the draft release + needs: sign-and-notarize + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download verified release artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.sign-and-notarize.outputs.artifact_name }} + path: release-candidate - - name: Attach assets to GitHub Release + - name: Attach verified assets to the draft release shell: bash env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} + RELEASE_VERSION: ${{ needs.sign-and-notarize.outputs.version }} run: | set -euo pipefail - asset_base="dist/OpenWritr-v${RELEASE_VERSION}-macOS-arm64" + asset_base="release-candidate/OpenWritr-v${RELEASE_VERSION}-macOS-arm64" assets=( "$asset_base.zip" "$asset_base.zip.sha256" "$asset_base.dmg" "$asset_base.dmg.sha256" - "dist/OpenWritr-${RELEASE_VERSION}.dmg" + "release-candidate/OpenWritr-${RELEASE_VERSION}.dmg" ) for asset in "${assets[@]}"; do if [[ ! -f "$asset" ]]; then @@ -300,42 +334,49 @@ jobs: # release is created as a DRAFT: nobody, including the in-app updater, # can receive it until the smoke test has passed and the publish job # below makes it public. An existing release keeps its state. - if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release create "$RELEASE_TAG" --draft --title "OpenWritr ${RELEASE_VERSION}" --notes-file "$RELEASE_NOTES_FILE" - elif [[ "$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)" == "true" ]]; then - gh release edit "$RELEASE_TAG" --title "OpenWritr ${RELEASE_VERSION}" --notes-file "$RELEASE_NOTES_FILE" + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --draft --title "OpenWritr ${RELEASE_VERSION}" \ + --notes-file release-candidate/release-notes.md + elif [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq .isDraft)" == "true" ]]; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --title "OpenWritr ${RELEASE_VERSION}" \ + --notes-file release-candidate/release-notes.md else # Replacing the assets of a public release would expose untested # files, and a failing smoke test could not take them back. echo "Release $RELEASE_TAG is already public; refusing to replace its assets. Publish a new version instead." >&2 exit 1 fi - gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + "${assets[@]}" --clobber # R05: install the uploaded DMG and exercise it, as a consumer would, BEFORE # the release becomes public. A failure leaves the draft unpublished. smoke-test: name: Smoke-test the release before publishing - needs: macos-release + needs: [sign-and-notarize, create-draft] permissions: # Draft releases are only visible to a token with write access. The # called job downloads and runs the assets; it changes nothing. contents: write uses: ./.github/workflows/smoke-test.yml with: - tag: ${{ needs.macos-release.outputs.tag }} + tag: ${{ needs.sign-and-notarize.outputs.tag }} publish: name: Publish the release - needs: [macos-release, smoke-test] - runs-on: ubuntu-latest + needs: [sign-and-notarize, create-draft, smoke-test] + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: write steps: - name: Make the tested draft public env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.macos-release.outputs.tag }} + RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} run: | set -euo pipefail gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false @@ -346,7 +387,7 @@ jobs: - name: Verify the public download is the file that was tested env: - RELEASE_TAG: ${{ needs.macos-release.outputs.tag }} + RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} TESTED_SHA256: ${{ needs.smoke-test.outputs.dmg_sha256 }} run: | set -euo pipefail diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 28f2500..c917a2a 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -35,7 +35,7 @@ permissions: jobs: smoke: name: Install and transcribe - runs-on: macos-latest + runs-on: macos-15 timeout-minutes: 30 outputs: dmg_sha256: ${{ steps.download.outputs.dmg_sha256 }} diff --git a/AGENTS.md b/AGENTS.md index 699137d..3341f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,14 +145,15 @@ swift test ## Credentials and revocation -Repository and release credentials are held as GitHub Actions secrets and are -never in the tree. If one is exposed, revoke it at its source first, then update -the secret. +Release credentials are held as secrets in the GitHub `release` environment and +are never in the tree. Only the signing/notarization job uses that environment. +If one is exposed, revoke it at its source first, then update the environment +secret. | Credential | Where it lives | If exposed | |---|---|---| -| `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD` (Developer ID Application `.p12`) | Actions secrets | Revoke the certificate in the Apple Developer portal, issue a new one, re-export the `.p12`, update both secrets. Maintainer only. | -| `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_PASSWORD` | Actions secrets | Revoke the app-specific password at appleid.apple.com, create a new one, update `APPLE_APP_PASSWORD`. Maintainer only. | +| `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD` (Developer ID Application `.p12`) | GitHub `release` environment secrets | Revoke the certificate in the Apple Developer portal, issue a new one, re-export the `.p12`, update both secrets. Maintainer only. | +| `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_PASSWORD` | GitHub `release` environment secrets | Revoke the app-specific password at appleid.apple.com, create a new one, update `APPLE_APP_PASSWORD`. Maintainer only. | | Local notary profile (`xcrun notarytool store-credentials`) | The maintainer's login keychain | Revoke the app-specific password as above and store the profile again. | | User-entered provider API keys | The user's macOS Keychain (`KeychainStore`) | The user revokes the key with the provider and enters a new one in Settings. The repository holds none. | diff --git a/README.md b/README.md index 304ac57..97b12eb 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,16 @@ The default comparison covers Apple Intelligence, Luna, Gemini Flash, MAI Flash, The release flow builds a Developer ID signed app, notarizes and staples the app bundle, packages a ZIP from that notarized app, then creates and notarizes a DMG. GitHub Releases for `v*` tags receive: -- notarized ZIP + SHA-256 checksum -- notarized DMG + SHA-256 checksum -- an additional `OpenWritr-{version}.dmg` (same signed/notarized bytes, renamed for [AppUpdater](#in-app-updates)) - -The required GitHub Actions secrets and what to do if one is exposed are listed in [AGENTS.md](AGENTS.md#credentials-and-revocation). +- `OpenWritr-v{version}-macOS-arm64.zip` +- `OpenWritr-v{version}-macOS-arm64.zip.sha256` +- `OpenWritr-v{version}-macOS-arm64.dmg` +- `OpenWritr-v{version}-macOS-arm64.dmg.sha256` +- `OpenWritr-{version}.dmg` (the same signed/notarized DMG bytes under the exact + name required by [AppUpdater](#in-app-updates)) + +The signing and notarization job reads its five credentials only from the +GitHub `release` environment. The required secret names and revocation steps are +listed in [AGENTS.md](AGENTS.md#credentials-and-revocation). For local releases, copy the example environment and store a notary profile once: diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 805720f..21ac3f8 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,80 +1,132 @@ # OpenWritr Release Checklist -Use this checklist for every tagged macOS release. +The tag-triggered GitHub Actions workflow is the canonical release path. The +maintainer prepares and tags the release; the workflow builds, signs, notarizes, +creates the draft, smoke-tests it, and publishes it. -## 1. Preflight +## One-time repository setup -- [ ] Working tree clean (`git status --short`) -- [ ] `CHANGELOG.md` has a section `## [x.y.z] — date` for this version and nothing left under Unreleased (the release workflow fails otherwise) -- [ ] `Info.plist` `CFBundleShortVersionString` and `CFBundleVersion` equal `x.y.z` (the release workflow fails otherwise) -- [ ] Developer ID identity available in keychain -- [ ] Notary profile available (`NOTARY_PROFILE=OpenWritr`) or Apple credentials set +The maintainer must create a GitHub environment named `release`, restrict its +deployment branches and tags to selected tags matching `v*`, and configure these +environment secrets: -## 2. Build + Sign + Notarize +- `MACOS_CERTIFICATE` +- `MACOS_CERTIFICATE_PWD` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_PASSWORD` -```sh -scripts/release_macos.sh -``` +These values must not remain repository-level Actions secrets after the +environment migration is verified. The environment and secret migration are +repository settings; the workflow cannot create or migrate them. -Expected outcome: +## 1. Maintainer: prepare the release -- Signed app bundle created -- App notarized and stapled -- Signed ZIP created at `dist/OpenWritr-macos.zip` -- `dist/OpenWritr-macos.zip.sha256` generated -- Signed DMG created at `dist/OpenWritr-macos.dmg` -- Notarization executed (not skipped) -- `dist/OpenWritr-macos.dmg.sha256` generated +- [ ] Work on a pull-request branch; do not release unreviewed local changes. +- [ ] Set both `CFBundleShortVersionString` and `CFBundleVersion` in `Info.plist` + to `x.y.z`. +- [ ] Add a non-empty `## [x.y.z] — YYYY-MM-DD` section to `CHANGELOG.md`. +- [ ] Leave no release entries under an `Unreleased` heading. +- [ ] Run the required validation: -## 3. Versioned Artifact Names + ```sh + swift build -c release -Xswiftc -warnings-as-errors + swiftlint lint --strict + swift test + ``` -Replace `x.y.z` with release version. +- [ ] Merge the release-preparation pull request and confirm the intended commit + is on `main`. -```sh -cp dist/OpenWritr-macos.zip dist/OpenWritr-vx.y.z-macOS-arm64.zip -cp dist/OpenWritr-macos.zip.sha256 dist/OpenWritr-vx.y.z-macOS-arm64.zip.sha256 -cp dist/OpenWritr-macos.dmg dist/OpenWritr-vx.y.z-macOS-arm64.dmg -cp dist/OpenWritr-macos.dmg.sha256 dist/OpenWritr-vx.y.z-macOS-arm64.dmg.sha256 -``` +## 2. Maintainer: create the release tag + +- [ ] Create and push `vx.y.z` at the prepared `main` commit. This explicit tag + push is the release trigger. +- [ ] Confirm the **Release macOS** workflow started for that tag. + +Do not build or upload release assets manually during the normal path. Do not +dispatch the workflow for a new release instead of pushing its tag. + +## 3. Workflow: build and publish + +The workflow performs these actions without maintainer intervention: + +1. Validates the tag, `Info.plist`, and changelog entry. +2. Uses the `release` environment to build, Developer ID-sign, notarize, staple, + and verify the app and disk image. +3. Passes the verified files to a separate job that creates or updates a + **draft** GitHub release. +4. Installs the draft DMG, verifies Gatekeeper and notarization, and runs the + transcription smoke test. +5. Publishes the draft only after the smoke test passes, then verifies the + public DMG is the tested file. + +The release contains exactly these five public assets: -## 4. Verification (must pass) +- `OpenWritr-vx.y.z-macOS-arm64.zip` +- `OpenWritr-vx.y.z-macOS-arm64.zip.sha256` +- `OpenWritr-vx.y.z-macOS-arm64.dmg` +- `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256` +- `OpenWritr-x.y.z.dmg` — the same notarized DMG bytes under the exact name + required by AppUpdater + +Release notes come from the matching `CHANGELOG.md` section. Do not write or +replace them manually. + +**Never attest `OpenWritr-x.y.z.dmg`.** OpenWritr intentionally has no updater +attestation policy; restoring one or attesting the update DMG can strand or crash +installed clients (see #31). + +## 4. Maintainer: monitor and recover + +- [ ] Confirm **Build signed and notarized macOS artifacts** passed. +- [ ] Confirm **Create or update the draft release** passed. +- [ ] Confirm **Smoke-test the release before publishing** passed. +- [ ] Confirm **Publish the release** passed. Its smoke-test job summary is the + `R05` record described in + [docs/release-smoke-tests.md](docs/release-smoke-tests.md). +- [ ] Confirm the release page is public and lists all five exact asset names. + +If signing, notarization, networking, or a runner fails transiently, rerun the +existing tag with `workflow_dispatch`, selecting the same `vx.y.z` tag as both +the workflow ref and the `tag` input. For example: ```sh -unzip -q dist/OpenWritr-vx.y.z-macOS-arm64.zip -d /tmp/openwritr-verify -xcrun stapler validate /tmp/openwritr-verify/OpenWritr.app -spctl --assess --type execute --verbose /tmp/openwritr-verify/OpenWritr.app -xcrun stapler validate dist/OpenWritr-vx.y.z-macOS-arm64.dmg -spctl --assess --type open --context context:primary-signature --verbose dist/OpenWritr-vx.y.z-macOS-arm64.dmg +gh workflow run release.yml --ref vx.y.z -f tag=vx.y.z ``` -Expected lines: +Using the tag as the workflow ref is required by the `release` environment's +`v*` deployment restriction. The rerun rebuilds the immutable tagged commit and +may replace assets only while the release remains a draft. + +The workflow refuses to overwrite an already-public release. If code, scripts, +metadata, release notes, or assets need a fix, prepare and tag a **new version**. +Never move or reuse the published tag. A failed draft may be deleted by the +maintainer before creating that new version. -- `The validate action worked!` -- `accepted` -- `source=Notarized Developer ID` +## 5. Optional local rehearsal or recovery -Optional deep check: +Local release commands are optional diagnostics, not the canonical release +procedure and not a substitute for the tag-triggered workflow. They do not +create or publish a GitHub release. + +With a local Developer ID identity and notary profile configured: ```sh -hdiutil attach -readonly -nobrowse dist/OpenWritr-vx.y.z-macOS-arm64.dmg -codesign -dv --verbose=4 /Volumes/OpenWritr/OpenWritr.app -hdiutil detach /Volumes/OpenWritr +cp .release.env.example .release.env +scripts/release_macos.sh ``` -## 5. GitHub Release - -- [ ] Push tag `vx.y.z`. The release workflow builds, signs, and notarizes, creates the GitHub release as a **draft**, smoke-tests the draft, and only then publishes it. If the smoke test fails the release stays a draft and nothing is public. Re-running the workflow for the same tag (`workflow_dispatch`) rebuilds the same commit, so it only recovers from a transient failure (runner, network, notarization service). A fix to code, scripts, or the changelog needs a **new version**: delete the draft, bump `Info.plist`, add a changelog entry, and tag again. A tag that already has a public release is refused -- [ ] Upload artifacts: - - `OpenWritr-vx.y.z-macOS-arm64.zip` - - `OpenWritr-vx.y.z-macOS-arm64.zip.sha256` - - `OpenWritr-vx.y.z-macOS-arm64.dmg` - - `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256` -- [ ] Release notes are the changelog section for the version; the release workflow publishes them, so do not write them by hand +The local script uses generic `dist/OpenWritr-macos.*` names. Verify those local +outputs directly: -## 6. Post-Release Sanity +```sh +xcrun stapler validate .build/release/OpenWritr.app +spctl --assess --type execute --verbose=2 .build/release/OpenWritr.app +xcrun stapler validate dist/OpenWritr-macos.dmg +spctl --assess --type open --context context:primary-signature \ + --verbose=2 dist/OpenWritr-macos.dmg +``` -- [ ] Download DMG from release page -- [ ] Verify checksum -- [ ] Install and launch on a clean user profile or second machine -- [ ] Confirm app starts and prompts for permissions as expected -- [ ] The `Smoke-test the release before publishing` and `Publish the release` jobs of the release run passed; its job summary is the `R05` record ([docs/release-smoke-tests.md](docs/release-smoke-tests.md)) +Do not upload locally produced files over a public release. Any recovered +release still goes through a new version and the canonical workflow. diff --git a/scripts/setup_notarization.sh b/scripts/setup_notarization.sh index ef2d557..aafb528 100755 --- a/scripts/setup_notarization.sh +++ b/scripts/setup_notarization.sh @@ -7,6 +7,7 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" RELEASE_ENV_FILE="$PROJECT_DIR/.release.env" repository="${OPENWRITR_REPOSITORY:-trsdn/OpenWritr}" +release_environment="${OPENWRITR_RELEASE_ENVIRONMENT:-release}" team_id="${APPLE_TEAM_ID:-G69Z5BNY97}" profile="${NOTARY_PROFILE:-OpenWritr}" @@ -45,17 +46,19 @@ usage() { cat <<'EOF' Usage: scripts/setup_notarization.sh [options] -Configure GitHub Actions notarization secrets and a local notarytool profile. +Configure release-environment notarization secrets and a local notarytool profile. Options: - --repo OWNER/REPO GitHub repository (default: trsdn/OpenWritr) - --team-id ID Apple Developer team ID (default: G69Z5BNY97) - --profile NAME Local notarytool profile (default: OpenWritr) - --gui Read credentials from secure macOS dialogs (no TTY needed) - -h, --help Show this help + --repo OWNER/REPO GitHub repository (default: trsdn/OpenWritr) + --environment NAME GitHub environment (default: release) + --team-id ID Apple Developer team ID (default: G69Z5BNY97) + --profile NAME Local notarytool profile (default: OpenWritr) + --gui Read credentials from secure macOS dialogs (no TTY needed) + -h, --help Show this help Environment overrides: - OPENWRITR_REPOSITORY, APPLE_TEAM_ID, NOTARY_PROFILE + OPENWRITR_REPOSITORY, OPENWRITR_RELEASE_ENVIRONMENT, APPLE_TEAM_ID, + NOTARY_PROFILE By default, credentials are read from an interactive terminal. With --gui, the Apple ID and hidden app-specific password are read from macOS dialogs. @@ -78,6 +81,15 @@ while [[ "$#" -gt 0 ]]; do repository="${1#*=}" shift ;; + --environment) + [[ "$#" -ge 2 && -n "$2" ]] || die "--environment requires a name." + release_environment="$2" + shift 2 + ;; + --environment=*) + release_environment="${1#*=}" + shift + ;; --team-id) [[ "$#" -ge 2 && -n "$2" ]] || die "--team-id requires a value." team_id="$2" @@ -112,6 +124,8 @@ done [[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \ || die "Repository must use the OWNER/REPO format." +[[ "$release_environment" =~ ^[A-Za-z0-9._-]+$ ]] \ + || die "Environment may contain only letters, digits, dots, underscores, and hyphens." [[ "$team_id" =~ ^[A-Z0-9]{10}$ ]] \ || die "Apple Developer team ID must be 10 uppercase letters or digits." [[ "$profile" =~ ^[A-Za-z0-9._-]+$ ]] \ @@ -160,6 +174,8 @@ fi xcrun --find notarytool >/dev/null 2>&1 || die "notarytool is not available through xcrun." gh auth status >/dev/null 2>&1 || die "GitHub CLI authentication is required; run 'gh auth login'." +gh api "repos/$repository/environments/$release_environment" >/dev/null 2>&1 \ + || die "GitHub environment '$release_environment' does not exist in $repository." if [[ -L "$RELEASE_ENV_FILE" ]]; then die ".release.env must not be a symbolic link." @@ -171,11 +187,11 @@ load_secret_names() { if ! secret_names="$( gh secret list \ --repo "$repository" \ - --app actions \ + --env "$release_environment" \ --json name \ --jq '.[].name' )"; then - die "Unable to list GitHub Actions secrets for $repository." + die "Unable to list secrets for environment '$release_environment' in $repository." fi } @@ -198,8 +214,8 @@ for secret_name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PWD; do done if [[ "${#missing_certificate_secrets[@]}" -gt 0 ]]; then - printf 'Error: Required certificate secret(s) missing in %s: %s\n' \ - "$repository" "${missing_certificate_secrets[*]}" >&2 + printf 'Error: Required certificate secret(s) missing in %s environment %s: %s\n' \ + "$repository" "$release_environment" "${missing_certificate_secrets[*]}" >&2 printf 'Configure the existing Developer ID certificate separately; this script never exports private keys.\n' >&2 exit 1 fi @@ -319,18 +335,18 @@ apple_app_password_confirmation="" unset apple_app_password_confirmation if ! printf '%s' "$apple_id" \ - | gh secret set APPLE_ID --repo "$repository" --app actions >/dev/null; then - die "Failed to set the APPLE_ID GitHub Actions secret." + | gh secret set APPLE_ID --repo "$repository" --env "$release_environment" >/dev/null; then + die "Failed to set APPLE_ID in environment '$release_environment'." fi if ! printf '%s' "$team_id" \ - | gh secret set APPLE_TEAM_ID --repo "$repository" --app actions >/dev/null; then - die "Failed to set the APPLE_TEAM_ID GitHub Actions secret." + | gh secret set APPLE_TEAM_ID --repo "$repository" --env "$release_environment" >/dev/null; then + die "Failed to set APPLE_TEAM_ID in environment '$release_environment'." fi if ! printf '%s' "$apple_app_password" \ - | gh secret set APPLE_APP_PASSWORD --repo "$repository" --app actions >/dev/null; then + | gh secret set APPLE_APP_PASSWORD --repo "$repository" --env "$release_environment" >/dev/null; then apple_app_password="" unset apple_app_password - die "Failed to set the APPLE_APP_PASSWORD GitHub Actions secret." + die "Failed to set APPLE_APP_PASSWORD in environment '$release_environment'." fi run_notarytool_gui() { @@ -595,8 +611,8 @@ for secret_name in \ done if [[ "${#missing_required_secrets[@]}" -gt 0 ]]; then - printf 'Error: Required GitHub Actions secret name(s) not found: %s\n' \ - "${missing_required_secrets[*]}" >&2 + printf 'Error: Required secret name(s) not found in environment %s: %s\n' \ + "$release_environment" "${missing_required_secrets[*]}" >&2 exit 1 fi @@ -639,5 +655,6 @@ if [[ -z "$signing_identity" ]]; then printf '.release.env contains only the validated notary profile.\n' >&2 fi -printf 'Notarization credentials configured successfully for %s.\n' "$repository" +printf 'Notarization credentials configured successfully for %s environment %s.\n' \ + "$repository" "$release_environment" printf 'Local configuration written to %s with mode 600.\n' "$RELEASE_ENV_FILE" From d7a9f5436f36540c196440be1ed36a7e75fed5a9 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 10:30:11 +0200 Subject: [PATCH 2/8] fix(release): bind builds to triggering tags Reject non-tag manual dispatches before the release environment is used, checkout and verify the fully qualified tag commit, and prevent GitHub CLI from creating a missing tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 109 ++++++++++++++++++++++------------ RELEASE_CHECKLIST.md | 13 ++-- 2 files changed, 79 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca63d02..4365673 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,6 @@ on: tags: - "v*" workflow_dispatch: - inputs: - tag: - description: Existing tag to build, for example v1.2.1 - required: true - type: string permissions: contents: read @@ -17,54 +12,92 @@ permissions: # One release run per tag at a time: two overlapping runs could test one set of # assets while the other replaces them before the publish job runs. concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + group: release-${{ github.ref }} cancel-in-progress: false jobs: - sign-and-notarize: - name: Build signed and notarized macOS artifacts - runs-on: macos-15 - timeout-minutes: 90 - environment: release - permissions: - contents: read + validate-release-ref: + name: Validate release tag reference + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} outputs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} artifact_name: ${{ steps.release.outputs.artifact_name }} steps: - - name: Resolve release metadata + - name: Resolve release metadata from the triggering tag id: release shell: bash run: | set -euo pipefail - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - tag="${{ inputs.tag }}" - else - tag="${GITHUB_REF_NAME}" + if [[ "$GITHUB_REF_TYPE" != "tag" || "$GITHUB_REF" != refs/tags/* ]]; then + echo "Release runs must be triggered from an existing tag, not $GITHUB_REF." >&2 + exit 1 + fi + + tag="$GITHUB_REF_NAME" + if [[ "$GITHUB_REF" != "refs/tags/$tag" ]]; then + echo "Release ref does not match its tag name: $GITHUB_REF" >&2 + exit 1 fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then - echo "Release tag must be a version such as v1.2.1: $tag" + echo "Release tag must be a version such as v1.2.1: $tag" >&2 exit 1 fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" + version="${tag#v}" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "artifact_name=OpenWritr-v${version}-macOS-arm64" >> "$GITHUB_OUTPUT" + { + echo "tag=$tag" + echo "version=$version" + echo "artifact_name=OpenWritr-v${version}-macOS-arm64" + } >> "$GITHUB_OUTPUT" + sign-and-notarize: + name: Build signed and notarized macOS artifacts + needs: validate-release-ref + runs-on: macos-15 + timeout-minutes: 90 + environment: release + permissions: + contents: read + outputs: + tag: ${{ needs.validate-release-ref.outputs.tag }} + version: ${{ needs.validate-release-ref.outputs.version }} + artifact_name: ${{ needs.validate-release-ref.outputs.artifact_name }} + steps: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ steps.release.outputs.tag }} + ref: refs/tags/${{ needs.validate-release-ref.outputs.tag }} fetch-depth: 0 persist-credentials: false + - name: Verify checkout matches the release tag + shell: bash + env: + RELEASE_TAG: ${{ needs.validate-release-ref.outputs.tag }} + run: | + set -euo pipefail + tag_ref="refs/tags/$RELEASE_TAG" + if ! git show-ref --verify --quiet "$tag_ref"; then + echo "Checked-out repository does not contain $tag_ref." >&2 + exit 1 + fi + + tag_commit="$(git rev-parse --verify "${tag_ref}^{commit}")" + checked_out_commit="$(git rev-parse --verify HEAD)" + if [[ "$checked_out_commit" != "$tag_commit" ]]; then + echo "Checked-out HEAD $checked_out_commit does not match $tag_ref ($tag_commit)." >&2 + exit 1 + fi + # Gate: nothing is built, signed, or published for a tag that cannot # describe itself. Fails before the signing certificate is imported. - name: Verify bundle version and changelog entry shell: bash env: - RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_VERSION: ${{ needs.validate-release-ref.outputs.version }} run: | set -euo pipefail @@ -175,7 +208,7 @@ jobs: - name: Build Developer ID signed app shell: bash env: - OPENWRITR_VERSION: ${{ steps.release.outputs.version }} + OPENWRITR_VERSION: ${{ needs.validate-release-ref.outputs.version }} run: | set -euo pipefail chmod +x scripts/*.sh @@ -188,7 +221,7 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} APP_PATH: .build/release/OpenWritr.app - ZIP_PATH: dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.zip + ZIP_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip run: | set -euo pipefail scripts/notarize_app.sh @@ -197,7 +230,7 @@ jobs: shell: bash env: APP_PATH: .build/release/OpenWritr.app - DMG_PATH: dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg + DMG_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg REQUIRE_NOTARIZED_APP: "true" run: | set -euo pipefail @@ -209,7 +242,7 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - DMG_PATH: dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg + DMG_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg run: | set -euo pipefail scripts/notarize_dmg.sh @@ -217,7 +250,7 @@ jobs: - name: Create AppUpdater-named DMG copy shell: bash env: - RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_VERSION: ${{ needs.validate-release-ref.outputs.version }} run: | set -euo pipefail # AppUpdater (in-app updater) expects a release DMG named @@ -247,8 +280,8 @@ jobs: shell: bash env: APP_PATH: .build/release/OpenWritr.app - ASSET_BASE: dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64 - APPUPDATER_DMG: dist/OpenWritr-${{ steps.release.outputs.version }}.dmg + ASSET_BASE: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64 + APPUPDATER_DMG: dist/OpenWritr-${{ needs.validate-release-ref.outputs.version }}.dmg run: | set -euo pipefail artifacts=( @@ -283,14 +316,14 @@ jobs: - name: Upload workflow artifact uses: actions/upload-artifact@v7 with: - name: ${{ steps.release.outputs.artifact_name }} + name: ${{ needs.validate-release-ref.outputs.artifact_name }} if-no-files-found: error path: | - dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.zip - dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.zip.sha256 - dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg - dist/OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64.dmg.sha256 - dist/OpenWritr-${{ steps.release.outputs.version }}.dmg + dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip + dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip.sha256 + dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg + dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg.sha256 + dist/OpenWritr-${{ needs.validate-release-ref.outputs.version }}.dmg dist/release-notes.md create-draft: @@ -336,7 +369,7 @@ jobs: # below makes it public. An existing release keeps its state. if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --draft --title "OpenWritr ${RELEASE_VERSION}" \ + --verify-tag --draft --title "OpenWritr ${RELEASE_VERSION}" \ --notes-file release-candidate/release-notes.md elif [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --json isDraft --jq .isDraft)" == "true" ]]; then diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 21ac3f8..ca63d3a 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -88,16 +88,19 @@ installed clients (see #31). - [ ] Confirm the release page is public and lists all five exact asset names. If signing, notarization, networking, or a runner fails transiently, rerun the -existing tag with `workflow_dispatch`, selecting the same `vx.y.z` tag as both -the workflow ref and the `tag` input. For example: +existing tag with `workflow_dispatch`, selecting that `vx.y.z` tag as the +workflow ref. For example: ```sh -gh workflow run release.yml --ref vx.y.z -f tag=vx.y.z +gh workflow run release.yml --ref vx.y.z ``` Using the tag as the workflow ref is required by the `release` environment's -`v*` deployment restriction. The rerun rebuilds the immutable tagged commit and -may replace assets only while the release remains a draft. +`v*` deployment restriction. There is no independent version input: the workflow +derives the release identity from the triggering tag, checks out its fully +qualified `refs/tags/vx.y.z` ref, and verifies that `HEAD` is that tag's commit. +The rerun rebuilds the immutable tagged commit and may replace assets only while +the release remains a draft. The workflow refuses to overwrite an already-public release. If code, scripts, metadata, release notes, or assets need a fix, prepare and tag a **new version**. From 49068307405541601d219c76e8348d6e338a1d86 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:03:48 +0200 Subject: [PATCH 3/8] fix(release): bind publication to trigger SHA Reject moved-tag races at checkout and draft mutation, and smoke-test the immutable workflow artifact with read-only repository access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 52 +++++++++++++++++++++---- .github/workflows/smoke-test.yml | 65 +++++++++++++++++++++++--------- RELEASE_CHECKLIST.md | 13 ++++--- docs/release-smoke-tests.md | 14 ++++--- 4 files changed, 110 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4365673..bd618f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} artifact_name: ${{ steps.release.outputs.artifact_name }} + triggering_sha: ${{ steps.release.outputs.triggering_sha }} steps: - name: Resolve release metadata from the triggering tag id: release @@ -47,10 +48,15 @@ jobs: fi version="${tag#v}" + if [[ ! "$GITHUB_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Trigger SHA is not a full Git commit SHA: $GITHUB_SHA" >&2 + exit 1 + fi { echo "tag=$tag" echo "version=$version" echo "artifact_name=OpenWritr-v${version}-macOS-arm64" + echo "triggering_sha=$GITHUB_SHA" } >> "$GITHUB_OUTPUT" sign-and-notarize: @@ -65,6 +71,7 @@ jobs: tag: ${{ needs.validate-release-ref.outputs.tag }} version: ${{ needs.validate-release-ref.outputs.version }} artifact_name: ${{ needs.validate-release-ref.outputs.artifact_name }} + triggering_sha: ${{ needs.validate-release-ref.outputs.triggering_sha }} steps: - name: Checkout uses: actions/checkout@v7 @@ -77,6 +84,7 @@ jobs: shell: bash env: RELEASE_TAG: ${{ needs.validate-release-ref.outputs.tag }} + TRIGGERING_SHA: ${{ needs.validate-release-ref.outputs.triggering_sha }} run: | set -euo pipefail tag_ref="refs/tags/$RELEASE_TAG" @@ -87,8 +95,12 @@ jobs: tag_commit="$(git rev-parse --verify "${tag_ref}^{commit}")" checked_out_commit="$(git rev-parse --verify HEAD)" - if [[ "$checked_out_commit" != "$tag_commit" ]]; then - echo "Checked-out HEAD $checked_out_commit does not match $tag_ref ($tag_commit)." >&2 + if [[ "$checked_out_commit" != "$TRIGGERING_SHA" ]]; then + echo "Checked-out HEAD $checked_out_commit does not match triggering SHA $TRIGGERING_SHA." >&2 + exit 1 + fi + if [[ "$tag_commit" != "$TRIGGERING_SHA" ]]; then + echo "$tag_ref resolves to $tag_commit, not triggering SHA $TRIGGERING_SHA." >&2 exit 1 fi @@ -346,6 +358,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} RELEASE_VERSION: ${{ needs.sign-and-notarize.outputs.version }} + TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} run: | set -euo pipefail asset_base="release-candidate/OpenWritr-v${RELEASE_VERSION}-macOS-arm64" @@ -363,6 +376,31 @@ jobs: fi done + read -r remote_type remote_sha < <( + gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv' + ) + tag_depth=0 + while [[ "$remote_type" == "tag" ]]; do + ((tag_depth += 1)) + if ((tag_depth > 5)); then + echo "Remote tag $RELEASE_TAG has too many nested tag objects." >&2 + exit 1 + fi + read -r remote_type remote_sha < <( + gh api "repos/$GITHUB_REPOSITORY/git/tags/$remote_sha" \ + --jq '[.object.type, .object.sha] | @tsv' + ) + done + if [[ "$remote_type" != "commit" ]]; then + echo "Remote tag $RELEASE_TAG resolves to $remote_type, not a commit." >&2 + exit 1 + fi + if [[ "$remote_sha" != "$TRIGGERING_SHA" ]]; then + echo "Remote tag $RELEASE_TAG moved from $TRIGGERING_SHA to $remote_sha." >&2 + exit 1 + fi + # Notes come from the changelog entry checked by the gate above. A new # release is created as a DRAFT: nobody, including the in-app updater, # can receive it until the smoke test has passed and the publish job @@ -385,18 +423,18 @@ jobs: gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ "${assets[@]}" --clobber - # R05: install the uploaded DMG and exercise it, as a consumer would, BEFORE - # the release becomes public. A failure leaves the draft unpublished. + # R05: install the verified workflow artifact attached to the draft and + # exercise it, as a consumer would, BEFORE the release becomes public. + # A failure leaves the draft unpublished. smoke-test: name: Smoke-test the release before publishing needs: [sign-and-notarize, create-draft] permissions: - # Draft releases are only visible to a token with write access. The - # called job downloads and runs the assets; it changes nothing. - contents: write + contents: read uses: ./.github/workflows/smoke-test.yml with: tag: ${{ needs.sign-and-notarize.outputs.tag }} + artifact_name: ${{ needs.sign-and-notarize.outputs.artifact_name }} publish: name: Publish the release diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index c917a2a..3239bea 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -4,23 +4,27 @@ name: Release smoke test # core function (speech-to-text) through the app's non-interactive --self-test # entry point. The job summary is the dated record for criterion R05. # -# release.yml runs it against the DRAFT release, before it is made public, so a -# failing build is never offered to anyone. It also runs on demand for any tag. -# The downloaded assets are the same files a consumer gets after publishing; the -# publish job re-verifies the public download against its checksum. +# release.yml runs it against the verified workflow artifact attached to the +# DRAFT release, before the draft is made public, so a failing build is never +# offered to anyone. It also runs on demand for any published tag. The publish +# job re-verifies the public download against the tested artifact's checksum. # Releases before the one that introduced --self-test fail the check below. on: workflow_dispatch: inputs: tag: - description: Release tag to test, draft or published, for example v1.6.5 + description: Published release tag to test, for example v1.6.5 required: true type: string workflow_call: inputs: tag: - description: Release tag to test, draft or published + description: Release tag represented by the workflow artifact + required: true + type: string + artifact_name: + description: Workflow artifact containing the verified release files required: true type: string outputs: @@ -29,8 +33,7 @@ on: value: ${{ jobs.smoke.outputs.dmg_sha256 }} permissions: - # Drafts are only visible with write access; this workflow only downloads. - contents: write + contents: read jobs: smoke: @@ -38,13 +41,11 @@ jobs: runs-on: macos-15 timeout-minutes: 30 outputs: - dmg_sha256: ${{ steps.download.outputs.dmg_sha256 }} + dmg_sha256: ${{ steps.verify-download.outputs.dmg_sha256 }} steps: - - name: Download the release DMG and checksum - id: download + - name: Validate release tag shell: bash env: - GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} run: | set -euo pipefail @@ -52,21 +53,51 @@ jobs: echo "Tag must look like v1.2.3: $TAG" >&2 exit 1 fi + + - name: Download verified workflow artifact + if: github.event_name == 'workflow_call' + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact_name }} + path: dl + + - name: Download published release DMG and checksum + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail mkdir -p dl gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dl \ --pattern "OpenWritr-v*-macOS-arm64.dmg" \ --pattern "OpenWritr-v*-macOS-arm64.dmg.sha256" - (cd dl && shasum -a 256 -c ./*.dmg.sha256) - echo "dmg_sha256=$(shasum -a 256 dl/*.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Verify downloaded DMG + id: verify-download + shell: bash + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + dmg="dl/OpenWritr-${TAG}-macOS-arm64.dmg" + checksum="$dmg.sha256" + if [[ ! -f "$dmg" || ! -f "$checksum" ]]; then + echo "Expected smoke-test DMG or checksum is missing for $TAG." >&2 + exit 1 + fi + (cd dl && shasum -a 256 -c "$(basename "$checksum")") + echo "DMG_PATH=$dmg" >> "$GITHUB_ENV" + echo "dmg_sha256=$(shasum -a 256 "$dmg" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Install to /Applications shell: bash run: | set -euo pipefail - dmg="$(ls dl/*.dmg)" mountpoint="$RUNNER_TEMP/openwritr-dmg" mkdir -p "$mountpoint" - hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mountpoint" + hdiutil attach "$DMG_PATH" -nobrowse -readonly -mountpoint "$mountpoint" rm -rf /Applications/OpenWritr.app ditto "$mountpoint/OpenWritr.app" /Applications/OpenWritr.app hdiutil detach "$mountpoint" @@ -109,7 +140,7 @@ jobs: echo "|---|---|" echo "| Date | $(date -u +%F) |" echo "| Version | $version ($TAG) |" - echo "| Asset | $(basename "$(ls dl/*.dmg)") from the GitHub release (draft or public), checksum verified |" + echo "| Asset | $(basename "$DMG_PATH"), checksum verified before installation |" echo "| macOS | $(sw_vers -productVersion) on $(uname -m) |" echo "| Exercised | Installed to /Applications, Gatekeeper and notarization checks, transcribed a synthesized phrase with the shipped speech model and required the words: hello world smoke test |" echo "| Result | passed |" diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ca63d3a..9f897bc 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -2,7 +2,8 @@ The tag-triggered GitHub Actions workflow is the canonical release path. The maintainer prepares and tags the release; the workflow builds, signs, notarizes, -creates the draft, smoke-tests it, and publishes it. +creates the draft, smoke-tests the verified workflow artifact attached to it, +and publishes it. ## One-time repository setup @@ -51,12 +52,14 @@ dispatch the workflow for a new release instead of pushing its tag. The workflow performs these actions without maintainer intervention: -1. Validates the tag, `Info.plist`, and changelog entry. +1. Validates the triggering tag and commit, `Info.plist`, and changelog entry. 2. Uses the `release` environment to build, Developer ID-sign, notarize, staple, and verify the app and disk image. -3. Passes the verified files to a separate job that creates or updates a - **draft** GitHub release. -4. Installs the draft DMG, verifies Gatekeeper and notarization, and runs the +3. Resolves the live remote tag again, requires it still points to the triggering + commit, and passes the verified files to a separate job that creates or + updates a **draft** GitHub release. +4. Downloads the same immutable workflow artifact without release-write access, + installs its DMG, verifies Gatekeeper and notarization, and runs the transcription smoke test. 5. Publishes the draft only after the smoke test passes, then verifies the public DMG is the tested file. diff --git a/docs/release-smoke-tests.md b/docs/release-smoke-tests.md index 4111760..1538c1f 100644 --- a/docs/release-smoke-tests.md +++ b/docs/release-smoke-tests.md @@ -7,12 +7,13 @@ maintainer has to. ## How [`smoke-test.yml`](../.github/workflows/smoke-test.yml) runs in every release -(called from `release.yml`) against the **draft** release, before it is made public, -and on demand for any tag (`Actions → Release smoke -test → Run workflow`). It: +(called from `release.yml`) against the verified workflow artifact that was +attached to the **draft** release, before it is made public. It also runs on +demand for any published tag (`Actions → Release smoke test → Run workflow`). It: -1. downloads the DMG and its checksum from the GitHub release (a draft needs the - workflow's write token; the files are the ones a consumer gets after publishing) and verifies the checksum; +1. downloads the DMG and checksum from the current workflow artifact during a + release run, or from a published GitHub release during an on-demand run, and + verifies the checksum; 2. installs the app to `/Applications`; 3. checks the signature, Gatekeeper assessment, and notarization ticket; 4. synthesizes a spoken phrase, runs the installed app with `--self-test`, and @@ -25,6 +26,9 @@ downloads the DMG through its public URL to verify it against its checksum. If t smoke test fails, the release stays a draft, so nobody, including the in-app updater, is offered a build that could not be installed and run. +The smoke-test job has only `contents: read`. It never needs permission to +create, edit, upload to, or publish a GitHub release. + The dated record is the job summary of each run: version, asset, macOS, what was exercised, the transcript, and the result. Find it under the workflow run for the release tag. From 36f4284676e6628e87936025b5afabb0b0e23788 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:28:43 +0200 Subject: [PATCH 4/8] fix(release): revalidate tag before publish Reuse a fail-closed remote tag resolver before both draft mutation and final publication so a tag moved during smoke testing cannot be published. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 45 +++++++++++------------- RELEASE_CHECKLIST.md | 5 +-- docs/release-smoke-tests.md | 9 ++--- scripts/verify_remote_release_tag.sh | 52 ++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 30 deletions(-) create mode 100755 scripts/verify_remote_release_tag.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd618f8..3b3b2d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -346,6 +346,14 @@ jobs: permissions: contents: write steps: + - name: Checkout tag verification helper + uses: actions/checkout@v7 + with: + ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} + persist-credentials: false + sparse-checkout: scripts/verify_remote_release_tag.sh + sparse-checkout-cone-mode: false + - name: Download verified release artifacts uses: actions/download-artifact@v8 with: @@ -376,30 +384,8 @@ jobs: fi done - read -r remote_type remote_sha < <( - gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '[.object.type, .object.sha] | @tsv' - ) - tag_depth=0 - while [[ "$remote_type" == "tag" ]]; do - ((tag_depth += 1)) - if ((tag_depth > 5)); then - echo "Remote tag $RELEASE_TAG has too many nested tag objects." >&2 - exit 1 - fi - read -r remote_type remote_sha < <( - gh api "repos/$GITHUB_REPOSITORY/git/tags/$remote_sha" \ - --jq '[.object.type, .object.sha] | @tsv' - ) - done - if [[ "$remote_type" != "commit" ]]; then - echo "Remote tag $RELEASE_TAG resolves to $remote_type, not a commit." >&2 - exit 1 - fi - if [[ "$remote_sha" != "$TRIGGERING_SHA" ]]; then - echo "Remote tag $RELEASE_TAG moved from $TRIGGERING_SHA to $remote_sha." >&2 - exit 1 - fi + bash scripts/verify_remote_release_tag.sh \ + "$RELEASE_TAG" "$TRIGGERING_SHA" "$GITHUB_REPOSITORY" # Notes come from the changelog entry checked by the gate above. A new # release is created as a DRAFT: nobody, including the in-app updater, @@ -444,12 +430,23 @@ jobs: permissions: contents: write steps: + - name: Checkout tag verification helper + uses: actions/checkout@v7 + with: + ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} + persist-credentials: false + sparse-checkout: scripts/verify_remote_release_tag.sh + sparse-checkout-cone-mode: false + - name: Make the tested draft public env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} + TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} run: | set -euo pipefail + bash scripts/verify_remote_release_tag.sh \ + "$RELEASE_TAG" "$TRIGGERING_SHA" "$GITHUB_REPOSITORY" gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false if [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" != "false" ]]; then echo "Release $RELEASE_TAG is still a draft." >&2 diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 9f897bc..5a22312 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -61,8 +61,9 @@ The workflow performs these actions without maintainer intervention: 4. Downloads the same immutable workflow artifact without release-write access, installs its DMG, verifies Gatekeeper and notarization, and runs the transcription smoke test. -5. Publishes the draft only after the smoke test passes, then verifies the - public DMG is the tested file. +5. After the smoke test, resolves the live remote tag again and requires it + still points to the triggering commit before publishing the draft, then + verifies the public DMG is the tested file. The release contains exactly these five public assets: diff --git a/docs/release-smoke-tests.md b/docs/release-smoke-tests.md index 1538c1f..1aa6753 100644 --- a/docs/release-smoke-tests.md +++ b/docs/release-smoke-tests.md @@ -21,10 +21,11 @@ demand for any published tag (`Actions → Release smoke test → Run workflow`) shipped speech model and transcribes the file through the same code path as a recording, without a microphone (`Sources/OpenWritr/SelfTest.swift`). -After it passes, `release.yml`'s `publish` job makes the draft public and -downloads the DMG through its public URL to verify it against its checksum. If the -smoke test fails, the release stays a draft, so nobody, including the in-app -updater, is offered a build that could not be installed and run. +After it passes, `release.yml`'s `publish` job re-resolves the remote tag and +requires it still points to the commit that triggered the release before making +the draft public. It then downloads the DMG through its public URL to verify it +against its checksum. If the smoke test fails or the tag moved, the release stays +a draft, so nobody, including the in-app updater, is offered an unverified build. The smoke-test job has only `contents: read`. It never needs permission to create, edit, upload to, or publish a GitHub release. diff --git a/scripts/verify_remote_release_tag.sh b/scripts/verify_remote_release_tag.sh new file mode 100755 index 0000000..72a5255 --- /dev/null +++ b/scripts/verify_remote_release_tag.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_tag="${1:-}" +expected_sha="${2:-}" +repository="${3:-${GITHUB_REPOSITORY:-}}" + +if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a version such as v1.2.1: $release_tag" >&2 + exit 1 +fi +if [[ ! "$expected_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Expected release commit is not a full Git SHA: $expected_sha" >&2 + exit 1 +fi +if [[ ! "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Repository must use the OWNER/REPO format: $repository" >&2 + exit 1 +fi +command -v gh >/dev/null 2>&1 || { + echo "GitHub CLI is required to resolve the remote release tag." >&2 + exit 1 +} + +read -r remote_type remote_sha < <( + gh api "repos/$repository/git/ref/tags/$release_tag" \ + --jq '[.object.type, .object.sha] | @tsv' +) + +tag_depth=0 +while [[ "$remote_type" == "tag" ]]; do + ((tag_depth += 1)) + if ((tag_depth > 5)); then + echo "Remote tag $release_tag has too many nested tag objects." >&2 + exit 1 + fi + read -r remote_type remote_sha < <( + gh api "repos/$repository/git/tags/$remote_sha" \ + --jq '[.object.type, .object.sha] | @tsv' + ) +done + +if [[ "$remote_type" != "commit" ]]; then + echo "Remote tag $release_tag resolves to $remote_type, not a commit." >&2 + exit 1 +fi +if [[ "$remote_sha" != "$expected_sha" ]]; then + echo "Remote tag $release_tag moved from $expected_sha to $remote_sha." >&2 + exit 1 +fi + +echo "Remote tag $release_tag still resolves to triggering commit $expected_sha." From acc4589601a115c6fe7b7648a058c31bf80b1190 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:39:16 +0200 Subject: [PATCH 5/8] fix(ci): pin release workflow actions Pin the release artifact supply chain to reviewed action commits and select reusable smoke-test downloads from the artifact input rather than the caller event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 10 +++++----- .github/workflows/smoke-test.yml | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b3b2d0..11adb39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: triggering_sha: ${{ needs.validate-release-ref.outputs.triggering_sha }} steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: refs/tags/${{ needs.validate-release-ref.outputs.tag }} fetch-depth: 0 @@ -326,7 +326,7 @@ jobs: ) - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ needs.validate-release-ref.outputs.artifact_name }} if-no-files-found: error @@ -347,7 +347,7 @@ jobs: contents: write steps: - name: Checkout tag verification helper - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} persist-credentials: false @@ -355,7 +355,7 @@ jobs: sparse-checkout-cone-mode: false - name: Download verified release artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.sign-and-notarize.outputs.artifact_name }} path: release-candidate @@ -431,7 +431,7 @@ jobs: contents: write steps: - name: Checkout tag verification helper - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} persist-credentials: false diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 3239bea..417ee52 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -55,14 +55,14 @@ jobs: fi - name: Download verified workflow artifact - if: github.event_name == 'workflow_call' - uses: actions/download-artifact@v8 + if: inputs.artifact_name != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.artifact_name }} path: dl - name: Download published release DMG and checksum - if: github.event_name == 'workflow_dispatch' + if: inputs.artifact_name == '' shell: bash env: GH_TOKEN: ${{ github.token }} From 9714469f5ebc03fb3534cee6c6b39dc46c371548 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:44:59 +0200 Subject: [PATCH 6/8] docs(release): verify versioned local artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- RELEASE_CHECKLIST.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 5a22312..51ee36d 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -121,18 +121,19 @@ With a local Developer ID identity and notary profile configured: ```sh cp .release.env.example .release.env -scripts/release_macos.sh +version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Info.plist)" +scripts/release_macos.sh "$version" ``` -The local script uses generic `dist/OpenWritr-macos.*` names. Verify those local -outputs directly: +The local script uses the versioned asset base +`dist/OpenWritr-v${version}-macOS-arm64`. Verify those local outputs directly: ```sh xcrun stapler validate .build/release/OpenWritr.app spctl --assess --type execute --verbose=2 .build/release/OpenWritr.app -xcrun stapler validate dist/OpenWritr-macos.dmg +xcrun stapler validate "dist/OpenWritr-v${version}-macOS-arm64.dmg" spctl --assess --type open --context context:primary-signature \ - --verbose=2 dist/OpenWritr-macos.dmg + --verbose=2 "dist/OpenWritr-v${version}-macOS-arm64.dmg" ``` Do not upload locally produced files over a public release. Any recovered From db0c36aaf0314c0692da77e22fb1a5d3f85192f6 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 12:27:38 +0200 Subject: [PATCH 7/8] fix(release): enforce exact asset set Fail closed on unexpected draft assets before upload, verify the exact five assets after upload, and recheck the contract immediately before publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/release.yml | 29 ++++++++- RELEASE_CHECKLIST.md | 8 ++- scripts/verify_release_asset_contract.sh | 80 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100755 scripts/verify_release_asset_contract.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11adb39..b07cb00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -351,7 +351,9 @@ jobs: with: ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} persist-credentials: false - sparse-checkout: scripts/verify_remote_release_tag.sh + sparse-checkout: | + scripts/verify_release_asset_contract.sh + scripts/verify_remote_release_tag.sh sparse-checkout-cone-mode: false - name: Download verified release artifacts @@ -377,6 +379,13 @@ jobs: "$asset_base.dmg.sha256" "release-candidate/OpenWritr-${RELEASE_VERSION}.dmg" ) + asset_names=( + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip.sha256" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg.sha256" + "OpenWritr-${RELEASE_VERSION}.dmg" + ) for asset in "${assets[@]}"; do if [[ ! -f "$asset" ]]; then echo "Expected release asset is missing: $asset" @@ -406,8 +415,12 @@ jobs: echo "Release $RELEASE_TAG is already public; refusing to replace its assets. Publish a new version instead." >&2 exit 1 fi + bash scripts/verify_release_asset_contract.sh \ + subset "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ "${assets[@]}" --clobber + bash scripts/verify_release_asset_contract.sh \ + exact "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" # R05: install the verified workflow artifact attached to the draft and # exercise it, as a consumer would, BEFORE the release becomes public. @@ -435,18 +448,30 @@ jobs: with: ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} persist-credentials: false - sparse-checkout: scripts/verify_remote_release_tag.sh + sparse-checkout: | + scripts/verify_release_asset_contract.sh + scripts/verify_remote_release_tag.sh sparse-checkout-cone-mode: false - name: Make the tested draft public env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} + RELEASE_VERSION: ${{ needs.sign-and-notarize.outputs.version }} TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} run: | set -euo pipefail bash scripts/verify_remote_release_tag.sh \ "$RELEASE_TAG" "$TRIGGERING_SHA" "$GITHUB_REPOSITORY" + asset_names=( + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip.sha256" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg" + "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg.sha256" + "OpenWritr-${RELEASE_VERSION}.dmg" + ) + bash scripts/verify_release_asset_contract.sh \ + exact "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false if [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" != "false" ]]; then echo "Release $RELEASE_TAG is still a draft." >&2 diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 51ee36d..9002028 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -57,13 +57,15 @@ The workflow performs these actions without maintainer intervention: and verify the app and disk image. 3. Resolves the live remote tag again, requires it still points to the triggering commit, and passes the verified files to a separate job that creates or - updates a **draft** GitHub release. + updates a **draft** GitHub release. A new draft may be empty; a rerun may + contain only the five expected asset names. Any unexpected stale asset fails + the workflow instead of being published. 4. Downloads the same immutable workflow artifact without release-write access, installs its DMG, verifies Gatekeeper and notarization, and runs the transcription smoke test. 5. After the smoke test, resolves the live remote tag again and requires it - still points to the triggering commit before publishing the draft, then - verifies the public DMG is the tested file. + still points to the triggering commit and rechecks the exact five-asset set + before publishing the draft, then verifies the public DMG is the tested file. The release contains exactly these five public assets: diff --git a/scripts/verify_release_asset_contract.sh b/scripts/verify_release_asset_contract.sh new file mode 100755 index 0000000..d328a10 --- /dev/null +++ b/scripts/verify_release_asset_contract.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-}" +release_tag="${2:-}" +repository="${3:-${GITHUB_REPOSITORY:-}}" +shift_count=3 + +if [[ "$mode" != "subset" && "$mode" != "exact" ]]; then + echo "Asset verification mode must be subset or exact: $mode" >&2 + exit 1 +fi +if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a version such as v1.2.1: $release_tag" >&2 + exit 1 +fi +if [[ ! "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Repository must use the OWNER/REPO format: $repository" >&2 + exit 1 +fi +if (($# <= shift_count)); then + echo "At least one expected release asset name is required." >&2 + exit 1 +fi +command -v gh >/dev/null 2>&1 || { + echo "GitHub CLI is required to verify release assets." >&2 + exit 1 +} + +contains_name() { + local expected="$1" + local candidate + shift + for candidate in "$@"; do + [[ "$candidate" == "$expected" ]] && return 0 + done + return 1 +} + +shift "$shift_count" +expected_assets=("$@") +for name in "${expected_assets[@]}"; do + if [[ -z "$name" || "$name" == */* ]]; then + echo "Expected release asset must be a non-empty file name: $name" >&2 + exit 1 + fi +done + +asset_output="$( + gh release view "$release_tag" --repo "$repository" \ + --json assets --jq '.assets[].name' +)" +current_assets=() +while IFS= read -r name; do + [[ -n "$name" ]] && current_assets+=("$name") +done <<< "$asset_output" + +for name in "${current_assets[@]-}"; do + [[ -z "$name" ]] && continue + if ! contains_name "$name" "${expected_assets[@]}"; then + echo "Draft release $release_tag contains unexpected asset: $name" >&2 + exit 1 + fi +done + +if [[ "$mode" == "exact" ]]; then + if ((${#current_assets[@]} != ${#expected_assets[@]})); then + echo "Release $release_tag has ${#current_assets[@]} assets; expected ${#expected_assets[@]}." >&2 + exit 1 + fi + + for name in "${expected_assets[@]}"; do + if ! contains_name "$name" "${current_assets[@]}"; then + echo "Release $release_tag is missing expected asset: $name" >&2 + exit 1 + fi + done +fi + +echo "Release $release_tag asset contract passed in $mode mode." From a177ea91bcf44d69e51eb485670127ac312d799d Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 12:34:00 +0200 Subject: [PATCH 8/8] fix(release): allow empty draft assets Keep the empty line from a no-assets GitHub response from becoming a failing while-loop status under set -e. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- scripts/verify_release_asset_contract.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/verify_release_asset_contract.sh b/scripts/verify_release_asset_contract.sh index d328a10..6e3989c 100755 --- a/scripts/verify_release_asset_contract.sh +++ b/scripts/verify_release_asset_contract.sh @@ -52,7 +52,9 @@ asset_output="$( )" current_assets=() while IFS= read -r name; do - [[ -n "$name" ]] && current_assets+=("$name") + if [[ -n "$name" ]]; then + current_assets+=("$name") + fi done <<< "$asset_output" for name in "${current_assets[@]-}"; do