Publish a nightly build of the CLI from main, as a separate package, so unreleased work can be exercised before a release is cut.
- published as
@taskless/cli-nightly
- version
n.m.k-yyyymmddhhmmssx<sha>, where n.m.k is the bump main's pending changesets propose
- OIDC trusted publishing, no manual approval
- a separate package, so nightlies do not inflate
@taskless/cli's version history
Also in scope: splitting the release workflows so each carries one design, and moving vale to automatic publishing.
Built from main, not from PRs
An earlier draft of this built on PR update. Building from main instead — the same shape vale-binaries.yml already uses — is simpler and avoids two real problems.
It removes the security problem entirely. release.yml is deliberately split so contributor-authored changeset text never reaches a credentialed job. A PR-triggered publish would have inverted that, and would additionally have published unreviewed code to npm under the @taskless scope, requiring a hard fork-PR exclusion and a careful job split to contain it. Building from main means the only code that can be published is code that already passed review.
Tying builds to changeset edits does not work. The natural PR-side trigger is "when the changeset changes", but the changeset is typically written early and the implementation lands after it. Measured on the live #71→#106 stack: add-vale-rule-engine had 7 commits after its last changeset edit, agent-command-and-vale-authoring had 11. A nightly stamped at changeset-edit time would omit all of them while looking current — worse than no artifact.
Nothing is actually given up. This tests what is unreleased rather than what is unmerged, but installing an unmerged PR was never a practical option: the CLI is a workspace package with a build step, so there is no npx incantation against a branch or archive that a person would realistically type. The reachable options were "nightly from main" or "no nightly".
What decides whether a build happens
The pending changesets on main answer both questions.
changeset status --output=<relative-path> writes the proposed release:
[{ "name": "@taskless/cli", "type": "minor", "oldVersion": "0.10.2", "newVersion": "0.11.0" }]
(Verified on this repo. The path must be repo-relative — an absolute /tmp/... path silently fails to write. Read the JSON file, not stdout: the command also prints unrelated workspace-version warnings.)
Two gates, in order
1. Is .changeset/ empty? No changeset files means nothing is pending, so there is nothing to build. This is a directory listing — it needs no tooling, runs before anything is installed, and is the cheapest possible early exit.
It also makes the Version Packages PR merge self-handling: that merge consumes every changeset and bumps package.json, so the directory is empty on the next push, no nightly is built, and release-cli.yml publishes the real release instead. No special case required.
2. Is this SHA already built? If a published version ends in x<sha>, this commit has a nightly. Nothing to do.
Only past both gates does the build run, taking newVersion from changeset status as the n.m.k.
Implementation note: because gate 1 is a directory check, the workflow never depends on how changeset status behaves with nothing pending. Read newVersion from the JSON file rather than parsing stdout, which also carries unrelated workspace-version warnings.
Chore merges. A chore adds no changeset, so it never starts a nightly. It will, however, produce a new nightly if changesets are already pending, because the SHA moved. If that is unwanted, gate additionally on packages/cli/** having changed since the last nightly.
Version scheme: n.m.k-yyyymmddhhmmssx<sha>
0.11.0-20260818123456x05b3c88
Reads as: the future 0.11.0, built at that time, from that commit. Both halves earn their place:
- The timestamp sorts. It is fixed-width and leading, so ASCII-lexical comparison of the prerelease identifier orders builds chronologically. A bare SHA does not sort at all.
- The SHA identifies and dedupes. It answers "should this run?" — if a published version ends in
x<sha>, the commit is already built. Re-runs are no-ops.
The x is load-bearing, not decoration. A dot-separated prerelease identifier consisting only of digits is compared numerically, and semver forbids a leading zero in a numeric identifier. Keeping this as a single identifier containing x makes it alphanumeric, so that rule never applies — a short SHA beginning with 0 cannot produce an intermittently invalid version.
Dedupe is a list-and-filter, not a point lookup. Because the timestamp precedes the SHA, the exact version is not known in advance. Query npm view @taskless/cli-nightly versions --json and test for a version ending in x<sha>.
--tag latest is mandatory, not cosmetic. vale-binaries.yml already records this: every version here is a semver prerelease, and npm will not move latest onto a prerelease unless told to. Without an explicit tag, npm i @taskless/cli-nightly installs nothing useful.
Note this differs deliberately from @taskless/vale-*, which uses n.m.k-yyyymmddhhmmss with no SHA. Vale publishes upstream binaries and has no commit of ours to key on.
Approval: a shared npm-autopublishironment
npm-production has required_reviewers (confirmed via gh api repos/taskless/cli/environments), so nightly cannot use it. A new environment, npm-autopublish, is created for flows that publish without a human click, and vale moves into it too.
The reasoning for vale: the review gate is already in the right place, and the environment approval is a second copy of it. vale-binaries.yml only publishes a version a human reviewed in the manifest-update PR, digests and all; and the CLI must opt in explicitly by bumping the pin in packages/cli/package.json, so an auto-published vale package reaches no user until someone edits that file. Re-approving each vale version adds friction at a point where nothing is actually decided.
The boundary becomes: approval gates what users get by default; code review gates what gets published.
| Flow |
Environment |
Approval |
CLI release (@taskless/cli) |
npm-production |
required reviewer |
| Vale platform packages |
npm-autopublish |
none — gated by the manifest PR |
| CLI nightly |
npm-autopublish |
none — gated by branch policy on main |
Each package needs its own npm trusted-publisher binding; @taskless/cli-nightly needs registering before the first run.
Aside: release.yml's comment claims "No required reviewers (fully automatic once the Version Packages PR merges), by design." That contradicts the live configuration and should be corrected as part of this work.
Package identity
Publishing the same source under a different name is a rewrite of packages/cli/package.json at pack time, as vale-prepare already does for the vale packages.
bin stays taskless. A nightly is a drop-in for the real thing, so every documented invocation works unchanged. Installing both globally collides on the binary; that is not a supported configuration.
optionalDependencies are untouched. Nightly keeps pointing at the published, pinned vale and ast-grep platform packages. It does not fork them.
--provenance stays on. The attestation matters more for a nightly, not less.
- Old-version cleanup is out of scope, handled later.
Refactor: one workflow per release design
release.yml currently holds two jobs with opposite trust properties in one file — the credential-free version job that reads untrusted changesets, and the credentialed publish job — and its header comment is already stale about the environment. Split so each file carries one design and one self-contained trust story:
| File |
Trigger |
Does |
Credential |
release-cli-changeset.yml |
push to main |
runs pnpm bump, opens/updates the Version Packages PR |
none |
release-cli.yml |
push to main |
publishes @taskless/cli when its version is not yet on npm |
npm-production, approval |
release-vale.yml |
schedule + push to main |
vale detect → manifest PR → stamp → publish |
npm-autopublish |
release-cli-nightly.yml |
push to main |
publishes @taskless/cli-nightly when changesets are pending and the SHA is unbuilt |
npm-autopublish |
Concurrency. Today a single concurrency: release-${{ github.ref }} group serializes version and publish. After the split, release-cli-changeset.yml keeps that group — it is the one that cares, since two pushes racing on the Version Packages PR is the failure worth preventing. release-cli.yml runs unserialized by deliberate choice; its credential-free "is this version on npm?" gate makes a duplicate publish a no-op, and the residual TOCTOU (two runs both observing "not published") is already handled the way vale-binaries.yml handles it — treat a publish failure as possibly-already-published rather than as an error.
The check job travels with the publish. The credential-free "is main's version already on npm?" gate is what keeps an OIDC-capable job from existing on ordinary pushes. It belongs in release-cli.yml, alongside the job it gates.
Acceptance
- A push to
main with pending changesets publishes @taskless/cli-nightly@<proposedBump>-<timestamp>x<sha> with --tag latest and provenance.
- A push to
main with an empty .changeset/ publishes no nightly, and exits before installing anything.
- A re-run on an already-built SHA publishes nothing.
- The merge of the Version Packages PR publishes the real release and no nightly.
- Vale publishes without a human click;
@taskless/cli still requires one.
release-cli-changeset.yml retains the existing concurrency group.
- No job holding an OIDC identity receives changeset text by interpolation.
Publish a nightly build of the CLI from
main, as a separate package, so unreleased work can be exercised before a release is cut.@taskless/cli-nightlyn.m.k-yyyymmddhhmmssx<sha>, wheren.m.kis the bumpmain's pending changesets propose@taskless/cli's version historyAlso in scope: splitting the release workflows so each carries one design, and moving vale to automatic publishing.
Built from
main, not from PRsAn earlier draft of this built on PR update. Building from
maininstead — the same shapevale-binaries.ymlalready uses — is simpler and avoids two real problems.It removes the security problem entirely.
release.ymlis deliberately split so contributor-authored changeset text never reaches a credentialed job. A PR-triggered publish would have inverted that, and would additionally have published unreviewed code to npm under the@tasklessscope, requiring a hard fork-PR exclusion and a careful job split to contain it. Building frommainmeans the only code that can be published is code that already passed review.Tying builds to changeset edits does not work. The natural PR-side trigger is "when the changeset changes", but the changeset is typically written early and the implementation lands after it. Measured on the live #71→#106 stack:
add-vale-rule-enginehad 7 commits after its last changeset edit,agent-command-and-vale-authoringhad 11. A nightly stamped at changeset-edit time would omit all of them while looking current — worse than no artifact.Nothing is actually given up. This tests what is unreleased rather than what is unmerged, but installing an unmerged PR was never a practical option: the CLI is a workspace package with a build step, so there is no
npxincantation against a branch or archive that a person would realistically type. The reachable options were "nightly frommain" or "no nightly".What decides whether a build happens
The pending changesets on
mainanswer both questions.changeset status --output=<relative-path>writes the proposed release:[{ "name": "@taskless/cli", "type": "minor", "oldVersion": "0.10.2", "newVersion": "0.11.0" }](Verified on this repo. The path must be repo-relative — an absolute
/tmp/...path silently fails to write. Read the JSON file, not stdout: the command also prints unrelated workspace-version warnings.)Two gates, in order
1. Is
.changeset/empty? No changeset files means nothing is pending, so there is nothing to build. This is a directory listing — it needs no tooling, runs before anything is installed, and is the cheapest possible early exit.It also makes the Version Packages PR merge self-handling: that merge consumes every changeset and bumps
package.json, so the directory is empty on the next push, no nightly is built, andrelease-cli.ymlpublishes the real release instead. No special case required.2. Is this SHA already built? If a published version ends in
x<sha>, this commit has a nightly. Nothing to do.Only past both gates does the build run, taking
newVersionfromchangeset statusas then.m.k.Implementation note: because gate 1 is a directory check, the workflow never depends on how
changeset statusbehaves with nothing pending. ReadnewVersionfrom the JSON file rather than parsing stdout, which also carries unrelated workspace-version warnings.Chore merges. A chore adds no changeset, so it never starts a nightly. It will, however, produce a new nightly if changesets are already pending, because the SHA moved. If that is unwanted, gate additionally on
packages/cli/**having changed since the last nightly.Version scheme:
n.m.k-yyyymmddhhmmssx<sha>Reads as: the future
0.11.0, built at that time, from that commit. Both halves earn their place:x<sha>, the commit is already built. Re-runs are no-ops.The
xis load-bearing, not decoration. A dot-separated prerelease identifier consisting only of digits is compared numerically, and semver forbids a leading zero in a numeric identifier. Keeping this as a single identifier containingxmakes it alphanumeric, so that rule never applies — a short SHA beginning with0cannot produce an intermittently invalid version.Dedupe is a list-and-filter, not a point lookup. Because the timestamp precedes the SHA, the exact version is not known in advance. Query
npm view @taskless/cli-nightly versions --jsonand test for a version ending inx<sha>.--tag latestis mandatory, not cosmetic.vale-binaries.ymlalready records this: every version here is a semver prerelease, and npm will not movelatestonto a prerelease unless told to. Without an explicit tag,npm i @taskless/cli-nightlyinstalls nothing useful.Note this differs deliberately from
@taskless/vale-*, which usesn.m.k-yyyymmddhhmmsswith no SHA. Vale publishes upstream binaries and has no commit of ours to key on.Approval: a shared
npm-autopublishironmentnpm-productionhasrequired_reviewers(confirmed viagh api repos/taskless/cli/environments), so nightly cannot use it. A new environment,npm-autopublish, is created for flows that publish without a human click, and vale moves into it too.The reasoning for vale: the review gate is already in the right place, and the environment approval is a second copy of it.
vale-binaries.ymlonly publishes a version a human reviewed in the manifest-update PR, digests and all; and the CLI must opt in explicitly by bumping the pin inpackages/cli/package.json, so an auto-published vale package reaches no user until someone edits that file. Re-approving each vale version adds friction at a point where nothing is actually decided.The boundary becomes: approval gates what users get by default; code review gates what gets published.
@taskless/cli)npm-productionnpm-autopublishnpm-autopublishmainEach package needs its own npm trusted-publisher binding;
@taskless/cli-nightlyneeds registering before the first run.Aside:
release.yml's comment claims "No required reviewers (fully automatic once the Version Packages PR merges), by design." That contradicts the live configuration and should be corrected as part of this work.Package identity
Publishing the same source under a different name is a rewrite of
packages/cli/package.jsonat pack time, asvale-preparealready does for the vale packages.binstaystaskless. A nightly is a drop-in for the real thing, so every documented invocation works unchanged. Installing both globally collides on the binary; that is not a supported configuration.optionalDependenciesare untouched. Nightly keeps pointing at the published, pinned vale and ast-grep platform packages. It does not fork them.--provenancestays on. The attestation matters more for a nightly, not less.Refactor: one workflow per release design
release.ymlcurrently holds two jobs with opposite trust properties in one file — the credential-freeversionjob that reads untrusted changesets, and the credentialedpublishjob — and its header comment is already stale about the environment. Split so each file carries one design and one self-contained trust story:release-cli-changeset.ymlmainpnpm bump, opens/updates the Version Packages PRrelease-cli.ymlmain@taskless/cliwhen its version is not yet on npmnpm-production, approvalrelease-vale.ymlmainnpm-autopublishrelease-cli-nightly.ymlmain@taskless/cli-nightlywhen changesets are pending and the SHA is unbuiltnpm-autopublishConcurrency. Today a single
concurrency: release-${{ github.ref }}group serializesversionandpublish. After the split,release-cli-changeset.ymlkeeps that group — it is the one that cares, since two pushes racing on the Version Packages PR is the failure worth preventing.release-cli.ymlruns unserialized by deliberate choice; its credential-free "is this version on npm?" gate makes a duplicate publish a no-op, and the residual TOCTOU (two runs both observing "not published") is already handled the wayvale-binaries.ymlhandles it — treat a publish failure as possibly-already-published rather than as an error.The
checkjob travels with the publish. The credential-free "is main's version already on npm?" gate is what keeps an OIDC-capable job from existing on ordinary pushes. It belongs inrelease-cli.yml, alongside the job it gates.Acceptance
mainwith pending changesets publishes@taskless/cli-nightly@<proposedBump>-<timestamp>x<sha>with--tag latestand provenance.mainwith an empty.changeset/publishes no nightly, and exits before installing anything.@taskless/clistill requires one.release-cli-changeset.ymlretains the existing concurrency group.