fix(ci): publish a nightly only from a commit Validate passed on - #132
Conversation
There was a problem hiding this comment.
Pull request overview
This PR ensures the CLI nightly publish workflow only runs for commits on main that have already passed the repository’s Validate workflow, eliminating the race where a nightly could be published before CI finishes.
Changes:
- Switch
Release CLI Nightlyfrompushtoworkflow_run(completed) for theValidateworkflow, with a job-level gate that only admits successfulpushruns onmainin this repository. - Ensure both the gate and publish jobs explicitly check out and assert the validated commit SHA (
github.event.workflow_run.head_sha) to avoid silently publishing the wrong tip. - Update OpenSpec tasks/spec/design documentation to capture the new requirement and operational implications (including the “skipped jobs” evidence model).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
openspec/changes/nightly-cli-builds/tasks.md |
Adds tasks for switching the trigger to workflow_run and verifying post-merge behavior. |
openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md |
Updates acceptance criteria to require validation success before publishing and documents key scenarios. |
openspec/changes/nightly-cli-builds/design.md |
Documents the design rationale, trust boundaries, and trade-offs for workflow_run-based gating. |
.github/workflows/validate.yml |
Adds an explicit comment that the workflow name: is an interface relied upon by the nightly trigger. |
.github/workflows/release-cli-nightly.yml |
Moves to workflow_run trigger, adds strict job-level gating, and pins checkout to the validated SHA with explicit assertions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
3638b75 to
5982884
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 8m 14s —— View job Review of PR #132
SummaryThis is a well-reasoned, well-documented fix, and the diff matches what the PR body describes. I traced the Specific things verified:
Inline comment postedOne low-severity, non-blocking nit on Not flagged (already justified in the file's own comments, and I agree with the reasoning)
Test adequacyNo new automated tests were added, but that's appropriate here: the changed logic lives in workflow YAML Overall: approve. No blocking issues found. |
Thanks — the one inline finding is fixed in The short-sha nit (fixed, not just noted). You were right that The second instance: the gate job's own comment claimed On the three things you deliberately did not flag — the Verification after the fix: — AI Coding Agent |
Acknowledged, with nothing to action: this review is a descriptive overview and explicitly reports "Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments." No findings, so nothing was changed on its account. Recording it rather than dropping it silently, since a zero-finding review and an unread one look identical after the fact. One correction for anyone reading the file table: it lists — AI Coding Agent |
`release-cli-nightly.yml` and `validate.yml` both listened to `push: branches: [main]`, so they ran in parallel and a nightly could reach npm before CI had formed any opinion about the commit. Observed on 3f114d6: Validate failed at 00:24:27 and @taskless/cli-nightly@0.11.0-20260821002453x3f114d6 published 26 seconds later, taking dist-tags.latest. That particular failure was the OpenSpec hygiene gate rather than a broken build — which is the argument for this change, since nothing distinguished "red for bookkeeping" from "red because the tests fail" and it published on both. Move the nightly onto `workflow_run` on `Validate`, `types: [completed]`. The absence of a verdict is now the absence of a run: there is no "has not reported yet" state to misread, because nothing exists until Validate has reported. The gate job carries a four-clause condition — a positive `conclusion == 'success'`, `event == 'push'` (the actual fork boundary, since a fork's own default branch may also be called `main`), `head_branch == 'main'`, and `head_repository.full_name == github.repository` — and every checkout takes an explicit `ref: workflow_run.head_sha`, because `github.sha` under `workflow_run` is the default-branch tip rather than the tested commit. Both jobs assert HEAD equals that sha: an empty `ref:` makes actions/checkout fall back to the default branch and succeed, which would publish an unvalidated commit while gate 2 deduped against the wrong sha. The credential-free gate / OIDC publish split is unchanged, as is the Version Packages merge, which is an ordinary push to main that Validate runs on and gate 1 still declines. The cost is latency — a nightly now waits the full Validate wall clock — and that is the intended trade. Adds the requirement "A nightly is published only from a commit that passed validation" to openspec/specs/cli-nightly-builds/spec.md, the change having been archived. `validate.yml` gains a comment noting that its `name:` is now an interface — `workflows:` matches the display name, not the path, so renaming it retires the nightly with nothing turning red. Fixes #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ting Raised in review on #132. `git rev-parse --short=<n>` sets 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. The publish job re-derived `--short=7` and string-compared it against the gate job's output, so two jobs abbreviating the same commit could disagree and fail the run with "the gate decided for X but this job holds Y" while nothing was wrong. It failed closed, so it was a spurious-failure risk rather than a publish risk. Test the gate's short sha as a prefix of the full validated sha instead. Every abbreviation of a commit, at any width, is a prefix of its full sha, so the comparison cannot spuriously fail — and it is the invariant actually wanted ("the short sha about to be stamped into the version names this commit") rather than a proxy for it. An empty gate output is classified separately so it cannot pass as a zero-length prefix. Passing the full 40-char sha through instead, as the review suggested, is not available: the version format is `<n.m.k>-<timestamp>x<short-sha>`, so the gate's short sha has to exist and has to be the stamped one. Also corrects the gate job's own comment, which claimed `--short=7` was "A FIXED abbreviation length" — the same overclaim that produced the bug, sitting six lines above where the short sha is computed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
5982884 to
fb737cd
Compare
The nightly has failed on every push since #132 merged — twice, on f5b3797 and 20f18f6 — with: Failed to find where HEAD diverged from "main". Does "main" exist and it's synced with remote? `changeset status` resolves baseBranch from .changeset/config.json and shells out to `git merge-base main HEAD`. Under the old `push` trigger the checkout took no `ref:`, so it checked out refs/heads/main and created a local `main` branch as a side effect. That side effect, not anything deliberate, is what made the command work; three nightlies published on top of it. `workflow_run` 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, so `main` stopped resolving. 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 previous behavior, it is that behavior: on a push to main the checked-out commit and the 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. `fetch-depth: 0` is the obvious wrong fix and the comment says so: a bare `main` does not resolve through a remote-tracking ref, so it would buy a full clone on every run and still fail. This is the failure mode #132 called out as structurally unverifiable before merge — workflow_run loads the workflow from the default branch, so no pull request could exercise it. It failed closed rather than publishing something unvalidated. The breadcrumb job's unpinned checkout is a separate defect, fixed in #137. Refs #127 Refs #131 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
#133 and #132 crossed. The nightly moved to `workflow_run` in #132, where every checkout must carry `ref: github.event.workflow_run.head_sha`; the `breadcrumb` job added by #133 was written against the `push` trigger and merged without it. Under `workflow_run` that is not an error. `github.sha` is the default branch tip at event time, so the job checks out whatever `main` had moved to and succeeds — running a copy of nightly-breadcrumb.cjs that Validate never saw, while announcing a nightly built from a different commit. An empty `ref:` fails the same way, silently, which is why the other two jobs assert HEAD rather than trusting the checkout. Cosmetic output does not lower the bar: it is the same fail-open shape, and it reads as a normal green run. The job now pins the ref and asserts HEAD against it, matching gate and publish line for line. Nothing else moves. `breadcrumb` still reaches gate 0 transitively — a skipped `gate` skips `publish`, which skips this — so it restates none of those four conditions, and it consumes the publish job's stamped version rather than re-deriving a short sha. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
#133 and #132 crossed. The nightly moved to `workflow_run` in #132, where every checkout must carry `ref: github.event.workflow_run.head_sha`; the `breadcrumb` job added by #133 was written against the `push` trigger and merged without it. Under `workflow_run` that is not an error. `github.sha` is the default branch tip at event time, so the job checks out whatever `main` had moved to and succeeds — running a copy of nightly-breadcrumb.cjs that Validate never saw, while announcing a nightly built from a different commit. An empty `ref:` fails the same way, silently, which is why the other two jobs assert HEAD rather than trusting the checkout. Cosmetic output does not lower the bar: it is the same fail-open shape, and it reads as a normal green run. The job now pins the ref and asserts HEAD against it, matching gate and publish line for line. Nothing else moves. `breadcrumb` still reaches gate 0 transitively — a skipped `gate` skips `publish`, which skips this — so it restates none of those four conditions, and it consumes the publish job's stamped version rather than re-deriving a short sha. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Release CLI NightlyandValidateboth listened topush: branches: [main], so they ran in parallel. A nightly could reach npm before CI had formed any opinion about the commit — publishing asserted only "this commit exists onmain", not "this commit builds, lints, typechecks and tests cleanly".This is observed, not theoretical. On
3f114d6,Validatefailed at00:24:27and@taskless/cli-nightly@0.11.0-20260821002453x3f114d6published 26 seconds later at00:24:53. It is on npm right now holdingdist-tags.latest.The honest caveat: that failure was the OpenSpec hygiene gate, not a broken build, so those bytes were probably fine. That is the argument for this change rather than against it — nothing in the old arrangement could tell "red for bookkeeping" from "red because the tests fail", and it published on both.
The mechanism
The nightly now triggers on
workflow_runagainstValidate,types: [completed].workflow_runis the only trigger GitHub offers that fires after another workflow's verdict.The property this buys is structural rather than mechanical: the absence of a verdict is the absence of a run. There is no "Validate has not reported yet" state for the nightly to misread, because nothing comes into existence until Validate has reported. The three outcomes are distinguished by construction:
That mattered here because the repo has been bitten by this exact shape before: gate 2's old
\|\| versions='[]'fell back to "no nightly found" on any unreadable registry response, so a re-run stamped a fresh timestamp and published a duplicate. Underworkflow_runthe "unknown" state is not something this workflow has to classify — it never runs.The gate job's four-clause condition
conclusion == 'success'is a positive test.!= 'failure'would admitcancelled,timed_out,skipped,neutral,action_required, and the null conclusion — six ways of not having passed, read as passing.event == 'push'is the actual fork boundary.Validateruns on every pull request including fork PRs, and — this is the trap — a fork whose own default branch is calledmainproduces a Validate run withhead_branch: main. Branch alone would let it through. This clause is also why thepull_request_target-shaped hazard in.github/copilot-instructions.mddoes not apply: no run of this workflow ever checks out a pull request head.head_branch == 'main'is kept anyway, becausevalidate.yml's ownbranches:filter lives in a different file that is free to change. A trust boundary spread across two files is one edit from being gone.head_repository.full_name == github.repositoryis redundant givenevent == 'push', kept as a second independent reason the fork case cannot reach a checkout. One line.A job-level
ifis the right home: there is no workflow-levelif, andpublishneeds: gate, so a skipped gate skips the OIDC-capable job with it. The credential-free gate / OIDC publish split is unchanged.github.shais not the tested commitUnder
workflow_run,github.shais the default branch tip at event time — on a busy day, a later commit Validate has said nothing about. Both checkouts now carry an explicitref: ${{ github.event.workflow_run.head_sha }}, and both jobs then assertHEADequals it.That assertion is not ceremony. An empty
ref:is not an error toactions/checkout— it falls back to the default branch and succeeds. So a missinghead_shawould publish an unvalidated commit with nothing reporting an error, and because gate 2 is per-SHA, dedup would silently break at the same moment. The publish job additionally asserts its short sha matches the one the gate decided on, since that same short sha is stamped into the published version.The cost is latency, and it is the intended trade
The two workflows used to start together. A nightly now waits the full
Validatewall clock (~1m10s on recent runs) before its gates even begin. Publishing an artifact nobody has checked is not faster, it is just earlier.Alternatives rejected
Validateand pays for it on every push tomain, including the overwhelming majority where gate 1 exits immediately. A second copy of the checks is a second thing to keep in agreement with the first.Validatecheck run from inside the gate jobneeds:across workflowsneedsis job-scoped. Merging the nightly intovalidate.ymlwould put an OIDC-capable job in the file that runs on every pull request, inverting the split the release workflows exist to maintain.push, add abranches:filter or a wait-for-check actionbranches: [main]filter on theworkflow_runtriggerhead_branch, which a fork controls, so it cannot be the security boundary — putting it there would dress a filter up as one. Accepted cost: every PR'sValidaterun also produces a nightly run with all jobs skipped. Actions-tab noise, no runner time.Does this still fire for the Version Packages merge? Yes — and it fails safe twice
The merge of
changeset-release/mainintomainis an ordinary push tomain, soValidateruns on it and this workflow is triggered exactly as before. Gate 1 still handles it with no special case: that merge consumes every changeset, so.changeset/holds onlyREADME.mdandconfig.json, gate 1 is false, and no nightly is built — whilerelease-cli.yml(untouched, still onpush) sees a version npm has not got and publishes the real release.The second layer: in any scenario where
Validatedoes not run for a push, the nightly not running is already the desired outcome. The new trigger's failure mode points the same direction gate 1 does. Established by reading the payload semantics, not by observing a live Version Packages merge — see "not verified" below.Interaction with a red
mainOnce this lands, a red
mainmeans no nightly publishes at all. That is intended, and it is the point of the issue rather than a side effect: a nightly's claim is "this ismain, and it works."mainrunning red while a forward-merging stack drains is a known, documented state, and there is nothing worth publishing during it. (mainwas red on the OpenSpec archive gate until423363acleared it 40 minutes before this PR was rebased — nothing here touches that.)Is any failure mode silent?
Two, and both are named rather than hidden:
workflows: [Validate]matchesvalidate.yml'sname:string, not its path. Renaming that string retires the nightly permanently, with nothing turning red — and since gate 1 is false on most pushes, the silence looks normal. Mitigated only by documentation:validate.ymlnow carries a comment on itsname:line saying the string is an interface. That is weaker than a check, and it is called out as chosen rather than missed.mainsuppressing nightlies is itself quiet — no error, just no artifact. Accepted, becausemainbeing red is already a loud standing signal on its own.Everything else fails closed and loudly: an unidentifiable commit, a checkout that does not match the validated sha, and a gate/publish sha disagreement all
::error::and exit non-zero.Spec
nightly-cli-buildswas archived in423363awhile this branch was in flight, so there is no open change to amend and re-opening one would putmainback into the state that archive just cleared. The requirement therefore goes straight into the live spec,openspec/specs/cli-nightly-builds/spec.md:3f114d6case above.openspec/specs/vale-binary-packages/spec.mdis deliberately untouched.Post-merge verification belongs on #131, which already carries the nightly's unobserved acceptance criteria; the items specific to this change are added there as a comment.
Verification (all run in the worktree, after the rebase onto
a8b4037)pnpm lintpnpm typecheckpnpm buildpnpm testnode --test .github/scripts/*.test.cjsworkflow-outputscheckermainadded ina8b4037)pnpm openspec validate --all --strictnode .github/scripts/openspec-visibility.cjsprettier --checkon changed filesBoth workflow files were additionally parsed with
yamlto confirm the trigger and the foldedifresolve as intended.Not verified, and cannot be from a PR: that the trigger actually fires.
workflow_runloads the workflow from the default branch, so no run of this file happens until it is onmain. The first qualifying push after merge is the proof — check that a run exists, that its gate job checked out the pushed commit rather than a later one, and that a failingValidateyields a run with every job skipped rather than no run at all.Review round
claude[bot]reviewed and approved with one low-severity inline finding, fixed in the second commit.The finding:
git rev-parse --short=<n>sets 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. The publish job re-derived--short=7and string-compared it against the gate job's output, so two jobs abbreviating the same commit could disagree and fail the run with "the gate decided for X but this job holds Y" while nothing was wrong. It failed closed, so it was a spurious-failure risk rather than a publish risk.The fix: test the gate's short sha as a prefix of the full validated sha. Every abbreviation of a commit, at any width, is a prefix of its full sha, so the guard cannot spuriously fail — and it is the invariant actually wanted ("the short sha about to be stamped into the version names this commit") rather than a proxy for it. An empty gate output is classified separately so it cannot pass as a zero-length prefix. Exercised in
bashacross matching-7, git-lengthened-8, full-sha, wrong-commit, empty, and glob-metacharacter inputs.The reviewer's alternative — pass the full 40-char
head_shathrough instead — is not available here: the version format is<n.m.k>-<timestamp>x<short-sha>, so the gate's short sha has to exist and has to be the stamped one.A second instance of the same wrong belief was six lines above where the short sha is computed: the gate job's own comment asserted
--short=7was "A FIXED abbreviation length". Corrected to say it pins the floor (which is what gate 2's suffix match needs) and that nothing downstream may assume the width.Overlap with #133
#133 (
feat/nightly-region-on-changeset-pr) adds a thirdbreadcrumbjob torelease-cli-nightly.ymland anoutputs.versiononpublish. This PR touches only theon:block, thegatejob'sif:and first two steps, thepublishjob's first two steps, and the header comment — so whichever merges second should have a small rebase. Note for that rebase: any job #133 adds needs the same explicitref: github.event.workflow_run.head_shaon its checkout.Fixes #127