Skip to content

Publish a nightly @taskless/cli-nightly build on PR update #111

Description

@thecodedrift

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions