diff --git a/.github/workflows/desktop-dmg.yml b/.github/workflows/desktop-dmg.yml index a39c87ad..0a3debfd 100644 --- a/.github/workflows/desktop-dmg.yml +++ b/.github/workflows/desktop-dmg.yml @@ -12,7 +12,15 @@ name: macOS app # gh workflow run desktop-dmg.yml --ref # dry run of a branch that carries this file # gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg # Only a run on master (or Release itself) attaches anything; every run leaves -# the signed Codeman.dmg as an artifact on its run page. +# the signed Codeman.dmg as an artifact on its run page. A rebuild takes only a +# Codeman release tag whose commit is on master ("Check the tag", in build and +# in sign). +# +# A dispatch runs its ref's copy of this file with the secrets, as every +# repository secret reaches anyone with write access. A GitHub Environment +# limited to master would not narrow the rebuild path (the job's ref is master +# while it runs the tag's scripts) and would refuse the branch dry run above; +# the tag check is what keeps a rebuild on master's history. # # Three jobs, so no job that holds a secret runs code from a crate: # - build runs cargo, and with it the build script of every crate in @@ -79,6 +87,36 @@ jobs: ref: ${{ env.BUILD_REF }} persist-credentials: false + # A rebuild by hand builds the tag it names, and sign then runs that tag's + # scripts with the Developer ID key unlocked. So the tag must be a Codeman + # release (another package's tag would put Codeman.dmg on its release), and + # its commit must be on master's history, where those scripts were reviewed. + # aicodeman@ is the tag a failed rename leaves (Release's retag step). + # A tag can move between jobs, so build and sign each check their own + # checkout. Inline on purpose: a script from the checkout would be the + # tag's own code. Kept the same as the step in sign. + - name: Check the tag + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$' + if ! [[ $TAG =~ $TAG_RE ]]; then + echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)" + exit 1 + fi + SHA=$(git rev-parse HEAD) + # behind: an ancestor of master's tip; identical: the tip itself. + STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status) + case "$STATUS" in + behind | identical) echo "$TAG ($SHA) is on master's history" ;; + *) + echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)" + exit 1 + ;; + esac + - name: Rust toolchain run: | rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \ @@ -151,6 +189,30 @@ jobs: ref: ${{ env.BUILD_REF }} persist-credentials: false + # The same check as in build, on this job's own checkout, before anything + # here touches a secret. Kept the same as the step in build. + - name: Check the tag + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$' + if ! [[ $TAG =~ $TAG_RE ]]; then + echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)" + exit 1 + fi + SHA=$(git rev-parse HEAD) + # behind: an ancestor of master's tip; identical: the tip itself. + STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status) + case "$STATUS" in + behind | identical) echo "$TAG ($SHA) is on master's history" ;; + *) + echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)" + exit 1 + ;; + esac + - name: Download the unsigned app uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ecb8b13..5bc6deef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,8 +145,12 @@ jobs: name: macOS app needs: release if: needs.release.outputs.codeman_version != '' + # The ceiling for the called jobs; only its attach job asks for write. A job in + # desktop-dmg.yml that asks for a permission not granted here (id-token or + # attestations for a provenance attestation, say) makes GitHub refuse this + # whole Release run at startup, npm publish included: widen both together. permissions: - contents: write # the ceiling for the called jobs; only its attach job asks for write + contents: write uses: ./.github/workflows/desktop-dmg.yml with: tag: codeman@${{ needs.release.outputs.codeman_version }} diff --git a/docs/releasing.md b/docs/releasing.md index e967cbf7..a6aaa640 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -70,7 +70,7 @@ curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/rele **Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref ` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release. -**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. A failed job is also retried in place with `gh run rerun --failed`. +**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. The tag must be a Codeman release tag (`codeman@x.y.z`, or the `aicodeman@x.y.z` a failed rename leaves) whose commit is on master's history: `build` and `sign` both refuse anything else before they run its scripts, since `sign` runs them with the signing key unlocked. A failed job is also retried in place with `gh run rerun --failed`. **If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log ` with the same login shows it again).