Release CLI Nightly #764
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-License-Identifier: MIT | |
| # Publish @taskless/cli-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." |