Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/nightly-cli-builds.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/scripts/vale-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/release-cli-changeset.yml
Original file line number Diff line number Diff line change
@@ -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 }}
161 changes: 161 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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):
#
Expand All @@ -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 <valeVersion>-<yyyymmddhhmmss>, a version npm has
# never seen, so it would answer "not published" every time and could never
# suppress anything.
Expand Down Expand Up @@ -79,7 +80,7 @@
#
# Action refs are pinned to commit SHAs; the trailing comment records the tag.

name: Vale Binaries
name: Release Vale
Comment thread
thecodedrift marked this conversation as resolved.

on:
# Detect only. Weekly is a deliberate cadence choice: a Vale security release
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading