From fdbf0a8328317770dd3b0d38ec2e5d5e0be528da Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 12:03:50 -0700 Subject: [PATCH 1/4] ci: add hardened changesets release workflow (OIDC, no stored npm token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automate the changesets release: a `version` job opens the Version Packages PR from pending changesets, and a `publish` job publishes to npm when main's version isn't yet published. Hardening: - Split jobs: `version` has no credential and only handles untrusted changeset text (into CHANGELOG/PR body); `publish` sees no untrusted text and builds only reviewed, merged source. - npm OIDC trusted publishing — short-lived token, no stored NPM_TOKEN. - Least privilege: version = contents/pull-requests write; publish = contents:read + id-token:write, scoped to an `npm-production` environment. No approver gate (automatic on Version PR merge). - --ignore-scripts, explicit build, --provenance, pinned action SHAs, push:main only (no pull_request_target), no ${{ }} run: interpolation. Draft: requires the npm trusted-publisher + the `npm-production` environment to be configured before the publish path can succeed (see PR description). Publish is version-gated, so merging the workflow itself never publishes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 119 ++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..44b4f0e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: MIT +# Automated changesets release. Adapted from the pattern in +# thecodedrift/firebot-script-music-to-my-ears and hardened for npm publishing. +# +# Security model (why it is split into two jobs): +# +# `version` reads contributor-authored changesets (UNTRUSTED text) and opens +# the "Version Packages" PR. It has NO npm credential and NO OIDC +# identity, so a crafted changeset / PR body has nothing to steal +# or escape into. The changeset TEXT is fully consumed here (into +# the CHANGELOG + PR body) and never reaches the credentialed job. +# +# `publish` runs only when main's version is not yet on npm — i.e. right +# after the Version Packages PR merges. By then there are no +# changesets left, so this job sees no untrusted PR/changeset text; +# it builds from reviewed, merged source only. It authenticates to +# npm with a SHORT-LIVED token minted via GitHub OIDC (npm trusted +# publishing) — there is NO stored NPM_TOKEN anywhere to exfiltrate. +# +# Residual perimeter, stated honestly: the publish job builds merged repo code, +# so "what can merge to main" is the real boundary. That is enforced by branch +# protection (review required) on main. `--ignore-scripts` keeps dependency +# lifecycle hooks from running while the OIDC identity is available; only our +# own build runs. No `pull_request_target` and no `${{ }}` interpolation of +# untrusted text into any `run:` — the two classic token-exfiltration footguns. +# +# Action refs are pinned to commit SHAs (supply-chain hardening); the trailing +# comment records the human-readable tag. + +name: Release + +on: + push: + branches: [main] + +# Serialize releases so two pushes can't race the version PR / publish. +concurrency: release-${{ github.ref }} + +# No workflow-wide grants; each job requests exactly what it needs. +permissions: {} + +jobs: + version: + name: Version Packages PR + runs-on: ubuntu-latest + permissions: + contents: write # push the changeset-release/main branch + pull-requests: write # open/update the Version Packages PR + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + + # `version: pnpm bump` runs `changeset version` AND `sync-skill-versions`, + # so the bumped version is propagated into skills/recipes in the same PR. + # No `publish:` input — this job can never publish. + - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1 + with: + version: pnpm bump + commit: "chore: version packages" + title: "chore: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + name: Publish to npm + runs-on: ubuntu-latest + # Environment is the scoping/audit boundary for the release and where the + # npm trusted-publisher is bound. No required reviewers (fully automatic + # once the Version Packages PR merges), by design. + environment: npm-production + permissions: + contents: read # checkout only + id-token: write # OIDC → short-lived npm auth + build provenance + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile --ignore-scripts + + # OIDC trusted publishing + provenance need a recent npm (>= 11.5.1); the + # npm bundled with Node 20 is older. Installing npm itself is trusted. + - run: npm install -g npm@latest + + # Publish only a version npm has never seen. On an ordinary feature merge + # the version is unchanged (already on npm) so this is a no-op; it fires + # only on the merge of the Version Packages PR. Reads package.json (repo + # source) via `node -p` — no untrusted interpolation into the shell. + - id: check + run: | + name=$(node -p "require('./packages/cli/package.json').name") + version=$(node -p "require('./packages/cli/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "$name@$version already published — nothing to do." + else + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "Will publish $name@$version." + fi + + - if: steps.check.outputs.publish == 'true' + run: pnpm --filter @taskless/cli build + + # OIDC handshake happens here (id-token: write + registry-url + a + # registered trusted publisher). No token in env. `--provenance` attaches + # a signed build-provenance attestation. + - if: steps.check.outputs.publish == 'true' + run: npm publish --provenance --access public + working-directory: packages/cli From 13565713bf1122cfda7a62ac881cc34107051240 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 12:14:13 -0700 Subject: [PATCH 2/4] ci: use Node 24 and pin npm for the release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #57: - node-version 20 → 24: @taskless/cli requires engines.node >=22.22.0 and CI runs Node 24, so the release jobs would have failed at install/build. - Pin npm@12.0.1 instead of npm@latest so the release is deterministic and a new npm release can't change publish behavior unreviewed (still satisfies the OIDC >= 11.5.1 requirement). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44b4f0e1..22f5a50a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile --ignore-scripts @@ -83,14 +83,15 @@ jobs: - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + node-version: 24 cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile --ignore-scripts - # OIDC trusted publishing + provenance need a recent npm (>= 11.5.1); the - # npm bundled with Node 20 is older. Installing npm itself is trusted. - - run: npm install -g npm@latest + # OIDC trusted publishing + provenance need npm >= 11.5.1. Pin the version + # (not @latest) so the release is deterministic and a new npm release + # can't change publish behavior unreviewed; bump this intentionally. + - run: npm install -g npm@12.0.1 # Publish only a version npm has never seen. On an ordinary feature merge # the version is unchanged (already on npm) so this is a no-op; it fires From d4c1860c1b2c72e3e34c707bbdb6c3989819d522 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 13:15:30 -0700 Subject: [PATCH 3/4] ci: gate publish behind a credential-free check job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review (under the new .github/copilot-instructions.md): the publish job granted id-token: write and bound the npm-production environment on every push to main, even when the version check decided there was nothing to publish — needlessly widening the window an OIDC identity exists. Split the decision out: a credential-free `check` job (contents:read only) computes publish=true|false; `publish` now `needs: check` and runs only `if: needs.check.outputs.publish == 'true'`. On ordinary pushes the OIDC-capable job never starts, so the release identity + environment exist only for an actual release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 58 +++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22f5a50a..1112cb20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,8 +68,44 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Decides whether main's version needs publishing — with NO credential and NO + # OIDC identity. Runs on every push, but the credentialed publish job below + # only starts when this reports a new version, so ordinary non-release pushes + # never instantiate an OIDC-capable job or touch the npm-production environment. + check: + name: Check for a new version + runs-on: ubuntu-latest + permissions: + contents: read # checkout + read package.json + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + # Publish only a version npm has never seen. On an ordinary feature merge + # the version is unchanged (already on npm) so this is false; it flips true + # only on the merge of the Version Packages PR. Reads package.json (repo + # source) via `node -p` — no untrusted interpolation into the shell. + - id: check + run: | + name=$(node -p "require('./packages/cli/package.json').name") + version=$(node -p "require('./packages/cli/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "$name@$version already published — nothing to do." + else + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "Will publish $name@$version." + fi + publish: name: Publish to npm + # Gate on the credential-free check: this job — and therefore the OIDC + # identity + npm-production environment — only exists for an actual release. + needs: check + if: needs.check.outputs.publish == 'true' runs-on: ubuntu-latest # Environment is the scoping/audit boundary for the release and where the # npm trusted-publisher is bound. No required reviewers (fully automatic @@ -93,28 +129,10 @@ jobs: # can't change publish behavior unreviewed; bump this intentionally. - run: npm install -g npm@12.0.1 - # Publish only a version npm has never seen. On an ordinary feature merge - # the version is unchanged (already on npm) so this is a no-op; it fires - # only on the merge of the Version Packages PR. Reads package.json (repo - # source) via `node -p` — no untrusted interpolation into the shell. - - id: check - run: | - name=$(node -p "require('./packages/cli/package.json').name") - version=$(node -p "require('./packages/cli/package.json').version") - if npm view "$name@$version" version >/dev/null 2>&1; then - echo "publish=false" >> "$GITHUB_OUTPUT" - echo "$name@$version already published — nothing to do." - else - echo "publish=true" >> "$GITHUB_OUTPUT" - echo "Will publish $name@$version." - fi - - - if: steps.check.outputs.publish == 'true' - run: pnpm --filter @taskless/cli build + - run: pnpm --filter @taskless/cli build # OIDC handshake happens here (id-token: write + registry-url + a # registered trusted publisher). No token in env. `--provenance` attaches # a signed build-provenance attestation. - - if: steps.check.outputs.publish == 'true' - run: npm publish --provenance --access public + - run: npm publish --provenance --access public working-directory: packages/cli From 8e387d50ab01d431ff516a226fa305ba2004669a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 13:18:32 -0700 Subject: [PATCH 4/4] =?UTF-8?q?ci:=20harden=20publish=20job=20=E2=80=94=20?= =?UTF-8?q?no=20persisted=20git=20creds,=20--ignore-scripts=20on=20npm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more defense-in-depth findings from the Copilot re-review under the new GHA credential-safety instructions: - persist-credentials: false on the check and publish checkouts — those jobs don't push, so don't leave GITHUB_TOKEN in the local git config (smaller blast radius if a later step uses git/network). - --ignore-scripts on the pinned `npm install -g` so no dependency lifecycle code executes while the publish job holds id-token: write. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1112cb20..d3a7305c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,8 @@ jobs: publish: ${{ steps.check.outputs.publish }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # no git writes here; don't leave the token in git config - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 @@ -116,6 +118,8 @@ jobs: id-token: write # OIDC → short-lived npm auth + build provenance steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # publish authenticates via OIDC/npm, not git creds - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -127,7 +131,8 @@ jobs: # OIDC trusted publishing + provenance need npm >= 11.5.1. Pin the version # (not @latest) so the release is deterministic and a new npm release # can't change publish behavior unreviewed; bump this intentionally. - - run: npm install -g npm@12.0.1 + # --ignore-scripts: no lifecycle code runs while the OIDC identity exists. + - run: npm install -g npm@12.0.1 --ignore-scripts - run: pnpm --filter @taskless/cli build