feat(ci): publish unreleased main as @taskless/cli-nightly - #122
feat(ci): publish unreleased main as @taskless/cli-nightly#122thecodedrift wants to merge 13 commits into
Conversation
Adds the nightly-cli-builds change: publish @taskless/cli-nightly from main, and split release.yml into one workflow per release design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Swaps the last two units of the nightly stack. The nightly now lands before Vale moves to `npm-autopublish`, rather than after. The reason is the trusted-publisher risk the proposal already names. npm bindings can be scoped to a GitHub environment, and 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. That trades a working release path for a convenience. The nightly exercises the same environment, the same handshake, and the same trusted-publishing model on a package where failure is free: `@taskless/cli-nightly` is new, nothing resolves it, and a failed first publish blocks nobody. Whatever the binding model turns out to require is learned there. By the time Vale moves, the destination is proven. Stated generally, since it outlives this change: prove a new credential path on something disposable before migrating something that works onto it. Both human-gated prerequisites move up accordingly — the environment and the `@taskless/cli-nightly` binding are now needed before the nightly unit rather than before the Vale one. The archive moves to the Vale PR, which is now the tip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Two of these were claimed as fixed in the PR description and were not actually in the artifacts — an implementer following the tasks would have reproduced both bugs the description said were handled. Select the proposed version by package name, never by index. `changeset status` returns an array of every package it releases, and `[0]` is the CLI only while the CLI is the sole managed package. The six vale packages already sit in the changesets ignore list, so a second managed package is not hypothetical. Taking `[0]` would stamp a nightly with another package's version — a wrong version that publishes cleanly and looks plausible. Now stated in the task, the design, and the spec, with a scenario. Record why there is no tag bookkeeping. `latest` is correct for every version this package produces, so there is no channel to maintain and no state to drift. The reviewer's case — a publish that lands while the tag move does not — is accepted rather than mitigated: the window closes at the next nightly, and gate 2 skipping a re-run of identical bytes is right on its own terms. Written down, because "we thought about this" and "we did not think about this" are indistinguishable from silence. Scope the PR 1 changeset to PR 1. The stack merges forward, where CLAUDE.md requires each unit to extend the changeset with what it landed rather than the base promising everything up front. Also: the group cross-reference in 1.8 was left pointing at the pre-reorder numbering, and task 0.3 asked to confirm a "currently published" name is "unclaimed". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`release.yml` held two jobs with opposite trust properties behind a single
header. Split it, with no change to what any job does:
- `release-cli-changeset.yml` — the `version` job, keeping
`concurrency: release-${{ github.ref }}`. It consumes untrusted changeset
text and holds no credential and no OIDC identity.
- `release-cli.yml` — the `check` and `publish` jobs, deliberately in one
file: the credential-free gate is what keeps an OIDC-capable job from being
instantiated on an ordinary merge, and it must not be separated from the job
it protects. Deliberately no concurrency group; the gate makes a duplicate
run a no-op and the residual TOCTOU is treated as possibly-already-published.
- `vale-binaries.yml` → `release-vale.yml`, rename only; its workflow name and
concurrency group are unchanged, so its check name still reports.
Also corrects a header claim that had been false since the environment was
created: `npm-production` has a required reviewer and a protected-branches
deployment policy, so a release has always waited on a human approval the
comment said was not there.
Verified while implementing, and corrected in the change's design/tasks:
`changeset status --output=` writes an object whose per-package entries live
under `releases` (not a bare array), and an absolute `--output` path is
resolved against the working directory rather than ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
The rename left `name: Vale Binaries` and `concurrency: vale-binaries` inside `release-vale.yml`, on the reasoning that task 1.5 asked for no behavior change. That preserved exactly the mismatch this split exists to remove: a file named for one thing announcing itself as another, in a change whose whole subject is making each workflow say what it is. Safe because branch protection requires only `Validate` (confirmed in task 0.1), so no required check is keyed to the old display name. The concurrency group is renamed with it; the only effect is that a run already in flight under the old group would not serialize against a new one, which is a single transition, not a standing property. Task 1.5 now says to do this, resolving its tension with 1.7 rather than leaving the next reader to rediscover it.
The release note claimed nothing about how the CLI is built, versioned, or published changes. Two things do: check/publish no longer share the release-* concurrency group, and the release runs as two workflow runs, so its check contexts are renamed. The publish mechanics are what is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ds PR 2 Three findings from the re-review, the first two of which would have shipped as bugs. `.changeset/` is never empty. It permanently holds `README.md` and `config.json` — `changesets init` writes both and nothing removes them — so a literal emptiness test answers "pending" on every push, including the Version Packages merge that D4 claims it self-handles. The gate is "any `.changeset/*.md` other than `README.md`", which is exactly how `require-changeset.yml` already counts them; reuse that rule rather than inventing a second one. Group 3 could not run where it was placed. It publishes the tarball from the pack script in task 4.1, and 4.1 is part of PR 2 — the PR group 3 is meant to precede. Introduced when the units were reordered to put the nightly ahead of the Vale move: before that swap the binding step came after the nightly PR, so the script existed. It now says what it always meant — open PR 2, run its pack script from that branch, publish and bind, then merge. Vale's equivalent reads as a clean prerequisite only because `vale-prepare.cjs` was already on `main`. Also the third stale cross-reference from the same renumbering: the Vale-bindings check is task 5.3, not 4.2. Found by the first review round that could read whole files rather than diff hunks, after the review workflow was fixed to check out the PR ref.
Removing the concurrency group was justified by a mitigation that lives in a different file. `release-vale.yml` runs `npm view` immediately before each `npm publish` and skips when the version is already there; `release-cli.yml` had a bare `npm publish` with no pre-check and no error handling. The header, design D6, and task 1.3 all claimed otherwise. The `check` job does query npm, but it is a separate job — which is exactly where the window is. A gate in another job cannot close the gap between its own answer and the publish. So the guard moves to where it can do the work, immediately before the publish. That is better than serializing rather than merely equivalent: it is idempotent instead of ordered, so it also absorbs a re-run against a version an earlier attempt already shipped, which a concurrency group does nothing about. Without it the losing run of a race fails with npm's "cannot publish over the previously published version" — a duplicate that reads as a broken release. The three places that asserted the old story now describe this one, and say that the omitted concurrency group is safe *because of* the guard rather than on its own.
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
…o openspec/nightly-1-workflow-split
…y-2-publish Reconciles two independent fixes of the same defect. Gate 1 was written as a bare directory listing, which is never true because `.changeset/` permanently holds `README.md` and `config.json`. That was found twice: once by review on the proposal branch, and again by measurement while building the gate here, because this branch was cut before the first fix landed. Both descriptions were right and each said something the other did not. The resolution keeps this branch's wording — it is checked off and folds in what building the thing actually taught — and grafts on the two clauses only the other had: that `changesets init` writes both files and nothing removes them, which is why they are permanent, and that a bare emptiness test would fire on the Version Packages merge, the one case the gate exists to handle. Task 4.3's rationale also survives from this side, since it corrects the proposal's stated mechanism: the leading-zero rule bites only if the separator is a `.`, while a bare concatenation stays valid semver and fails differently — a 21-digit numeric identifier past exact double precision, which breaks ordering rather than validity.
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.
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.
|
@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/.
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