feat(ci): publish unreleased main as @taskless/cli-nightly - #122
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds the “nightly CLI” publishing path: on pushes to main with pending changesets, it packs the CLI as @taskless/cli-nightly with a stamped prerelease version and publishes it via OIDC trusted publishing in the npm-autopublish environment.
Changes:
- Adds a reusable pack script (
.github/scripts/nightly-pack.cjs) plus unit tests to stamp a nightly semver and pack without leavingpackages/cli/package.jsonmodified. - Introduces
release-cli-nightly.ymlwith a credential-free gate job (pending changesets + dedupe by SHA) and a credentialed publish job. - Documents nightlies and ignores the generated tarball/status artifacts.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents how to install/use @taskless/cli-nightly and collision caveats. |
| openspec/changes/nightly-cli-builds/tasks.md | Marks PR2 nightly tasks as completed and records measured corrections. |
| openspec/changes/nightly-cli-builds/design.md | Refines the “x separator” rationale with measured failure modes. |
| .gitignore | Ignores nightly build artifacts (.nightly-dist/, nightly-status.json). |
| .github/workflows/release-cli-nightly.yml | Adds the nightly gate+publish workflow using OIDC trusted publishing. |
| .github/scripts/nightly-pack.test.cjs | Adds unit tests for version selection/stamping and dedupe logic. |
| .github/scripts/nightly-pack.cjs | Implements version selection/stamping, manifest rewrite/restore, and npm pack. |
| .changeset/nightly-cli-builds.md | Extends the changeset to include the nightly publishing feature. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@claude /review |
|
Claude finished @thecodedrift's task in 2m 37s —— View job Claude finished @thecodedrift's taskReviewed the diff against
Overall assessmentSolid piece of work — the design doc's five corrections (leading-zero vs. precision-loss, Findings1. (Correctness, confirmed) The pre-publish 2. (Correctness, confirmed) The write/pack/restore isn't fully inside the 3. (Test coverage) The gate-2 stdin/JSON-parsing wrapper is untested 4. (Minor/cosmetic) Misplaced JSDoc 5. (Simplification, non-blocking) Some duplication with the Vale release scripts
Nothing else rose to the level of a concrete bug — argument parsing, regex escaping, the |
A nightly shipped @taskless/cli's skill and recipe content verbatim, so an agent following it ran `npx @taskless/cli` — the released CLI. Someone installs a nightly to exercise unreleased behavior and their agent silently uses the released binary. Nothing errors; the instructions are simply for a different package. Adds a `nightly` build target beside `dev` and `self`, rewriting the baked invocation to `npx @taskless/cli-nightly@<version>` through the same `__TASKLESS_CLI__` define the other targets already use. Target resolution moves to `scripts/build-target.ts` so it can be unit-tested over an explicit environment rather than this process. The version is computed ONCE and shared. `nightly-pack.cjs --print-version` stamps it; the build reads TASKLESS_NIGHTLY_VERSION and the pack takes `--version`, which is the only version input pack mode accepts — it rejects `--status`/`--sha` so it cannot derive a second one. Two `new Date()` calls a build apart would ship instructions naming a version that was never published, so this is enforced rather than observed. A missing or malformed version fails the build; falling back to `npx @taskless/cli` would silently reintroduce the bug being fixed. `build:nightly` emits to `dist`, unlike `dev`/`self`, because `files: ["dist"]` is what npm packs — so it overwrites a local prod build, which the comment says. Also closes a fail-open in gate 2, found in review on #122. Any non-zero exit from the version query landed in the "no nightly found" branch, so unparseable output meant publish. Since the version carries a timestamp, a re-run after a parse failure mints a different version for the same commit and publishes it — two nightlies for one SHA, no error anywhere, by the gate whose only job is suppression. Now three-way: present means skip, parsed-and-absent means build, unparseable means fail. The 404 carve-out stays, because on bootstrap day the package genuinely does not exist. The bootstrap block in the header gains the build step it was missing; following it literally packed a tarball with no dist/.
cc8e4ca to
2225291
Compare
A nightly shipped @taskless/cli's skill and recipe content verbatim, so an agent following it ran `npx @taskless/cli` — the released CLI. Someone installs a nightly to exercise unreleased behavior and their agent silently uses the released binary. Nothing errors; the instructions are simply for a different package. Adds a `nightly` build target beside `dev` and `self`, rewriting the baked invocation to `npx @taskless/cli-nightly@<version>` through the same `__TASKLESS_CLI__` define the other targets already use. Target resolution moves to `scripts/build-target.ts` so it can be unit-tested over an explicit environment rather than this process. The version is computed ONCE and shared. `nightly-pack.cjs --print-version` stamps it; the build reads TASKLESS_NIGHTLY_VERSION and the pack takes `--version`, which is the only version input pack mode accepts — it rejects `--status`/`--sha` so it cannot derive a second one. Two `new Date()` calls a build apart would ship instructions naming a version that was never published, so this is enforced rather than observed. A missing or malformed version fails the build; falling back to `npx @taskless/cli` would silently reintroduce the bug being fixed. `build:nightly` emits to `dist`, unlike `dev`/`self`, because `files: ["dist"]` is what npm packs — so it overwrites a local prod build, which the comment says. Also closes a fail-open in gate 2, found in review on #122. Any non-zero exit from the version query landed in the "no nightly found" branch, so unparseable output meant publish. Since the version carries a timestamp, a re-run after a parse failure mints a different version for the same commit and publishes it — two nightlies for one SHA, no error anywhere, by the gate whose only job is suppression. Now three-way: present means skip, parsed-and-absent means build, unparseable means fail. The 404 carve-out stays, because on bootstrap day the package genuinely does not exist. The bootstrap block in the header gains the build step it was missing; following it literally packed a tarball with no dist/.
0595e1c to
29d9b94
Compare
A nightly shipped @taskless/cli's skill and recipe content verbatim, so an agent following it ran `npx @taskless/cli` — the released CLI. Someone installs a nightly to exercise unreleased behavior and their agent silently uses the released binary. Nothing errors; the instructions are simply for a different package. Adds a `nightly` build target beside `dev` and `self`, rewriting the baked invocation to `npx @taskless/cli-nightly@<version>` through the same `__TASKLESS_CLI__` define the other targets already use. Target resolution moves to `scripts/build-target.ts` so it can be unit-tested over an explicit environment rather than this process. The version is computed ONCE and shared. `nightly-pack.cjs --print-version` stamps it; the build reads TASKLESS_NIGHTLY_VERSION and the pack takes `--version`, which is the only version input pack mode accepts — it rejects `--status`/`--sha` so it cannot derive a second one. Two `new Date()` calls a build apart would ship instructions naming a version that was never published, so this is enforced rather than observed. A missing or malformed version fails the build; falling back to `npx @taskless/cli` would silently reintroduce the bug being fixed. `build:nightly` emits to `dist`, unlike `dev`/`self`, because `files: ["dist"]` is what npm packs — so it overwrites a local prod build, which the comment says. Also closes a fail-open in gate 2, found in review on #122. Any non-zero exit from the version query landed in the "no nightly found" branch, so unparseable output meant publish. Since the version carries a timestamp, a re-run after a parse failure mints a different version for the same commit and publishes it — two nightlies for one SHA, no error anywhere, by the gate whose only job is suppression. Now three-way: present means skip, parsed-and-absent means build, unparseable means fail. The 404 carve-out stays, because on bootstrap day the package genuinely does not exist. The bootstrap block in the header gains the build step it was missing; following it literally packed a tarball with no dist/.
2225291 to
e4f95f2
Compare
29d9b94 to
217c114
Compare
e4f95f2 to
137b943
Compare
Add release-cli-nightly.yml and the pack script behind it. Every push to main with changesets pending publishes the CLI under a second name at <next-version>-<yyyymmddhhmmss>x<short-sha>, so merged-but-unreleased work is installable. Two credential-free gates, in their own job, decide whether the publish job exists at all: pending changesets (before any install) and whether the commit already has a nightly. The rename happens at pack time, so the committed manifest and @taskless/cli's version history are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
npm always includes README.md in a tarball regardless of `files`, so the nightly was publishing @taskless/cli's documentation under a different package name — install instructions for a package the reader did not install, with nothing saying so. Someone arriving from a search would follow them and never learn this is a prerelease of something else. The pack swap now covers the README the same way it covers the manifest: written, packed, restored in `finally`, so the committed file is never left rewritten. Deliberately minimal. It names the package, links to the real one for documentation and support, explains what the version string encodes, and carries the one warning a reader can act on destructively — that the release and the nightly collide on the `taskless` executable and installing both globally is unsupported. It does not restate anything from the CLI's README, because a copy would have to be kept in sync with a file it was copied from.
A nightly shipped @taskless/cli's skill and recipe content verbatim, so an agent following it ran `npx @taskless/cli` — the released CLI. Someone installs a nightly to exercise unreleased behavior and their agent silently uses the released binary. Nothing errors; the instructions are simply for a different package. Adds a `nightly` build target beside `dev` and `self`, rewriting the baked invocation to `npx @taskless/cli-nightly@<version>` through the same `__TASKLESS_CLI__` define the other targets already use. Target resolution moves to `scripts/build-target.ts` so it can be unit-tested over an explicit environment rather than this process. The version is computed ONCE and shared. `nightly-pack.cjs --print-version` stamps it; the build reads TASKLESS_NIGHTLY_VERSION and the pack takes `--version`, which is the only version input pack mode accepts — it rejects `--status`/`--sha` so it cannot derive a second one. Two `new Date()` calls a build apart would ship instructions naming a version that was never published, so this is enforced rather than observed. A missing or malformed version fails the build; falling back to `npx @taskless/cli` would silently reintroduce the bug being fixed. `build:nightly` emits to `dist`, unlike `dev`/`self`, because `files: ["dist"]` is what npm packs — so it overwrites a local prod build, which the comment says. Also closes a fail-open in gate 2, found in review on #122. Any non-zero exit from the version query landed in the "no nightly found" branch, so unparseable output meant publish. Since the version carries a timestamp, a re-run after a parse failure mints a different version for the same commit and publishes it — two nightlies for one SHA, no error anywhere, by the gate whose only job is suppression. Now three-way: present means skip, parsed-and-absent means build, unparseable means fail. The 404 carve-out stays, because on bootstrap day the package genuinely does not exist. The bootstrap block in the header gains the build step it was missing; following it literally packed a tarball with no dist/.
217c114 to
21c5b81
Compare
#131 `main` had been failing the `Check for unarchived OpenSpec changes on main` step in `validate.yml` on every push since 162afa0, because this change directory was still sitting under `openspec/changes/`. Issue #127 will make a red `main` suppress nightly publishing outright, so a red `main` had stopped being cosmetic. Every requirement in the change's spec delta shipped in #116, #119 and #122 — the workflow split, the pack script, the build target, both publish gates and the version stamp. What remained was one environment move and a set of verification steps, neither of which any delivered requirement depends on. Both move to #131 rather than holding `main` red. Removes the `vale-binary-packages` delta from the change. Applying it would have written into the published spec that the Vale workflow "SHALL publish without a human approval step, using the reviewer-free publishing environment", which is false: `release-vale.yml` still names `npm-production`, which has a required reviewer. A requirement describing deferred work is worse than no requirement, because a spec is what later readers trust. Also corrects task 3.2, briefly marked done on the strength of `npm view` showing a published version. That is evidence of the manual bootstrap in 3.1, not of a trusted-publisher binding; the two are independent. Refs #131 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Stack (root → tip):
PR 2 of 3 for
nightly-cli-builds. Adds the nightly publish itself: a pack script, its unit tests, andrelease-cli-nightly.yml.Shape
.github/scripts/nightly-pack.cjs— pure exported helpers (selectProposedVersion,buildNightlyVersion,applyNightlyIdentity,hasNightlyForSha,isValidVersion) plus amain()that rewritespackages/cli/package.jsonto@taskless/cli-nightlyand the stamped version, packs, and restores the manifest infinally. It asserts the official semver grammar before stamping, so an invalid version is never produced rather than being caught downstream.release-cli-nightly.yml— push tomain; a credential-freegatejob, thenpublishundernpm-autopublish. The gate/publish split is the same propertyrelease-cli.ymlrelies on: the OIDC identity only exists for a run that will actually publish..gitignoreentries.Against this repo's real state it produces
@taskless/cli-nightly@0.11.0-20260820051459x773226a. Tarball verified: name and version rewritten,binstill{"taskless":"./dist/index.js"}, 13optionalDependenciesintact, andgit status packages/cli/package.jsonclean afterwards.Five things the proposal had wrong, found by building it
The first two were already corrected on the branches below; the last three are new and each would have broken a real run.
Gate 1 was specified as a directory listing.
.changeset/is never empty —README.mdandconfig.jsonare permanent — so it would have reported "pending" on every push forever, including the Version Packages merge it is supposed to self-handle. Now therequire-changeset.ymlrule: any.changeset/*.mdother thanREADME.md.The
xseparator's justification was wrong about the mechanism. The leading-zero rule bites only when the separator is a.; a bare concatenation stays valid semver, because the timestamp's leading digit is never0. What concatenation actually produces is a 21-digit numeric identifier past exact double precision —202608181234560123456reads back as202608181234560140000, andn === n+1— so it destroys the chronological ordering the timestamp exists for, silently. The test now covers both alternatives against the semver grammar rather than only the dotted one.npm view --jsonon a 404 prints an error object to stdout and exits non-zero. So the natural$(npm view … || echo '[]')yields object-then-[]and crashesJSON.parse. The fallback has to replace the capture, not append to it. This is exactly the bootstrap-day state, when the package does not exist yet.npm view <pkg> versions --jsonreturns a bare string when exactly one version exists — the state immediately after the manual bootstrap publish. Unhandled, the gate would have skipped every build until a second version landed.git rev-parse --shortauto-scales its length with repo size. Unpinned, it would eventually emit 8 characters and stop matching 7-character suffixes, silently disabling dedupe. Pinned to--short=7, and the same value is passed to the pack script so the gate and the stamp cannot disagree.What is verified, and what is not
Executed: the pack end-to-end and the resulting tarball's manifest; the version computation against real
changeset statusoutput; both gates as shell logic, including thenpm view404 path and the "onlyREADME.mdandconfig.json" case; YAML parse of all 10 workflows.Reasoned only: anything requiring the environment or OIDC — the
npm-autopublishbranch policy, the trusted-publisher binding, the real publish, and--tag latestactually moving on npm. This workflow cannot run end to end until the two maintainer actions happen, and the first real run is the only proof of them.Before this merges
Two maintainer actions, in this order:
npm-autopublishneeds its deployment branch policy restricted tomain. Done — verified ascustom_branch_policies: truewith exactly one policy,branch: main.@taskless/cli-nightlymust be published once manually from the packed tarball, not the package directory (publishing a directory burns the name on a placeholder0.0.0— the traprelease-vale.ymldocuments), then a trusted-publisher binding registered againstrelease-cli-nightly.ymland thenpm-autopublishenvironment. The workflow header carries the exact commands.Registering that binding is also the first direct evidence of whether npm bindings are environment-scoped — which is what determines whether PR 3's Vale move invalidates its six existing bindings. That is why the nightly goes first.
Verification
pnpm lintclean ·pnpm typecheck1/1 ·pnpm test602 passing ·openspec validate --all --strict24/24 ·node --test .github/scripts/*.test.cjs122 passingThe change is deliberately not archived — PR 3 is the tip.
Refs #111
Refs OSS-34
Built on top of #116
Proposal only — no implementation. Artifacts for a nightly build of the CLI published from
mainas a separate package, plus a split of the release workflows so each carries one design.What it proposes
@taskless/cli-nightly, published frommain, versionn.m.k-yyyymmddhhmmssx<sha>.changeset/empty (a directory listing, before any install), and is this SHA already builtnpm-autopublishenvironment for flows that publish without a human clickrelease.ymlandvale-binaries.yml, each with one trust storyThe design is argued in #111;
design.mdrecords the decisions with their reasoning rather than restating the conclusions.Built from
main, not from PRsThe original framing was "on PR update". That is what the title of #111 said until this proposal was written, and it does not survive contact with
release.yml's security model: a PR-triggered publish would route contributor-authored changeset text into a credentialed job, inverting the split that file exists to maintain, and would publish unreviewed code under the@tasklessscope.Tying builds to changeset edits does not work either. Measured on the #71→#106 stack,
add-vale-rule-enginehad 7 commits after its last changeset edit andagent-command-and-vale-authoringhad 11 — a nightly stamped at changeset-edit time would omit all of them while looking current.Delivery: stacked, merging forward, three PRs
release.yml's header, which claims "No required reviewers … by design" whilenpm-productiondemonstrably has a required reviewer.npm-autopublishand registering trusted publishing.npm-autopublish— tip, archives the change.The nightly deliberately precedes the Vale move. npm trusted-publisher bindings can be scoped to a GitHub environment. If the six
@taskless/vale-*bindings are, moving Vale first invalidates all of them and the next publish fails the OIDC handshake with no stored token to fall back on — trading a working release path for a convenience. The nightly exercises the same environment, handshake, and binding model on a package where failure is free: nothing resolves@taskless/cli-nightly, so a failed first publish blocks nobody. Stated generally indesign.md, because it outlives this change: prove a new credential path on something disposable before migrating something that works onto it.Two prerequisites no implementer can satisfy
Called out in the proposal body rather than buried in tasks, because they gate merges:
npm-autopublishenvironment must be created in repo settings — no required reviewers, branch policy onmain.@taskless/cli-nightlymust be published once manually and a trusted-publisher binding registered. Trusted publishing is per-package, and the package does not exist yet.Both sit as maintainer task groups before the nightly unit. A PR waits rather than merging with a dangling environment reference.
Gaps this surfaced in #111
Writing the design down found four things the discussion had left implicit, all captured as tasks:
changeset statusreturns an array; taking[0]breaks the day a second changesets-managed package appears. Filter by name.npm view … versionslists versions regardless of tag, so a version that published but failed to get tagged reads as already-built and is skipped forever. The Vale workflow handles that case explicitly; nightly's was left implicit.mainrequiresValidate; any branch-protection or breadcrumb reference to an old name silently stops reporting.openspec validate --all --strict: 24 passed, 0 failed.No changeset — a proposal ships nothing.
Refs #111
Refs OSS-34
Built on top of #119
PR 1 of 3 for the
nightly-cli-buildschange. Behavior-preserving apart from one correction, and it exists so PRs 2 and 3 add one flow each to a file that already has one job.The split
release-cli-changeset.ymlversion— opens the Version Packages PRconcurrency: release-${{ github.ref }}release-cli.ymlcheck+publish, in one filenpm-production, approvalrelease-vale.ymlvale-binaries.ymlrelease.ymlis deleted. Each new file carries a header written for its own trust story rather than a copy of the original —release.yml's header is the reason its security properties survived this long, and splitting it without splitting the reasoning would have thrown that away.checkandpublishstay together deliberately: the credential-free gate is what keeps an OIDC-capable job from existing on an ordinary push, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit.release-cli.ymlhas no concurrency group, also deliberately, and the header says why — the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled the way the Vale workflow already handles it, treating a publish failure as possibly-already-published.Verified equivalence
A parsed-config diff of old versus new shows
version,check, andpublishbyte-identical, with the same triggers and the samepermissions: {}. All 9 workflow files parse underjs-yaml.Two intentional behavior differences:
check/publishno longer share therelease-*concurrency group (D6).Release CLI Version PR / …andRelease CLI / …instead ofRelease / ….Vale Binaries→Release Vale,vale-binaries→release-vale), so its check context changes too and anything keyed on the old name (badges, notification rules,gh run list --workflow) stops matching.None of these is a required check. Branch protection on
mainrequires onlyValidate, fromci.yml— confirmed via the API, so no repo-settings change is needed.pr-check-openspec.yml,require-changeset.yml,stack-breadcrumb.yml, and.github/scripts/*.cjskey on nothing workflow-name-specific.The renamed Vale workflow is renamed through:
name:becomesRelease Valeandconcurrency:becomesrelease-vale. A file calledrelease-vale.ymlthat still announced itself asVale Binarieswould reproduce the naming mismatch this split exists to remove, so task 1.5 calls for both. Its whole header comment is preserved verbatim apart from therelease.yml→release-cli.ymlreference.The one behavior-adjacent correction
release.yml's header claimed "No required reviewers (fully automatic once the Version Packages PR merges), by design." That is false:npm-productionhasrequired_reviewers(reviewerthecodedrift) plus a protected-branches policy. A release has always waited on an approval the file denied existed. The new header states what is configured, and why.Three things measurement corrected in the proposal
Task group 0 is verification, and it contradicted the design in three places. All three are fixed in
design.mdandtasks.mdon this branch, since PR 2 codes against them:changeset status --outputreturns an object, not an array. It is{changesets: [...], releases: [{name, type, oldVersion, changesets, newVersion}]}. The bump isdata.releases.find(r => r.name === "@taskless/cli").newVersion. The proposal described a bare top-level array — so the review finding about not indexing with[0]was right about the hazard and wrong about the shape.--output=/tmp/x.jsonresolves against cwd to<cwd>/tmp/x.jsonand fails loudly withENOENTand exit 1. It does not silently write nothing. It would write to the wrong place silently only if that directory happened to exist.npm-autopublishalready exists, created 2026-08-18 — but with no protection rules and no deployment branch policy, meaning any branch may deploy to it. Group 2 is half-done and the missing half is the branch restriction. Noted inline on task 2.1 so nobody assumes it is complete. Inert today since no workflow references it; load-bearing the moment PR 3 does.@taskless/cli-nightlyis confirmed unclaimed on npm.Verification
pnpm lintclean ·pnpm typecheck1/1 ·pnpm test602 passing ·openspec validate --all --strict24/24 ·.github/scriptstests 113 passing across 4 files (turbo does not cover these, so they were run directly)Refs #111
Refs OSS-34