Skip to content

Release CLI Nightly #743

Release CLI Nightly

Release CLI Nightly #743

# SPDX-License-Identifier: MIT
# Publish @taskless/cli-nightly — the CLI as it stands on main, between releases.
#
# A nightly is the same build as the release it anticipates, published under a
# different name and a stamped prerelease version (design D2/D3). The rename
# happens at pack time in .github/scripts/nightly-pack.cjs; the committed
# packages/cli/package.json is never changed, so @taskless/cli's own version
# history contains releases and nothing else.
#
# A VALIDATED COMMIT ON main, NEVER A PULL REQUEST. This is a security property,
# not a convenience. A PR-triggered publish would invert the split
# release-cli.yml exists to maintain: contributor-authored text would flow into
# a job holding an OIDC identity, and UNREVIEWED code would be published to npm
# under the @taskless scope. Building from main means the failure cannot arise —
# the only code that can be published is code that already merged. (A PR-side
# trigger is also measurably stale: a changeset is written early and the
# implementation lands after it, by 7 and 11 commits on the two branches
# measured for D1.)
#
# TRIGGERED BY `Validate` FINISHING, NOT BY THE PUSH ITSELF (issue #127; the
# requirement is "A nightly is published only from a commit that passed
# validation" in openspec/specs/cli-nightly-builds/spec.md). Publishing has to
# mean "this commit builds, lints, typechecks and tests cleanly." A second
# workflow listening to the same `push` event only means "this commit exists":
# Validate and this file ran in PARALLEL. `workflow_run` is the only trigger
# GitHub offers that fires after another workflow's verdict.
#
# THIS IS OBSERVED, NOT THEORETICAL. On 3f114d6, Validate FAILED at 00:24:27 and
# @taskless/cli-nightly@0.11.0-20260821002453x3f114d6 published 26 seconds later
# at 00:24:53, taking dist-tags.latest. Read the caveat honestly: that failure
# was the OpenSpec hygiene gate rather than a broken build, so those bytes were
# probably fine. That is the argument FOR this change, not against it — nothing
# in the old arrangement could tell "red for bookkeeping" from "red because the
# tests fail", and it published on both.
#
# THE COST IS LATENCY, AND IT IS THE INTENDED TRADE. The two workflows used to
# start together; a nightly now waits the full Validate wall clock (~1m10s on
# recent runs) before its gates even begin. Publishing an artifact nobody has
# checked is not faster, it is just earlier.
#
# THE ABSENCE OF A VERDICT IS THE ABSENCE OF A RUN, and that is the property to
# protect above the mechanism. There is no "Validate has not reported yet" state
# for this file to misread, because nothing here comes into existence until
# Validate has reported. The three answers are structurally distinct: Validate
# passed (this run's jobs execute), Validate did not pass (this run exists with
# every job skipped), Validate never reported (no run at all). A gate that
# polled for the check from inside this workflow would have to tell "not started
# yet" from "passed" using the same absent answer, and would be wrong in the
# direction that publishes.
#
# FOUR CONDITIONS ON THE GATE JOB, EACH LOAD-BEARING. Do not collapse them:
#
# * `conclusion == 'success'` — a POSITIVE test. `!= 'failure'` would admit
# `cancelled`, `timed_out`, `skipped`, `neutral`, `action_required`, and the
# null conclusion, i.e. six ways of not having passed read as passing.
# * `event == 'push'` — Validate also runs on every pull request, including
# from forks, and this is the boundary that keeps those out. It is what
# makes the `pull_request_target`-shaped hazard (.github/copilot-instructions.md)
# inapplicable here: no run of this workflow ever checks out a pull request
# head.
# * `head_branch == 'main'` — validate.yml's push trigger is already filtered
# to main, but a trust boundary must not be spread across two files, one of
# which is free to change its filter. Note it CANNOT stand alone: a fork
# whose own default branch is called `main` produces a Validate run with
# `head_branch: main`, which is why the `event` test above is the actual
# fork boundary and this one is the branch one.
# * `head_repository.full_name == github.repository` — redundant given
# `event == 'push'`, kept deliberately as a second, independent reason the
# fork case cannot reach a checkout. The cost is one line.
#
# `github.sha` IS NOT THE TESTED COMMIT under `workflow_run` — it is the tip of
# the default branch when the event fired, which on a busy day is a later commit
# that Validate has said nothing about. Every checkout here therefore carries an
# explicit `ref: github.event.workflow_run.head_sha`, and both jobs then assert
# that HEAD is that commit. That assertion is not ceremony: an EMPTY `ref:`
# makes actions/checkout fall back to the default branch and succeed, so a
# missing head_sha would publish an unvalidated commit with nothing reporting an
# error — the same fail-open shape gate 2 was already fixed for. Gate 2 is
# per-SHA, so a wrong SHA also silently stops deduplicating.
#
# THIS FILE ALWAYS RUNS FROM main's COPY. GitHub loads a `workflow_run` workflow
# from the default branch, whatever the triggering run was. Two consequences: an
# edit here cannot be exercised on its own pull request, so the first proof is
# the first qualifying push after it merges; and the workflow deciding to
# publish is reviewed separately from the code it publishes.
#
# `workflows: [Validate]` MATCHES validate.yml's `name:`, NOT ITS PATH. Renaming
# that string stops every nightly, permanently, with no error anywhere — gate 1
# is false on most pushes anyway, so nobody would notice the silence.
# validate.yml carries a comment on its `name:` saying so; keep the two in
# agreement.
#
# Every Validate run on a pull request also produces a run of this workflow with
# all jobs skipped. That Actions-tab noise is accepted: a `branches:` filter on
# the trigger would hide it, but it matches `head_branch`, which a fork controls,
# so putting it there would dress a filter up as the security boundary it cannot
# be. Let the workflow run and decide inside it (CLAUDE.md).
#
# TWO GATES, IN THIS ORDER (design D4):
#
# 1. Are any changesets pending? No pending changeset means nothing is
# unreleased: main is at its released version and there is no future n.m.k
# to name. This is a file listing. It runs BEFORE any dependency install,
# because it is false on the overwhelmingly common push and there is no
# cheaper place to exit.
#
# 2. Does this commit already have a nightly? Versions ending in `x<sha>`
# belong to this commit, so a re-run — or any future trigger that fires
# twice for one commit — publishes nothing.
#
# The order matters: gate 1 costs a `find`, gate 2 costs a registry round trip.
# Only past both does anything get installed, built, or credentialed.
#
# THE VERSION PACKAGES MERGE NEEDS NO SPECIAL CASE, and that falls out of gate 1
# rather than being arranged. That merge consumes every changeset and bumps
# packages/cli/package.json, so on that push .changeset/ holds only its README
# and config, gate 1 is false, and no nightly is built — while release-cli.yml
# sees a version npm has not got and publishes the real release. Neither
# workflow knows about the other, and neither has a rule naming the other's
# commit.
#
# That survives the move to `workflow_run` unchanged, and it survives it twice
# over. The Version Packages merge is an ordinary push to main, so Validate runs
# on it and this workflow is triggered exactly as before — and gate 1 is still
# false, because that merge consumed the changesets. And in the case where
# Validate does not run at all for some push, the nightly not running is already
# the desired outcome, so the failure mode of the new trigger points the same
# way as gate 1 does.
#
# GATES ARE CREDENTIAL-FREE AND LIVE IN THEIR OWN JOB, exactly as in
# release-cli.yml. The point is not saving CI minutes: it is that the
# OIDC-capable `publish` job is never INSTANTIATED for a run that will not
# publish. Keep the two jobs in this file together for the same reason
# release-cli.yml keeps its `check` and `publish` together — a later edit that
# reads only the publish half would see `id-token: write` with no visible reason
# for the `needs:` and drop it.
#
# NO CONCURRENCY GROUP, deliberately (design D6). Gate 2 is per-SHA, and two
# runs for one SHA cannot both publish; the `npm view` guard immediately before
# `npm publish` closes the remaining window between the gate job's answer and
# the publish, the same way release-cli.yml and release-vale.yml do.
#
# PUBLISHING IDENTITY: npm trusted publishing, a short-lived OIDC-minted token
# bound to the `npm-autopublish` environment, with no stored NPM_TOKEN anywhere.
# `npm-autopublish` has no required reviewer — an unattended flow cannot use
# npm-production, which does — and earns that with a deployment branch policy
# restricting it to main. Approval gates what users get by default; code review
# gates what gets published, and for a nightly the code review already happened
# on the merge (design D7). The trusted-publisher binding is registered PER
# PACKAGE on npmjs.com and there is nothing to bind until the name exists, so
# the FIRST publish of @taskless/cli-nightly is a deliberate one-time manual
# step by a maintainer. There is no fallback token path here on purpose.
#
# ONE VERSION, TWO CONSUMERS. The stamped version is not only the published
# version: the CLI build bakes `npx @taskless/cli-nightly@<version>` into every
# skill, command, and recipe the nightly ships, so an agent reading them calls
# the package the user actually installed rather than the released
# `@taskless/cli`. That means the version has to exist BEFORE the build, and the
# build and the pack must use the same one — two `new Date()` calls a build
# apart would ship instructions naming a version that was never published.
# `nightly-pack.cjs --print-version` stamps it once; the build reads it from
# TASKLESS_NIGHTLY_VERSION and the pack takes it as `--version`, which is the
# only version input pack mode accepts (it rejects `--status`/`--sha`, so it
# cannot recompute one).
#
# ANNOUNCING THE NIGHTLY ON THE VERSION PACKAGES PR. A nightly exists so the
# work sitting in pending changesets can be RUN before it is released, and the
# pull request where that audience already is, is the changesets "Version
# Packages" PR — the one that lists exactly those changesets. So after a
# publish, a third job writes a `<!-- nightly -->` … `<!-- /nightly -->` region
# at the END of that body. The region is REMOVED AND RE-APPENDED on every
# publish rather than edited in place: deleting it is a supported thing for a
# human to do, and a body someone has reordered then converges instead of
# accumulating one block per day.
#
# The reasoning that belongs with the reader of this file, in the order it is
# likely to be questioned:
#
# WHY A THIRD JOB. Writing a PR body needs `pull-requests: write`. Granting
# that to `publish` would widen what a compromised step there can reach from
# "publish a package under the @taskless scope" to "publish a package AND
# rewrite pull request text" — including the text of the PR that gates the
# next release. It is the same boundary the gate/publish split already draws,
# drawn once more. The two capabilities never coexist in one job.
#
# WHY IT PARSES THE VERSION INSTEAD OF MEASURING ANYTHING. The stamp is
# `<n.m.k>-<yyyymmddhhmmss>x<sha>`, so it already carries the build time and
# the commit. A fresh `Date.now()` would print a time disagreeing with the
# version on the line above it, and a fresh `git rev-parse` a sha the
# published tarball does not carry. This is the THIRD consumer of the
# stamped-once rule (build, pack, breadcrumb) and it obeys it by reading the
# version backwards — see parseStampedVersion in nightly-breadcrumb.cjs.
#
# WHY "NO PULL REQUEST" IS SUCCESS AND "NO ANSWER" IS NOT. See the job's own
# comment below; the distinction is the same fail-open closed in gate 2.
#
# WHY NOT stack-breadcrumb.cjs. Its REGION_PATTERN hardcodes the name `stack`
# and its upsert is keyed to PR numbers; neither generalizes to a singleton
# region under another name. The two regions do coexist on one body — a
# Version Packages PR can be in a stack — and neither pattern can match the
# other's markers, which nightly-breadcrumb.test.cjs covers directly.
#
# WHY THE REGION NAMES @taskless/cli-nightly. Issue #128 wrote the install
# line as `npx @taskless/cli@<version>`. That version exists only under the
# nightly name (D2), so the literal line would 404 — or, worse, resolve to
# the last RELEASE and look like it worked.
#
# BOOTSTRAPPING THE PACKAGE NAME, once — the same three inputs in the same
# order, including the build, which produces the `dist/` the tarball carries:
#
# pnpm install --frozen-lockfile
# pnpm exec changeset status --output=nightly-status.json
# VERSION=$(node .github/scripts/nightly-pack.cjs --print-version \
# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)")
# TASKLESS_NIGHTLY_VERSION="$VERSION" pnpm --filter @taskless/cli build:nightly
# node .github/scripts/nightly-pack.cjs --version "$VERSION"
# npm publish --access public --tag latest .nightly-dist/*.tgz
#
# `build:nightly` emits to `packages/cli/dist` — the same directory as an
# ordinary build, because `files: ["dist"]` is what npm packs — so it overwrites
# a local prod build. Run `pnpm --filter @taskless/cli build` afterwards.
#
# Publish the packed tarball rather than the package directory: a bare
# `npm publish` in packages/cli would burn the name on @taskless/cli's committed
# name and version. Provenance is omitted from the manual step (it needs a CI
# OIDC identity); register the trusted publisher afterwards and every later
# publish gets it.
#
# Action refs are pinned to commit SHAs; the trailing comment records the tag.
name: Release CLI Nightly
on:
# Not `push`. See the header: a nightly may only be built from a commit
# `Validate` has already passed on, and this is the only trigger that fires
# after another workflow's verdict. `workflows:` matches validate.yml's
# `name:` — renaming it there silently retires this file.
workflow_run:
workflows: [Validate]
types: [completed]
# No workflow-wide grants; each job asks for exactly what it needs.
permissions: {}
jobs:
gate:
name: "nightly gate"
runs-on: ubuntu-latest
# GATE 0, and the only one that cannot be expressed as a step: was this a
# SUCCESSFUL Validate run, on a PUSH, to main, in THIS repository? Each
# clause is explained in the header; all four are required and none is
# implied by another. A job-level `if` is where this belongs — there is no
# workflow-level `if`, and `publish` needs `gate`, so a skipped `gate`
# skips the OIDC-capable job with it.
if: >-
github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'push'
&& github.event.workflow_run.head_branch == 'main'
&& github.event.workflow_run.head_repository.full_name == github.repository
permissions:
contents: read # checkout only
outputs:
should_publish: ${{ steps.gate.outputs.should_publish }}
short_sha: ${{ steps.gate.outputs.short_sha }}
steps:
# `ref:` is MANDATORY under workflow_run. Without it checkout takes
# `github.sha`, which here is the default branch tip at event time, not
# the commit Validate tested.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false # nothing here writes to git
- name: Confirm the checkout is the commit Validate passed on
env:
VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
# An empty `ref:` is not an error to actions/checkout — it falls back
# to the default branch and succeeds. So "no head_sha in the payload"
# would otherwise present as a normal run against a commit nobody
# validated, and gate 2 would dedupe against the wrong sha at the same
# time. Absent must not read as fine.
if [ -z "$VALIDATED_SHA" ]; then
echo "::error::The workflow_run payload carried no head_sha; refusing to build a nightly from an unidentified commit."
exit 1
fi
actual=$(git rev-parse HEAD)
if [ "$actual" != "$VALIDATED_SHA" ]; then
echo "::error::Checked out ${actual} but Validate passed on ${VALIDATED_SHA}; refusing to build a nightly from a commit that was not validated."
exit 1
fi
echo "Building from ${actual}, which Validate passed on."
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
# No dependency install in this job, by design — gate 1 is the reason the
# workflow can decide before installing anything, and nightly-pack.cjs is
# zero-dependency CommonJS, so gate 2 can reuse its tested matcher without
# one either.
- id: gate
run: |
set -euo pipefail
# GATE 1 — are any changesets pending?
#
# NOT a bare directory listing: .changeset/ permanently holds README.md
# and config.json, so it is never empty and `ls | wc -l` would report
# "pending" on every push forever. The question is whether any
# .changeset/*.md OTHER than the template README exists.
# require-changeset.yml counts them with exactly this rule; keep the
# two in agreement rather than inventing a second definition of "a
# changeset."
pending=$(find .changeset -maxdepth 1 -name '*.md' | grep -viE '/README\.md$' || true)
if [ -z "$pending" ]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "No pending changesets — main is at its released version, so there is no nightly to build."
exit 0
fi
echo "Pending changeset(s):"
echo "$pending" | sed 's/^/ - /'
# A PINNED abbreviation length. `git rev-parse --short` scales the
# length with the size of the repository, so an unpinned length would
# eventually produce an 8-character sha that no longer matches the
# 7-character suffix of every nightly published before it — gate 2
# would stop deduping, silently, at a moment unrelated to any change
# here.
#
# Pinned, not fixed, and the difference is worth knowing: `--short=<n>`
# sets a MINIMUM width and git still lengthens past it to keep the
# abbreviation unambiguous. It holds the floor steady, which is what
# gate 2's suffix match needs; it is not a guarantee of exactly seven
# characters, so nothing downstream may assume the width (raised in
# review on #132 — see the publish job's prefix test).
short_sha=$(git rev-parse --short=7 HEAD)
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
# GATE 2 — does this commit already have a nightly?
#
# A list-and-filter rather than a point lookup: the timestamp precedes
# the sha in the version, so the exact string is not known until the
# build computes it.
#
# THE ANSWER IS THREE-WAY, NOT TWO. "Already built", "not built", and
# "could not tell" are different, and the third must fail the job.
# A gate whose only job is suppression must not fail open: the version
# carries a timestamp, so a re-run after an unreadable registry
# response mints a DIFFERENT version for the SAME commit and publishes
# it successfully — two nightlies for one sha, with no error anywhere.
# An earlier `|| versions='[]'` did exactly that for any hiccup.
#
# The npm exit status is captured and handed to the classifier rather
# than being collapsed here, because ONE non-zero exit is legitimate:
# `npm view --json` on a 404 prints an `{"error":{"code":"E404"}}`
# object to stdout (measured) and exits 1, which on the bootstrap day
# genuinely means "nothing published yet." Every other failure — a
# different error code, truncated output, anything unparseable —
# raises. parseVersionsResponse in nightly-pack.cjs draws that line
# and is unit-tested; this step only routes its exit code.
set +e
versions=$(npm view @taskless/cli-nightly versions --json 2>/dev/null)
npm_status=$?
SHORT_SHA="$short_sha" NPM_STATUS="$npm_status" node -e '
const { hasNightlyForSha, parseVersionsResponse } = require("./.github/scripts/nightly-pack.cjs");
let raw = "";
process.stdin
.on("data", (chunk) => { raw += chunk; })
.on("end", () => {
try {
const versions = parseVersionsResponse(raw, process.env.NPM_STATUS);
process.exit(hasNightlyForSha(versions, process.env.SHORT_SHA) ? 0 : 1);
} catch (error) {
console.error(error.message);
// 2, never 1: exit 1 is the "no nightly for this sha" answer,
// and an uncaught throw would be indistinguishable from it.
process.exit(2);
}
});
' <<< "$versions"
gate_status=$?
set -e
case "$gate_status" in
0)
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "A nightly ending in x${short_sha} is already published — nothing to do."
;;
1)
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "Will build a nightly for ${short_sha}."
;;
*)
echo "::error::Could not determine whether ${short_sha} already has a nightly; refusing to publish a possible duplicate."
exit 1
;;
esac
publish:
name: Publish the nightly to npm
# Gate on the credential-free job: this job — and therefore the OIDC
# identity and the npm-autopublish environment — only exists for a run that
# will actually publish.
needs: gate
if: needs.gate.outputs.should_publish == 'true'
runs-on: ubuntu-latest
# The stamped version, for the breadcrumb job below. Published exactly once
# (see "ONE VERSION, TWO CONSUMERS" above) and passed on, never recomputed —
# a second stamp would read a second clock and name a version that was
# never published.
outputs:
version: ${{ steps.version.outputs.version }}
# The scoping and audit boundary for the nightly, and where the npm trusted
# publisher for @taskless/cli-nightly is bound. No required reviewer, by
# design; its deployment branch policy restricts it to main.
environment: npm-autopublish
permissions:
contents: read # checkout only
id-token: write # OIDC → short-lived npm auth + build provenance
steps:
# Same explicit `ref:` as the gate job, for the same reason, and asserted
# again here rather than trusted from there. This is the job that produces
# the bytes: `gate` proved that some job checked out the validated commit,
# not that this one did.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false # publish authenticates via OIDC, not git creds
- name: Confirm the checkout is the commit Validate passed on
env:
VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }}
GATE_SHORT_SHA: ${{ needs.gate.outputs.short_sha }}
run: |
set -euo pipefail
if [ -z "$VALIDATED_SHA" ]; then
echo "::error::The workflow_run payload carried no head_sha; refusing to publish from an unidentified commit."
exit 1
fi
actual=$(git rev-parse HEAD)
if [ "$actual" != "$VALIDATED_SHA" ]; then
echo "::error::Checked out ${actual} but Validate passed on ${VALIDATED_SHA}; refusing to publish a commit that was not validated."
exit 1
fi
# The gates were evaluated against a short sha, and that same short
# sha is stamped into the version below. If the two jobs somehow saw
# different commits, the dedupe answer belongs to one of them and the
# tarball to the other.
#
# TESTED AS A PREFIX, NOT RE-ABBREVIATED AND COMPARED. `--short=<n>`
# is a MINIMUM width, not a fixed one: git lengthens an abbreviation
# whenever it is ambiguous in the object database of the job that runs
# it. Two jobs abbreviating the SAME commit can therefore disagree —
# and a string comparison would fail the run with "the gate decided
# for X but this job holds Y" while nothing whatsoever is wrong
# (raised in review on #132). A prefix test cannot do that: every
# abbreviation of this commit, at any width, is a prefix of its full
# sha. It is also the invariant actually wanted — "the short sha about
# to be stamped into the version names THIS commit" — rather than a
# proxy for it.
if [ -z "$GATE_SHORT_SHA" ]; then
echo "::error::The gate job published no short_sha; refusing to publish a version stamped from an unknown commit."
exit 1
fi
if [ "${VALIDATED_SHA#"$GATE_SHORT_SHA"}" = "$VALIDATED_SHA" ]; then
echo "::error::The gate job decided for ${GATE_SHORT_SHA}, which does not name ${VALIDATED_SHA}; refusing to publish."
exit 1
fi
echo "Publishing from ${actual}, which Validate passed on."
# RESTORES A REF THE `push` TRIGGER USED TO PROVIDE FOR FREE, and without
# which `changeset status` below cannot run at all.
#
# `changeset status` resolves the base branch from .changeset/config.json
# (`baseBranch: main`) and shells out to `git merge-base main HEAD`. Under
# the old `push` trigger, checkout was given no `ref:` and so checked out
# `refs/heads/main`, which creates a LOCAL `main` branch as a side effect
# — and that side effect, not anything deliberate, is what made the
# command work. Three nightlies published on top of it.
#
# Moving to `workflow_run` (#132) requires an explicit `ref:` — an empty
# one silently falls back to the default branch — but checking out a bare
# sha lands in DETACHED HEAD with no branches at all, so `main` stopped
# resolving and every run failed with:
#
# Failed to find where HEAD diverged from "main".
# Does "main" exist and it's synced with remote?
#
# Reproduced outside CI with `git fetch --depth=1 origin <sha>` +
# `git checkout --detach FETCH_HEAD`, which yields exactly
# `fatal: Not a valid object name main`.
#
# Pointing `main` at HEAD is not an approximation of the old behavior, it
# IS the old behavior: on a push to main the checked-out commit and the
# `main` branch were the same commit, so `merge-base` returned HEAD then
# too. `getChangedPackagesSinceRef` was already a no-op here and stays
# one; the `releases` array this step is read for comes from the
# `.changeset/*.md` files, not from a git diff.
#
# Do NOT "fix" this with `fetch-depth: 0` instead. That fetches every ref
# into `refs/remotes/origin/*`, and a bare `main` does not resolve through
# a remote-tracking ref — it would cost a full clone on every run and
# still fail.
- name: Give changesets the `main` ref it diffs against
run: git branch -f main HEAD
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# setup-node >= v7 is the floor here; release-cli.yml's publish job
# explains why (`always-auth` in .npmrc until v6.1, a dummy
# NODE_AUTH_TOKEN in env until v7).
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: https://registry.npmjs.org
# --ignore-scripts: no dependency lifecycle code runs while an OIDC
# identity is available in this job.
- run: pnpm install --frozen-lockfile --ignore-scripts
# OIDC trusted publishing and provenance need npm >= 11.5.1. Pinned to the
# same version release-cli.yml and release-vale.yml pin, not @latest, so
# publish behavior cannot change unreviewed; bump all three together.
- run: npm install -g npm@12.0.1 --ignore-scripts
# The JSON FILE is authoritative, not stdout: `changeset status` also
# emits one workspace-version warning per @taskless/vale-* package per
# changeset (18 lines in the current tree). The path must be
# REPO-RELATIVE — `--output` resolves against the working directory with
# no special case for a leading `/`, so `--output=/tmp/status.json` means
# `<cwd>/tmp/status.json` and fails with ENOENT from the repo root.
#
# This now runs BEFORE the build, because the build needs the version.
- run: pnpm exec changeset status --output=nightly-status.json
# THE VERSION IS STAMPED HERE, ONCE, and every later step is handed it.
# Both the build and the pack consume it; if each computed its own, they
# would read the clock a build apart and the skills inside the tarball
# would name a version that was never published. The sha comes from the
# gate job so the gates and the stamp describe the same commit at the same
# abbreviation length, and it is routed through `env:` rather than
# interpolated into the shell body.
- id: version
env:
SHORT_SHA: ${{ needs.gate.outputs.short_sha }}
run: |
node .github/scripts/nightly-pack.cjs \
--print-version \
--status nightly-status.json \
--sha "$SHORT_SHA"
# `build:nightly` bakes `npx @taskless/cli-nightly@<version>` into every
# skill, command, and recipe it emits, so an agent following a nightly's
# instructions calls the package the user installed rather than the
# released @taskless/cli. The target REFUSES to build without a valid
# TASKLESS_NIGHTLY_VERSION — falling back to the released invocation would
# be the silent bug this exists to prevent. Output goes to
# packages/cli/dist, the directory `files: ["dist"]` packs.
- env:
TASKLESS_NIGHTLY_VERSION: ${{ steps.version.outputs.version }}
run: pnpm --filter @taskless/cli build:nightly
# Rewrites packages/cli/package.json to the nightly name and the stamped
# version, packs, and restores the manifest. Pack mode takes the version
# and cannot derive one: it rejects --status/--sha outright, so it is not
# possible for this tarball's version to differ from the one built above.
- id: pack
env:
NIGHTLY_VERSION: ${{ steps.version.outputs.version }}
run: |
node .github/scripts/nightly-pack.cjs \
--version "$NIGHTLY_VERSION" \
--out .nightly-dist
# `--tag latest` is required, not cosmetic: every version here is a semver
# prerelease by construction, and npm will not move the default tag onto a
# prerelease unless told to. Without it the package would have versions
# and no default, and `npm i @taskless/cli-nightly` would resolve nothing.
# These are not preview builds — they are the only builds of this package.
#
# The `npm view` guard immediately before the publish is what makes the
# missing concurrency group safe. Gate 2 runs in a SEPARATE JOB, so
# between its answer and this line another run can publish for the same
# commit; asking again at the moment it matters is idempotent rather than
# merely ordered. release-cli.yml and release-vale.yml guard the same way.
- name: Publish (skipping a version already on npm)
env:
NIGHTLY_VERSION: ${{ steps.version.outputs.version }}
NIGHTLY_TARBALL: ${{ steps.pack.outputs.tarball }}
run: |
set -euo pipefail
if npm view "@taskless/cli-nightly@${NIGHTLY_VERSION}" version >/dev/null 2>&1; then
echo "@taskless/cli-nightly@${NIGHTLY_VERSION} is already published — nothing to do."
else
npm publish --provenance --access public --tag latest "$NIGHTLY_TARBALL"
fi
# A THIRD JOB, AND IT HOLDS NO CREDENTIAL. Writing a pull request body needs
# `pull-requests: write`, and adding that to `publish` would widen what a
# compromised step there can reach from "publish a package under the
# @taskless scope" to "publish a package AND rewrite pull request text" —
# including the text of the pull request that gates the next release. So the
# split already drawn between `gate` and `publish` is drawn once more: this
# job has `pull-requests: write` and NOTHING ELSE — no `id-token`, no write
# access to contents, no environment, no npm identity. The two capabilities
# never coexist in one job.
#
# `needs: publish` is the "block on a successful publish" requirement: a job
# whose dependency was skipped does not run, so a suppressed nightly (either
# gate false) never reaches this, and neither does a failed publish.
#
# NO OPEN VERSION PACKAGES PULL REQUEST IS NORMAL AND MUST SUCCEED QUIETLY.
# `changeset-release/main` exists only while changesets are pending, and a
# nightly can publish in the seconds before changesets opens it. A cosmetic
# breadcrumb must never fail a run that already published to npm. That is NOT
# the same as the query failing — `gh api` exiting non-zero fails the step,
# because "there is no pull request" and "I could not find out" are different
# answers and only one of them is fine.
breadcrumb:
name: Link the nightly on the Version Packages PR
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read # checkout only — the script lives in this repo
pull-requests: write # the one capability this job exists to use
steps:
# `ref:` is MANDATORY under workflow_run here too, for the same reason it
# is on the other two jobs: without it checkout takes `github.sha`, the
# default branch tip at event time, and this job would run a COPY OF THE
# SCRIPT that is not the one Validate passed on — while announcing a
# nightly built from a different commit. Cosmetic output does not lower
# the bar; it is the same fail-open, and it reads as a normal run.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false # gh authenticates with GITHUB_TOKEN below
- name: Confirm the checkout is the commit Validate passed on
env:
VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
# An empty `ref:` is not an error to actions/checkout — it falls back
# to the default branch and succeeds. Absent must not read as fine.
if [ -z "$VALIDATED_SHA" ]; then
echo "::error::The workflow_run payload carried no head_sha; refusing to annotate from an unidentified commit."
exit 1
fi
actual=$(git rev-parse HEAD)
if [ "$actual" != "$VALIDATED_SHA" ]; then
echo "::error::Checked out ${actual} but Validate passed on ${VALIDATED_SHA}; refusing to annotate with a script from an unvalidated commit."
exit 1
fi
echo "Annotating from ${actual}, which Validate passed on."
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
# A LOST UPDATE IS POSSIBLE HERE, AND IS ACCEPTED (Copilot review, #133).
# A pull request body is replaced whole: GitHub offers no field-level
# patch and no compare-and-swap — PATCH on a pull request honors no
# `If-Match` — so every writer is doing read-modify-write against a
# resource two others also touch. changesets regenerates this body on each
# push to its branch, and stack-breadcrumb.yml rewrites it when the pull
# request is stacked. A write landing between the GET below and the PATCH
# is therefore lost, and no arrangement of these API calls prevents it.
#
# It is accepted because all three writers are ADDITIVE AND SELF-HEALING,
# so a lost update costs one cycle rather than data: if changesets wins,
# the next nightly re-appends its region; if this job wins, changesets
# rewrites the release notes on the next push; the stack breadcrumb
# reconciles on its own dispatch. Coordinating them — a lock, or routing
# all three through one workflow — would buy consistency for a cosmetic
# region by coupling a release workflow to a breadcrumb, which is the
# worse trade. Reach for that only if a writer appears whose content
# cannot be reconstructed.
#
# No dependency install: nightly-breadcrumb.cjs is zero-dependency
# CommonJS, for the same reason the gate job's matcher is.
- id: breadcrumb
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
# The version the publish job stamped. Routed through `env:` rather
# than interpolated into the shell body, as everywhere else here.
NIGHTLY_VERSION: ${{ needs.publish.outputs.version }}
run: |
set -euo pipefail
# A plain query, allowed to fail. No `|| echo '[]'`: a fallback here
# would turn an API error into "no pull request found" and the run
# would go green having silently skipped the update.
#
# `base=main` is not redundant with `head=`. GitHub allows several
# open pull requests from ONE head branch to different bases, so the
# head filter alone can return more than one and the script would
# annotate whichever was listed first. The script re-checks both refs
# for the same reason, so widening this query cannot silently widen
# what gets written.
gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main&base=main" > nightly-pulls.json
# Writes nightly-body.md only when the body actually changes, and
# exits 0 with changed=false when there is no open Version Packages
# pull request at all.
node .github/scripts/nightly-breadcrumb.cjs \
--version "$NIGHTLY_VERSION" \
--pulls nightly-pulls.json \
--out nightly-body.md
# `gh api -X PATCH`, never `gh pr edit`: the GraphQL path `gh pr edit`
# takes is broken by GitHub's Projects (classic) deprecation (see
# CLAUDE.md, "Editing an existing PR").
- if: steps.breadcrumb.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PULL_NUMBER: ${{ steps.breadcrumb.outputs.pull_number }}
run: |
set -euo pipefail
gh api -X PATCH "repos/${REPO}/pulls/${PULL_NUMBER}" \
-f body="$(cat nightly-body.md)" > /dev/null
echo "Updated #${PULL_NUMBER} with the nightly build info."