diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d54b2cf..8f6dfa0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,27 @@ on: branches: - main +# Never cancel a release in flight: a publish that is interrupted between npm +# and the git tags cannot be undone. Runs therefore queue instead. GitHub keeps +# only one pending run per group, so a third push while a release is running +# silently drops the one waiting behind it — the commits still ship, on the +# next run, but nothing announces the skip. Keeping this job short is what +# keeps that window small. concurrency: ${{ github.workflow }}-${{ github.ref }} +# This repository installs a local pre-push gate that re-runs the full test +# suite. It exists to save a CI round trip from a developer's machine; inside +# CI it is pure duplication, and `scripts/pre-push.sh` reads this to stand +# down. Set at the workflow level so it reaches every push, including the ones +# a JS action spawns. +env: + SKIP_HOOKS: "1" + +# Fail closed: a job gets no token scope it does not ask for, so the repository +# or organisation default can never quietly hand write access to a job that +# only reads. Every job below grants itself exactly what it needs. +permissions: {} + jobs: # Publishing to npm is irreversible — never let a commit that fails CI or # Nix Build ship. Those workflows run in parallel on the same push, so this @@ -94,6 +113,32 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # The `prepare` script reinstalls the local hooks during the install + # above, so this has to run after it, not before. `SKIP_HOOKS` already + # tells the hook to stand down; deleting it as well removes the last way + # the suite could run inside this job — an environment that reaches the + # hook scrubbed, or a hook that stops honouring the variable. + # + # Worth the belt and braces: on the publish path, the push these hooks + # would gate carries the version tags, and it runs after npm has already + # accepted the packages. A hook failure there — one flaky test under + # runner load is enough — leaves the release published but untagged, and + # skips every job that depends on it: docs, native artifacts, the VS Code + # extension, the Homebrew tap, the CDN. + - name: Disarm the local git hooks + run: | + # `--git-path hooks` is git's own resolution of core.hooksPath, so + # this deletes from the directory git will actually read rather than + # assuming `.git/hooks`. + hooks_dir=$(git rev-parse --git-path hooks) + rm -f -- "${hooks_dir}/pre-push" "${hooks_dir}/pre-commit" + if [ -e "${hooks_dir}/pre-push" ] || [ -e "${hooks_dir}/pre-commit" ]; then + echo "::error::A git hook survived removal in ${hooks_dir}." + echo "::error::The release push would re-run the full test suite; fix this before releasing." + exit 1 + fi + echo "Local git hooks disarmed (${hooks_dir})." + - name: Generate Pythinker Code built-in catalog shell: bash run: | @@ -378,6 +423,8 @@ jobs: redeploy-cdn: timeout-minutes: 10 name: Redeploy CDN + # Posts to a webhook with a secret; it never touches the GitHub API. + permissions: {} needs: - release - publish-native-assets @@ -453,6 +500,9 @@ jobs: verify-cdn-release: timeout-minutes: 20 name: Verify release consistency + # Checkout only; verify-release-consistency.mjs uses no GitHub token. + permissions: + contents: read needs: - release - redeploy-cdn @@ -485,6 +535,9 @@ jobs: update-brew-tap: timeout-minutes: 15 + # Checkout only; the tap push uses a minted app token, not this one. + permissions: + contents: read name: Update Homebrew tap needs: release if: needs.release.outputs.packages_published == 'true' @@ -535,6 +588,10 @@ jobs: native-artifacts: name: Native release artifact needs: release + # Matches the `contents: read` that _native-build.yml declares for itself; + # a called workflow cannot exceed what the calling job grants. + permissions: + contents: read if: needs.release.outputs.pythinker_native_release == 'true' uses: ./.github/workflows/_native-build.yml with: