diff --git a/.changeset/nightly-cli-builds.md b/.changeset/nightly-cli-builds.md new file mode 100644 index 00000000..e0acb3c8 --- /dev/null +++ b/.changeset/nightly-cli-builds.md @@ -0,0 +1,25 @@ +--- +"@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. + +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. 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..7c5c6f30 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,161 @@ +# 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 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..8f16317d 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. @@ -79,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 @@ -110,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/.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..dfecb88b 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. @@ -123,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 2c6fd21e..e91d2d13 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 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` +- [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