diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d3a7305c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,143 @@ +# 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: 24 + 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 }} + + # 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 + 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 + # 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 + # 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 + 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: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile --ignore-scripts + + # 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. + # --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 + + # OIDC handshake happens here (id-token: write + registry-url + a + # registered trusted publisher). No token in env. `--provenance` attaches + # a signed build-provenance attestation. + - run: npm publish --provenance --access public + working-directory: packages/cli