From 62c7bd61129b905a9af6fdbaf51595b08b084afd Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 11:57:19 -0700 Subject: [PATCH 1/4] refactor(ci): split release.yml so each workflow carries one design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release.yml` held two jobs with opposite trust properties behind a single header. Split it, with no change to what any job does: - `release-cli-changeset.yml` — the `version` job, keeping `concurrency: release-${{ github.ref }}`. It consumes untrusted changeset text and holds no credential and no OIDC identity. - `release-cli.yml` — the `check` and `publish` jobs, deliberately in one file: the credential-free gate is what keeps an OIDC-capable job from being instantiated on an ordinary merge, and it must not be separated from the job it protects. Deliberately no concurrency group; the gate makes a duplicate run a no-op and the residual TOCTOU is treated as possibly-already-published. - `vale-binaries.yml` → `release-vale.yml`, rename only; its workflow name and concurrency group are unchanged, so its check name still reports. Also corrects a header claim that had been false since the environment was created: `npm-production` has a required reviewer and a protected-branches deployment policy, so a release has always waited on a human approval the comment said was not there. Verified while implementing, and corrected in the change's design/tasks: `changeset status --output=` writes an object whose per-package entries live under `releases` (not a bare array), and an absolute `--output` path is resolved against the working directory rather than ignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .changeset/nightly-cli-builds.md | 18 +++ .github/scripts/vale-manifest.json | 2 +- .github/workflows/release-cli-changeset.yml | 69 +++++++++ .github/workflows/release-cli.yml | 133 ++++++++++++++++ .../{vale-binaries.yml => release-vale.yml} | 11 +- .github/workflows/release.yml | 143 ------------------ openspec/changes/nightly-cli-builds/design.md | 30 ++-- openspec/changes/nightly-cli-builds/tasks.md | 26 ++-- packages/vale-darwin-arm64/README.md | 2 +- packages/vale-darwin-x64/README.md | 2 +- packages/vale-linux-arm64/README.md | 2 +- packages/vale-linux-x64/README.md | 2 +- packages/vale-win32-arm64/README.md | 2 +- packages/vale-win32-x64/README.md | 2 +- 14 files changed, 265 insertions(+), 179 deletions(-) create mode 100644 .changeset/nightly-cli-builds.md create mode 100644 .github/workflows/release-cli-changeset.yml create mode 100644 .github/workflows/release-cli.yml rename .github/workflows/{vale-binaries.yml => release-vale.yml} (97%) delete mode 100644 .github/workflows/release.yml diff --git a/.changeset/nightly-cli-builds.md b/.changeset/nightly-cli-builds.md new file mode 100644 index 00000000..3f779ef0 --- /dev/null +++ b/.changeset/nightly-cli-builds.md @@ -0,0 +1,18 @@ +--- +"@taskless/cli": patch +--- + +Split the release pipeline so each workflow file carries one release design. + +`release.yml` held two jobs with opposite trust properties behind one header. +It is now `release-cli-changeset.yml` — which reads contributor-authored +changesets and opens the Version Packages PR holding no npm credential and no +OIDC identity — and `release-cli.yml`, which keeps the credential-free +"is this version already on npm?" gate together with the publish job it +protects, so an OIDC-capable job is never instantiated on an ordinary merge. +`vale-binaries.yml` is renamed `release-vale.yml` to match. + +Nothing about how `@taskless/cli` is built, versioned, or published changes. +The header comments do get one correction: they claimed `npm-production` had no +required reviewers, and it has had one all along, so a release has always +waited for a human approval that the file said was not there. diff --git a/.github/scripts/vale-manifest.json b/.github/scripts/vale-manifest.json index d78e4cd5..2197df39 100644 --- a/.github/scripts/vale-manifest.json +++ b/.github/scripts/vale-manifest.json @@ -12,7 +12,7 @@ "the archive is verified before anything is unpacked from it.", "", "`asset` is a template; {version} is replaced with valeVersion. The detect", - "phase of .github/workflows/vale-binaries.yml rewrites valeVersion and every", + "phase of .github/workflows/release-vale.yml rewrites valeVersion and every", "sha256 from upstream's checksums file, and leaves the templates alone." ], "valeVersion": "3.17.1", diff --git a/.github/workflows/release-cli-changeset.yml b/.github/workflows/release-cli-changeset.yml new file mode 100644 index 00000000..dbc87298 --- /dev/null +++ b/.github/workflows/release-cli-changeset.yml @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MIT +# Version Packages PR for @taskless/cli. Adapted from the pattern in +# thecodedrift/firebot-script-music-to-my-ears. +# +# THIS WORKFLOW CANNOT PUBLISH, and that is its entire security property. +# +# It reads contributor-authored changesets (UNTRUSTED text) and folds them into +# a CHANGELOG and a pull request body. It holds NO npm credential and NO OIDC +# identity, so a crafted changeset or PR body has nothing here to steal and +# nothing to escape into. The changeset TEXT is fully consumed at this stage and +# never reaches a credentialed job: by the time `release-cli.yml` publishes, the +# Version Packages PR has merged and there are no changesets left to read. +# +# That is why the publish lives in its own file rather than a job below. Keeping +# untrusted text and an OIDC identity in one file invites a later edit that +# hands one to the other — e.g. an `outputs:` carrying changeset-derived text +# into a `run:` in a job holding `id-token: write`. +# +# There is no `publish:` input on the changesets action, deliberately. Supplying +# one would turn this job into a publisher while it is still holding untrusted +# input, which is the arrangement the split exists to prevent. +# +# CONCURRENCY is required here specifically. Two pushes racing on the +# `changeset-release/main` branch is a real failure: both would force the branch +# and one PR would end up describing versions the other computed. +# +# Action refs are pinned to commit SHAs (supply-chain hardening); the trailing +# comment records the human-readable tag. + +name: Release CLI Version PR + +on: + push: + branches: [main] + +# Serialize so two pushes can't race the Version Packages PR branch. +concurrency: release-${{ github.ref }} + +# No workflow-wide grants; the 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 }} diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..42171360 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,133 @@ +# 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. The +# credential-free gate already makes a duplicate run a no-op: the second run +# sees the version on npm and does nothing. The residual TOCTOU (two runs both +# observing "not published") is handled the way release-vale.yml handles it — +# a failed publish is treated as possibly-already-published rather than as a +# fresh error, because a version is immutable and the losing run is trying to +# publish bytes the winner already shipped. Serializing would buy nothing the +# gate does not already provide, at the cost of queueing a release behind +# unrelated pushes. +# +# 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. + - run: npm publish --provenance --access public + working-directory: packages/cli diff --git a/.github/workflows/vale-binaries.yml b/.github/workflows/release-vale.yml similarity index 97% rename from .github/workflows/vale-binaries.yml rename to .github/workflows/release-vale.yml index 6a825abc..d2d64d2c 100644 --- a/.github/workflows/vale-binaries.yml +++ b/.github/workflows/release-vale.yml @@ -1,10 +1,11 @@ # SPDX-License-Identifier: MIT # Vale platform packages — detect upstream releases, then publish @taskless/vale-*. # -# Standalone by design. This workflow does not touch release.yml and release.yml -# does not touch these packages: they are in the changesets `ignore` list, their -# versions are stamped here rather than bumped by a changeset, and release.yml's -# "is main's version on npm yet?" check reads packages/cli/package.json only. +# Standalone by design. This workflow does not touch the CLI release workflows +# and they do not touch these packages: the platform packages are in the +# changesets `ignore` list, their versions are stamped here rather than bumped +# by a changeset, and release-cli.yml's "is main's version on npm yet?" check +# reads packages/cli/package.json only. # # TWO PHASES, because the trust boundary is code review (design D6): # @@ -29,7 +30,7 @@ # the distinction between them is worth stating precisely because half of it is # a trap (design D5). # -# A check against the STAMPED version — the thing release.yml uses — cannot work +# A check against the STAMPED version — the thing release-cli.yml uses — cannot work # here: every publish stamps -, a version npm has # never seen, so it would answer "not published" every time and could never # suppress anything. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d3a7305c..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,143 +0,0 @@ -# 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 diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 7588faab..3fdafd92 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -85,21 +85,29 @@ Only past both gates does the build run, taking `newVersion` from `changeset sta **Reading the bump.** `changeset status --output=` writes: ```json -[ - { - "name": "@taskless/cli", - "type": "minor", - "oldVersion": "0.10.2", - "newVersion": "0.11.0" - } -] +{ + "changesets": [{ "releases": [], "summary": "…", "id": "…" }], + "releases": [ + { + "name": "@taskless/cli", + "type": "minor", + "oldVersion": "0.10.2", + "changesets": ["…"], + "newVersion": "0.11.0" + } + ] +} ``` -Three traps, all silent. +Verified against this repository on 2026-08-19 with `@changesets/cli` as pinned here. Note the shape: the file is an **object**, and the per-package entries live under `releases`, not at the root. The bump is `data.releases.find((r) => r.name === "@taskless/cli").newVersion`. -**The path must be repo-relative** — an absolute `/tmp/...` path fails to write the file without failing the command. **The JSON file is authoritative, not stdout**, which also carries unrelated workspace-version warnings. Read the file. +Three traps. -And **the output is an array, so select by name, never by index.** It lists every package the pending changesets release. `[0]` is `@taskless/cli` only for as long as the CLI is the sole changesets-managed package, and the six `@taskless/vale-*` packages already sit in the changesets `ignore` list precisely because a second managed package is a thing that happens. The day one is added, `[0]` stamps the nightly with another package's version — a wrong version that publishes successfully and looks plausible. Match on `name === "@taskless/cli"`. +**The path must be repo-relative.** `--output` is resolved against the process working directory with no special case for a leading `/`, so `--output=/tmp/status.json` targets `/tmp/status.json`. If that directory happens to exist the file is written somewhere nobody looks; if it does not, the command fails with `ENOENT` on a path the caller never named — which is what it does from the repo root here. Neither outcome puts a file at `/tmp`. + +**The JSON file is authoritative, not stdout**, which also carries unrelated workspace-version warnings (one line per `@taskless/vale-*` package, repeated per changeset — 18 lines in the current tree). Read the file. + +And **`releases` is an array, so select by name, never by index.** It lists every package the pending changesets release. `[0]` is `@taskless/cli` only for as long as the CLI is the sole changesets-managed package, and the six `@taskless/vale-*` packages already sit in the changesets `ignore` list precisely because a second managed package is a thing that happens. The day one is added, `[0]` stamps the nightly with another package's version — a wrong version that publishes successfully and looks plausible. Match on `name === "@taskless/cli"`. **Chore merges.** A chore adds no changeset, so it never _starts_ a nightly. It will produce a new nightly if changesets are already pending, because the SHA moved and gate 2 is per-SHA. That is accepted: the nightly claims to be "this commit of `main`," and a chore does produce a new commit of `main`. If it becomes noisy, the fix is an additional gate on `packages/cli/**` having changed since the last nightly — deliberately not built now, because the cost is a version string nobody will notice. diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index 2c6fd21e..81d2ef6a 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -2,25 +2,25 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is ## 0. Prerequisites and facts to confirm before writing anything -- [ ] 0.1 Re-confirm the live environment configuration with `gh api repos/taskless/cli/environments` — the correction in 1.4 depends on `npm-production` still having `required_reviewers` -- [ ] 0.2 Confirm `changeset status --output=` on this repository writes `[{ name, type, oldVersion, newVersion }]`, and re-confirm both traps: an absolute path silently writes nothing, and stdout carries unrelated workspace-version warnings so the JSON file is the only source to read -- [ ] 0.3 Confirm the `@taskless/cli-nightly` name is unclaimed on npm +- [x] 0.1 Re-confirm the live environment configuration with `gh api repos/taskless/cli/environments` — the correction in 1.4 depends on `npm-production` still having `required_reviewers` +- [x] 0.2 Confirm `changeset status --output=` on this repository writes `{ changesets: [...], releases: [{ name, type, oldVersion, changesets, newVersion }] }`, and re-confirm both traps: an absolute path is resolved against the working directory (so `/tmp/x.json` means `/tmp/x.json` — written where nobody looks if that directory exists, `ENOENT` if it does not), and stdout carries unrelated workspace-version warnings so the JSON file is the only source to read +- [x] 0.3 Confirm the `@taskless/cli-nightly` name is unclaimed on npm ## 1. PR 1 — split the release workflows (no behavior change) -- [ ] 1.1 Create `.github/workflows/release-cli-changeset.yml` from `release.yml`'s `version` job, keeping `concurrency: release-${{ github.ref }}` and the header text explaining why the job holds no credential and no OIDC identity (D5, D6) -- [ ] 1.2 Create `.github/workflows/release-cli.yml` from `release.yml`'s `check` and `publish` jobs, keeping them **in one file** — the credential-free gate is what keeps an OIDC-capable job from existing on ordinary pushes, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit (D5) -- [ ] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and say so in the header: the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled by treating a publish failure as possibly-already-published, the way `vale-binaries.yml` already does (D6) -- [ ] 1.4 Correct the stale claim carried over from `release.yml`'s header — `npm-production` **does** have a required reviewer, so "No required reviewers (fully automatic once the Version Packages PR merges), by design" is false and must be replaced with what is actually configured and why -- [ ] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml` with no behavior change, preserving the whole header comment -- [ ] 1.6 Delete `.github/workflows/release.yml` -- [ ] 1.7 Check for references to the old filenames — branch protection required checks, `pr-check-openspec.yml`, `require-changeset.yml`, `stack-breadcrumb.yml`, README and docs — and update anything that names `release.yml` or `Vale Binaries`. A renamed workflow means a renamed check, and a required check that no longer reports blocks merges silently -- [ ] 1.8 Add the changeset on this branch, before cutting the PRs above it, describing **this PR's scope only** — the workflow split. The stack merges forward, so `CLAUDE.md` requires each unit to extend the changeset with what it actually landed rather than the base promising the whole change up front; a reviewer reading it should see only what has merged. Groups 4 and 5 each extend the same file (never add a second changeset) +- [x] 1.1 Create `.github/workflows/release-cli-changeset.yml` from `release.yml`'s `version` job, keeping `concurrency: release-${{ github.ref }}` and the header text explaining why the job holds no credential and no OIDC identity (D5, D6) +- [x] 1.2 Create `.github/workflows/release-cli.yml` from `release.yml`'s `check` and `publish` jobs, keeping them **in one file** — the credential-free gate is what keeps an OIDC-capable job from existing on ordinary pushes, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit (D5) +- [x] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and say so in the header: the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled by treating a publish failure as possibly-already-published, the way `vale-binaries.yml` already does (D6) +- [x] 1.4 Correct the stale claim carried over from `release.yml`'s header — `npm-production` **does** have a required reviewer, so "No required reviewers (fully automatic once the Version Packages PR merges), by design" is false and must be replaced with what is actually configured and why +- [x] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml` with no behavior change, preserving the whole header comment +- [x] 1.6 Delete `.github/workflows/release.yml` +- [x] 1.7 Check for references to the old filenames — branch protection required checks, `pr-check-openspec.yml`, `require-changeset.yml`, `stack-breadcrumb.yml`, README and docs — and update anything that names `release.yml` or `Vale Binaries`. A renamed workflow means a renamed check, and a required check that no longer reports blocks merges silently +- [x] 1.8 Add the changeset on this branch, before cutting the PRs above it, describing **this PR's scope only** — the workflow split. The stack merges forward, so `CLAUDE.md` requires each unit to extend the changeset with what it actually landed rather than the base promising the whole change up front; a reviewer reading it should see only what has merged. Groups 4 and 5 each extend the same file (never add a second changeset) - [ ] 1.9 Verify on merge that the Version Packages PR flow still opens/updates normally and that an ordinary push instantiates no OIDC-capable job ## 2. Human-gated prerequisite — the `npm-autopublish` environment (before the nightly) -- [ ] 2.1 **Maintainer action, in GitHub repository settings:** create an environment named `npm-autopublish` with **no required reviewers** and a deployment branch policy restricting it to `main`. An implementer cannot do this and cannot test around it — a workflow referencing a missing environment fails the run (D7, D8) +- [ ] 2.1 **Maintainer action, in GitHub repository settings:** create an environment named `npm-autopublish` with **no required reviewers** and a deployment branch policy restricting it to `main`. An implementer cannot do this and cannot test around it — a workflow referencing a missing environment fails the run (D7, D8). **As of 2026-08-19 `npm-autopublish` already exists** (created 2026-08-18) with no protection rules — but also with `deployment_branch_policy: null`, i.e. no branch restriction at all. Only the branch policy is outstanding; do not re-create the environment - [ ] 2.2 Confirm via `gh api repos/taskless/cli/environments` that `npm-autopublish` exists, has no `required_reviewers`, and has the branch policy applied ## 3. Human-gated prerequisite — trusted publishing for `@taskless/cli-nightly` @@ -34,7 +34,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is ## 4. PR 2 — the nightly (depends on groups 2 and 3 for its first real run) - [ ] 4.1 Add a pack script under `.github/scripts/` that rewrites `packages/cli/package.json` at pack time to `name: @taskless/cli-nightly` and the stamped version, leaving `bin`, `optionalDependencies`, and the committed manifest untouched — the same shape `vale-prepare.cjs` uses (D2) -- [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2). **Select the entry by `name === "@taskless/cli"`, never `[0]`** — the output is an array of every package the pending changesets release, and index 0 is only the CLI while it is the sole changesets-managed package. Taking `[0]` stamps the nightly with another package's version the day a second one is added, and nothing fails loudly when it does +- [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2). **Select the entry from `releases` by `name === "@taskless/cli"`, never `[0]`** — the file is an object whose `releases` array lists every package the pending changesets release, and index 0 is only the CLI while it is the sole changesets-managed package. Taking `[0]` stamps the nightly with another package's version the day a second one is added, and nothing fails loudly when it does - [ ] 4.3 Keep the `x` separator and cover it with a test: a short SHA of all digits beginning with `0` must still produce a valid semantic version. This is the one detail most likely to be "simplified" away by a later reader who sees it as decoration (D3) - [ ] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 as a **listing of `.changeset/*.md` excluding `README.md`** that runs before any dependency install (`.changeset/` also permanently holds `README.md` and `config.json`, so a bare emptiness test is never true — `require-changeset.yml` already counts them this way with `grep -viE '/README\.md$'`), and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4) - [ ] 4.5 Keep the gates credential-free and in their own job, so the publish job — and therefore the OIDC identity — exists only for a run that will actually publish diff --git a/packages/vale-darwin-arm64/README.md b/packages/vale-darwin-arm64/README.md index dfc6d37d..d8ce870f 100644 --- a/packages/vale-darwin-arm64/README.md +++ b/packages/vale-darwin-arm64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__macOS_arm64.tar.gz` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and diff --git a/packages/vale-darwin-x64/README.md b/packages/vale-darwin-x64/README.md index c8f91643..b9f796f1 100644 --- a/packages/vale-darwin-x64/README.md +++ b/packages/vale-darwin-x64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__macOS_64-bit.tar.gz` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and diff --git a/packages/vale-linux-arm64/README.md b/packages/vale-linux-arm64/README.md index a182d209..e31a3973 100644 --- a/packages/vale-linux-arm64/README.md +++ b/packages/vale-linux-arm64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__Linux_arm64.tar.gz` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and diff --git a/packages/vale-linux-x64/README.md b/packages/vale-linux-x64/README.md index 6ac78447..9e57f38f 100644 --- a/packages/vale-linux-x64/README.md +++ b/packages/vale-linux-x64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__Linux_64-bit.tar.gz` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and diff --git a/packages/vale-win32-arm64/README.md b/packages/vale-win32-arm64/README.md index 898d0c54..d45c5bfa 100644 --- a/packages/vale-win32-arm64/README.md +++ b/packages/vale-win32-arm64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__Windows_arm64.zip` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and diff --git a/packages/vale-win32-x64/README.md b/packages/vale-win32-x64/README.md index 6f991f34..63d1e2a8 100644 --- a/packages/vale-win32-x64/README.md +++ b/packages/vale-win32-x64/README.md @@ -32,7 +32,7 @@ the placeholder is never itself published. ## Where the binary comes from -The binary is not stored in this repository. `.github/workflows/vale-binaries.yml` +The binary is not stored in this repository. `.github/workflows/release-vale.yml` downloads `vale__Windows_64-bit.zip` from the upstream release, checks the archive's SHA256 against the digest committed in `.github/scripts/vale-manifest.json`, refuses to go further on a mismatch, and From a1d85037952b292849ee34c610117a20e69303da Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 12:06:04 -0700 Subject: [PATCH 2/4] refactor(ci): name the Vale workflow for what its file says The rename left `name: Vale Binaries` and `concurrency: vale-binaries` inside `release-vale.yml`, on the reasoning that task 1.5 asked for no behavior change. That preserved exactly the mismatch this split exists to remove: a file named for one thing announcing itself as another, in a change whose whole subject is making each workflow say what it is. Safe because branch protection requires only `Validate` (confirmed in task 0.1), so no required check is keyed to the old display name. The concurrency group is renamed with it; the only effect is that a run already in flight under the old group would not serialize against a new one, which is a single transition, not a standing property. Task 1.5 now says to do this, resolving its tension with 1.7 rather than leaving the next reader to rediscover it. --- .github/workflows/release-vale.yml | 4 ++-- openspec/changes/nightly-cli-builds/tasks.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-vale.yml b/.github/workflows/release-vale.yml index d2d64d2c..8f16317d 100644 --- a/.github/workflows/release-vale.yml +++ b/.github/workflows/release-vale.yml @@ -80,7 +80,7 @@ # # Action refs are pinned to commit SHAs; the trailing comment records the tag. -name: Vale Binaries +name: Release Vale on: # Detect only. Weekly is a deliberate cadence choice: a Vale security release @@ -111,7 +111,7 @@ on: permissions: {} # One at a time, so a scheduled detect cannot race a publish. -concurrency: vale-binaries +concurrency: release-vale jobs: detect: diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index 81d2ef6a..b077e565 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -12,7 +12,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [x] 1.2 Create `.github/workflows/release-cli.yml` from `release.yml`'s `check` and `publish` jobs, keeping them **in one file** — the credential-free gate is what keeps an OIDC-capable job from existing on ordinary pushes, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit (D5) - [x] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and say so in the header: the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled by treating a publish failure as possibly-already-published, the way `vale-binaries.yml` already does (D6) - [x] 1.4 Correct the stale claim carried over from `release.yml`'s header — `npm-production` **does** have a required reviewer, so "No required reviewers (fully automatic once the Version Packages PR merges), by design" is false and must be replaced with what is actually configured and why -- [x] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml` with no behavior change, preserving the whole header comment +- [x] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml`, preserving the whole header comment. Rename its `name:` to `Release Vale` and its concurrency group to `release-vale` as well — a file called `release-vale.yml` that still announces itself as `Vale Binaries` reproduces the naming mismatch this split exists to remove. Safe to do here because branch protection requires only `Validate` (confirmed in 0.1), so no required check depends on the old display name - [x] 1.6 Delete `.github/workflows/release.yml` - [x] 1.7 Check for references to the old filenames — branch protection required checks, `pr-check-openspec.yml`, `require-changeset.yml`, `stack-breadcrumb.yml`, README and docs — and update anything that names `release.yml` or `Vale Binaries`. A renamed workflow means a renamed check, and a required check that no longer reports blocks merges silently - [x] 1.8 Add the changeset on this branch, before cutting the PRs above it, describing **this PR's scope only** — the workflow split. The stack merges forward, so `CLAUDE.md` requires each unit to extend the changeset with what it actually landed rather than the base promising the whole change up front; a reviewer reading it should see only what has merged. Groups 4 and 5 each extend the same file (never add a second changeset) From b3cc5ff22fdec1ba03513eca3d77e47421b5112f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 13:11:34 -0700 Subject: [PATCH 3/4] docs(changeset): say which operational details the split changes The release note claimed nothing about how the CLI is built, versioned, or published changes. Two things do: check/publish no longer share the release-* concurrency group, and the release runs as two workflow runs, so its check contexts are renamed. The publish mechanics are what is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .changeset/nightly-cli-builds.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.changeset/nightly-cli-builds.md b/.changeset/nightly-cli-builds.md index 3f779ef0..e0acb3c8 100644 --- a/.changeset/nightly-cli-builds.md +++ b/.changeset/nightly-cli-builds.md @@ -12,7 +12,14 @@ OIDC identity — and `release-cli.yml`, which keeps the credential-free protects, so an OIDC-capable job is never instantiated on an ordinary merge. `vale-binaries.yml` is renamed `release-vale.yml` to match. -Nothing about how `@taskless/cli` is built, versioned, or published changes. -The header comments do get one correction: they claimed `npm-production` had no -required reviewers, and it has had one all along, so a release has always +The build and publish steps themselves are unchanged — same triggers, same +`permissions: {}`, same action pins, same OIDC trusted publishing behind the +same `npm-production` approval. Two operational details do differ: `check` and +`publish` no longer share the `release-*` concurrency group, and the release +now runs as two workflow runs instead of one, so its check contexts are +`Release CLI Version PR / …` and `Release CLI / …` rather than `Release / …`. +Neither is a required check. + +The header comments also get one correction: they claimed `npm-production` had +no required reviewers, and it has had one all along, so a release has always waited for a human approval that the file said was not there. From 137b943b25b390ecf669cc34fdce4a5fe1a04728 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 20:27:44 -0700 Subject: [PATCH 4/4] ci: guard the CLI publish the way the Vale publish is guarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the concurrency group was justified by a mitigation that lives in a different file. `release-vale.yml` runs `npm view` immediately before each `npm publish` and skips when the version is already there; `release-cli.yml` had a bare `npm publish` with no pre-check and no error handling. The header, design D6, and task 1.3 all claimed otherwise. The `check` job does query npm, but it is a separate job — which is exactly where the window is. A gate in another job cannot close the gap between its own answer and the publish. So the guard moves to where it can do the work, immediately before the publish. That is better than serializing rather than merely equivalent: it is idempotent instead of ordered, so it also absorbs a re-run against a version an earlier attempt already shipped, which a concurrency group does nothing about. Without it the losing run of a race fails with npm's "cannot publish over the previously published version" — a duplicate that reads as a broken release. The three places that asserted the old story now describe this one, and say that the omitted concurrency group is safe *because of* the guard rather than on its own. --- .github/workflows/release-cli.yml | 48 +++++++++++++++---- openspec/changes/nightly-cli-builds/design.md | 2 +- openspec/changes/nightly-cli-builds/tasks.md | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 42171360..7c5c6f30 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -33,15 +33,19 @@ # `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. The -# credential-free gate already makes a duplicate run a no-op: the second run -# sees the version on npm and does nothing. The residual TOCTOU (two runs both -# observing "not published") is handled the way release-vale.yml handles it — -# a failed publish is treated as possibly-already-published rather than as a -# fresh error, because a version is immutable and the losing run is trying to -# publish bytes the winner already shipped. Serializing would buy nothing the -# gate does not already provide, at the cost of queueing a release behind -# unrelated pushes. +# 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. @@ -129,5 +133,29 @@ jobs: # 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 + # + # 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 diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 3fdafd92..dfecb88b 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -131,7 +131,7 @@ The split is otherwise a move, not a rewrite. Each file keeps the header comment Today one group serializes `version` and `publish`. After the split: - **`release-cli-changeset.yml` keeps `concurrency: release-${{ github.ref }}`.** It is the flow that cares. Two pushes racing on the Version Packages PR branch is a real failure and the group prevents it. -- **`release-cli.yml` runs unserialized, deliberately.** Its credential-free gate makes a duplicate publish a no-op: the second run sees the version on npm and does nothing. The residual TOCTOU — two runs both observing "not published" — is handled the way `vale-binaries.yml` already handles it, by treating a publish failure as possibly-already-published rather than as an error. Serializing would buy nothing the gate does not already provide, at the cost of queueing releases behind unrelated pushes. +- **`release-cli.yml` runs unserialized, deliberately — but the gate that permits it is not the `check` job.** `check` is credential-free and makes the ordinary duplicate a no-op, so the publish job never exists for a version already on npm. It is a _separate job_ though, so a window remains between its answer and the publish, and a gate in another job cannot close it. What closes it is an `npm view` guard immediately before `npm publish`, added to the publish step: idempotent rather than merely ordered, so it also absorbs a re-run against a version an earlier attempt already shipped — something serialization does not address. `release-vale.yml` guards each tarball the same way. Without that inline guard the removal of the concurrency group would be unjustified, and the losing run of a race would fail with npm's "cannot publish over the previously published version", reading as a broken release rather than a duplicate already handled. - **`release-cli-nightly.yml`** has the same property for the same reason: gate 2 is per-SHA, and two runs for one SHA cannot both publish. Stating this explicitly matters because the obvious reading of the split — "the group was on the file, so put it on all four files" — would serialize flows that have no reason to wait on each other. diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index b077e565..e91d2d13 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -10,7 +10,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [x] 1.1 Create `.github/workflows/release-cli-changeset.yml` from `release.yml`'s `version` job, keeping `concurrency: release-${{ github.ref }}` and the header text explaining why the job holds no credential and no OIDC identity (D5, D6) - [x] 1.2 Create `.github/workflows/release-cli.yml` from `release.yml`'s `check` and `publish` jobs, keeping them **in one file** — the credential-free gate is what keeps an OIDC-capable job from existing on ordinary pushes, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit (D5) -- [x] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and say so in the header: the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled by treating a publish failure as possibly-already-published, the way `vale-binaries.yml` already does (D6) +- [x] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and earn that by adding an `npm view` guard immediately before `npm publish` — the `check` job is a separate job, so it cannot close the window between its answer and the publish. `release-vale.yml` guards each tarball the same way. Say both parts in the header, since the omission is only safe because of the guard (D6) - [x] 1.4 Correct the stale claim carried over from `release.yml`'s header — `npm-production` **does** have a required reviewer, so "No required reviewers (fully automatic once the Version Packages PR merges), by design" is false and must be replaced with what is actually configured and why - [x] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml`, preserving the whole header comment. Rename its `name:` to `Release Vale` and its concurrency group to `release-vale` as well — a file called `release-vale.yml` that still announces itself as `Vale Binaries` reproduces the naming mismatch this split exists to remove. Safe to do here because branch protection requires only `Validate` (confirmed in 0.1), so no required check depends on the old display name - [x] 1.6 Delete `.github/workflows/release.yml`