From e098fa252c587b728692505e04b3fb1b780d9824 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 18:28:14 -0400 Subject: [PATCH 1/2] ci: stop the release job re-running the test suite before it pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm install` reinstalls this repo's local pre-push gate, so every push the release job makes re-ran the whole suite inside CI — 20,387 tests, 17 minutes, on a commit CI had already tested. That is what failed the last release run: the push held a stale lease for the whole window and was rejected. On the publish path the same push carries the version tags and happens after npm has already accepted the packages. One flaky test there, or the 30-minute job timeout, would leave a published release untagged and skip every job that follows it — docs, native artifacts, the extension, the tap, the CDN. Two independent layers: SKIP_HOOKS, which the hook reads and which reaches the pushes a JS action spawns, and deleting the hooks after install, which does not depend on the environment surviving that hop. The delete resolves the hooks directory through git rather than assuming .git/hooks, and fails the job if a hook is still there afterwards. --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d54b2cf..aa7cce3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,22 @@ 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" + 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 +108,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: | From d9eb5945730245842f6a76b5c3ea71e30718eb8e Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 18:56:07 -0400 Subject: [PATCH 2/2] ci: fail closed on release token scope, and end rm option parsing Two review findings on this workflow. The workflow had no top-level `permissions`, so any job without its own block inherited whatever the repository or organisation hands out, which can be write-capable. It now denies everything by default and each job asks for what it needs: `contents: read` for the three that only check out, nothing at all for the CDN trigger, which posts to a webhook and never calls the API. The reusable native build declares `contents: read` for itself, so its caller now grants exactly that. The hook removal passed a resolved path straight to `rm`. A `core.hooksPath` beginning with a dash would have been read as options; `--` ends parsing. --- .github/workflows/release.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa7cce3a..8f6dfa0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,11 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} 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 @@ -126,7 +131,7 @@ jobs: # 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" + 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." @@ -418,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 @@ -493,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 @@ -525,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' @@ -575,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: