diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97817cb..b07cb00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,57 +5,111 @@ on: tags: - "v*" workflow_dispatch: - inputs: - tag: - description: Existing tag to build, for example v1.2.1 - required: true - 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. concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + group: release-${{ github.ref }} cancel-in-progress: false jobs: - macos-release: - name: Build signed and notarized macOS artifacts - runs-on: macos-latest + 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 }} + triggering_sha: ${{ steps.release.outputs.triggering_sha }} 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" - echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + 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: + 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 }} + triggering_sha: ${{ needs.validate-release-ref.outputs.triggering_sha }} + steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 }} + TRIGGERING_SHA: ${{ needs.validate-release-ref.outputs.triggering_sha }} + 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" != "$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 # 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 @@ -89,7 +143,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 +191,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,16 +209,18 @@ 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 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 @@ -169,7 +233,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 @@ -178,7 +242,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 @@ -190,7 +254,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 @@ -198,7 +262,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 @@ -228,8 +292,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=( @@ -264,30 +328,63 @@ jobs: - name: Upload workflow artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: OpenWritr-v${{ steps.release.outputs.version }}-macOS-arm64 + 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: + name: Create or update the draft release + needs: sign-and-notarize + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout tag verification helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} + persist-credentials: false + sparse-checkout: | + scripts/verify_release_asset_contract.sh + scripts/verify_remote_release_tag.sh + sparse-checkout-cone-mode: false + + - name: Download verified release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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 }} + TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} 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" + ) + 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 @@ -296,48 +393,85 @@ jobs: fi done + 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, # 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" \ + --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 + 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 + 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 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: 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 + contents: read uses: ./.github/workflows/smoke-test.yml with: - tag: ${{ needs.macos-release.outputs.tag }} + tag: ${{ needs.sign-and-notarize.outputs.tag }} + artifact_name: ${{ needs.sign-and-notarize.outputs.artifact_name }} 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: Checkout tag verification helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} + persist-credentials: false + 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.macos-release.outputs.tag }} + 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 @@ -346,7 +480,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..417ee52 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,22 +33,19 @@ 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: name: Install and transcribe - runs-on: macos-latest + 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: 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: inputs.artifact_name == '' + 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/AGENTS.md b/AGENTS.md index 86cf8bd..b3eeaa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,14 +148,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..9002028 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,80 +1,142 @@ # OpenWritr Release Checklist -Use this checklist for every tagged macOS release. - -## 1. Preflight - -- [ ] 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 - -## 2. Build + Sign + Notarize +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 the verified workflow artifact attached to it, +and publishes it. + +## One-time repository setup + +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: + +- `MACOS_CERTIFICATE` +- `MACOS_CERTIFICATE_PWD` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_PASSWORD` + +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. + +## 1. Maintainer: prepare the release + +- [ ] 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: + + ```sh + swift build -c release -Xswiftc -warnings-as-errors + swiftlint lint --strict + swift test + ``` + +- [ ] Merge the release-preparation pull request and confirm the intended commit + is on `main`. + +## 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 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. 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. 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 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: + +- `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 that `vx.y.z` tag as the +workflow ref. For example: ```sh -scripts/release_macos.sh +gh workflow run release.yml --ref vx.y.z ``` -Expected outcome: +Using the tag as the workflow ref is required by the `release` environment's +`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. -- 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 +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. -## 3. Versioned Artifact Names +## 5. Optional local rehearsal or recovery -Replace `x.y.z` with release version. - -```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 -``` +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. -## 4. Verification (must pass) +With a local Developer ID identity and notary profile configured: ```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 +cp .release.env.example .release.env +version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Info.plist)" +scripts/release_macos.sh "$version" ``` -Expected lines: - -- `The validate action worked!` -- `accepted` -- `source=Notarized Developer ID` - -Optional deep check: +The local script uses the versioned asset base +`dist/OpenWritr-v${version}-macOS-arm64`. Verify those local outputs directly: ```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 +xcrun stapler validate .build/release/OpenWritr.app +spctl --assess --type execute --verbose=2 .build/release/OpenWritr.app +xcrun stapler validate "dist/OpenWritr-v${version}-macOS-arm64.dmg" +spctl --assess --type open --context context:primary-signature \ + --verbose=2 "dist/OpenWritr-v${version}-macOS-arm64.dmg" ``` -## 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 - -## 6. Post-Release Sanity - -- [ ] 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/docs/release-smoke-tests.md b/docs/release-smoke-tests.md index 4111760..1aa6753 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 @@ -20,10 +21,14 @@ test → Run workflow`). It: 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. 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 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" diff --git a/scripts/verify_release_asset_contract.sh b/scripts/verify_release_asset_contract.sh new file mode 100755 index 0000000..6e3989c --- /dev/null +++ b/scripts/verify_release_asset_contract.sh @@ -0,0 +1,82 @@ +#!/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 + if [[ -n "$name" ]]; then + current_assets+=("$name") + fi +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." 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."