Repository navigation
chore(openspec): archive the two changes the stack landed #25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-License-Identifier: MIT | |
| # Publish @taskless/cli to npm. Adapted from the pattern in | |
| # thecodedrift/firebot-script-music-to-my-ears and hardened for npm publishing. | |
| # | |
| # TWO JOBS IN ONE FILE, ON PURPOSE: | |
| # | |
| # check Decides whether main's version needs publishing — with NO npm | |
| # credential and NO OIDC identity. It runs on every push to main and | |
| # reads packages/cli/package.json (repo source) via `node -p`, so | |
| # nothing untrusted is interpolated into a shell. | |
| # | |
| # publish Exists only when `check` says yes — i.e. right after the Version | |
| # Packages PR merges, when the version in the manifest is one npm has | |
| # never seen. It authenticates with a SHORT-LIVED token minted via | |
| # GitHub OIDC (npm trusted publishing); there is NO stored NPM_TOKEN | |
| # anywhere to exfiltrate. | |
| # | |
| # The gate and the job it gates MUST stay in the same file. The gate's real work | |
| # is not saving a minute of CI — it is keeping an OIDC-capable job from being | |
| # instantiated at all on an ordinary merge. Split across two files, a later edit | |
| # that reads only the publish half would see a job with `id-token: write` and no | |
| # visible reason for the `needs:`, and drop it. | |
| # | |
| # The untrusted half of the release lives in `release-cli-changeset.yml`, which | |
| # holds no credential. By the time this workflow publishes, the changesets have | |
| # already been consumed into the merged CHANGELOG, so this job builds from | |
| # reviewed, merged source only. | |
| # | |
| # 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 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. | |
| # | |
| # NO CONCURRENCY GROUP HERE, deliberately — do not add one for tidiness. Two | |
| # things stand in for serialization, and the second is the one that matters. | |
| # The credential-free `check` gate makes the ordinary duplicate a no-op: the | |
| # second run sees the version on npm and never instantiates this job. But | |
| # `check` is a SEPARATE JOB, so between its answer and the publish there is a | |
| # window where another run can ship the same version — serialization would | |
| # close that window, and a gate in another job does not. | |
| # | |
| # What closes it is the `npm view` guard immediately before `npm publish`, | |
| # below. Asking again at the moment it matters is idempotent rather than merely | |
| # ordered: it also covers a re-run of this workflow against a version an | |
| # earlier attempt already published, which serialization does nothing about. | |
| # `release-vale.yml` guards each tarball the same way, for the same reason. | |
| # | |
| # Action refs are pinned to commit SHAs (supply-chain hardening); the trailing | |
| # comment records the human-readable tag. | |
| name: Release CLI | |
| on: | |
| push: | |
| branches: [main] | |
| # No workflow-wide grants; each job requests exactly what it needs. | |
| permissions: {} | |
| jobs: | |
| # 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 — so ordinary 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 | |
| - 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 for @taskless/cli is bound. npm-production carries a | |
| # REQUIRED REVIEWER and a protected-branches deployment policy, so this job | |
| # waits for a human to approve the deployment before it starts — the merge | |
| # of the Version Packages PR is not by itself enough to ship. That click is | |
| # deliberate and is the gate on what users get by default: a publish here | |
| # moves the `latest` tag that every `npm i @taskless/cli` resolves. Flows | |
| # that reach no user by default (the Vale platform packages, nightlies) use | |
| # npm-autopublish instead and are gated by code review rather than a click. | |
| 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. | |
| # | |
| # The `npm view` guard immediately before the publish is what makes the | |
| # missing concurrency group safe, and it has to live HERE rather than in | |
| # the `check` job. `check` runs in a separate job, so between its answer | |
| # and this line there is a window in which another run can publish the | |
| # same version; serializing the workflow would close that window, but so | |
| # does asking again at the moment it matters. Re-asking is the better of | |
| # the two: it is idempotent rather than merely ordered, so it also covers | |
| # a re-run of this workflow on a version an earlier attempt already | |
| # published — which serialization does nothing about. | |
| # | |
| # This mirrors `release-vale.yml`, which guards each tarball the same way | |
| # for the same reason. Without it, the losing run of a race fails with | |
| # npm's "cannot publish over the previously published version", which | |
| # reads as a broken release rather than as a duplicate that was already | |
| # handled. | |
| - name: Publish (skipping a version already on npm) | |
| working-directory: packages/cli | |
| run: | | |
| name=$(node -p "require('./package.json').name") | |
| version=$(node -p "require('./package.json').version") | |
| if npm view "$name@$version" version >/dev/null 2>&1; then | |
| echo "$name@$version is already published — nothing to do." | |
| else | |
| npm publish --provenance --access public | |
| fi |