From 56d20562cf52faa207430ba2178b0ad76a0929f4 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 18 Aug 2026 23:25:07 -0700 Subject: [PATCH 1/4] docs(openspec): propose nightly CLI builds 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 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .../changes/nightly-cli-builds/.openspec.yaml | 2 + openspec/changes/nightly-cli-builds/design.md | 185 ++++++++++++++++++ .../changes/nightly-cli-builds/proposal.md | 38 ++++ .../specs/cli-nightly-builds/spec.md | 109 +++++++++++ .../specs/infrastructure/spec.md | 74 +++++++ .../specs/vale-binary-packages/spec.md | 35 ++++ openspec/changes/nightly-cli-builds/tasks.md | 62 ++++++ 7 files changed, 505 insertions(+) create mode 100644 openspec/changes/nightly-cli-builds/.openspec.yaml create mode 100644 openspec/changes/nightly-cli-builds/design.md create mode 100644 openspec/changes/nightly-cli-builds/proposal.md create mode 100644 openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md create mode 100644 openspec/changes/nightly-cli-builds/specs/infrastructure/spec.md create mode 100644 openspec/changes/nightly-cli-builds/specs/vale-binary-packages/spec.md create mode 100644 openspec/changes/nightly-cli-builds/tasks.md diff --git a/openspec/changes/nightly-cli-builds/.openspec.yaml b/openspec/changes/nightly-cli-builds/.openspec.yaml new file mode 100644 index 00000000..41c30bab --- /dev/null +++ b/openspec/changes/nightly-cli-builds/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md new file mode 100644 index 00000000..3796d40d --- /dev/null +++ b/openspec/changes/nightly-cli-builds/design.md @@ -0,0 +1,185 @@ +## Context + +**Current state.** `.github/workflows/release.yml` holds three jobs. `version` runs `pnpm bump` and opens the "Version Packages" PR; it reads contributor-authored changeset text and deliberately holds no npm credential and no OIDC identity. `check` asks, credential-free, whether `packages/cli/package.json`'s version is already on npm. `publish` runs only when it is not — which in practice means only on the merge of the Version Packages PR — and holds `id-token: write` against the `npm-production` environment. A single `concurrency: release-${{ github.ref }}` group serializes all of it. + +That split is the file's whole security argument, stated in its header: the changeset text is fully consumed by the credential-free job and never reaches the credentialed one, and the credential-free `check` gate is what keeps an OIDC-capable job from existing on ordinary pushes. Everything below preserves that argument; nothing here relaxes it. + +`.github/workflows/vale-binaries.yml` is the closest working precedent for what the nightly needs: a differently-named set of packages, a stamped prerelease version that npm has never seen, its own gate job, and its own publish job. Its header already records two lessons this change reuses — `--tag latest` is required for a prerelease, and a publish failure in a retry-safe loop is treated as possibly-already-published rather than as an error. + +**Two facts about the live configuration.** `npm-production` has `required_reviewers` (`gh api repos/taskless/cli/environments`). `release.yml`'s header comment says the opposite — "No required reviewers (fully automatic once the Version Packages PR merges), by design" — and has been wrong for as long as the reviewer has been configured. An unattended nightly therefore cannot use `npm-production`: it would sit waiting for a click that defeats the point. + +**What is unreachable today.** Between releases, merged work is not installable. `@taskless/cli` is a workspace package with a build step, so no `npx` against a branch or an archive produces a working CLI. The reachable options were "nightly from `main`" or "no nightly." + +## Goals / Non-Goals + +**Goals:** make every unreleased `main` commit installable; keep the credential-free/credentialed split intact and easier to read after the change than before; give each release flow one design and one file; let flows whose review gate is elsewhere publish without a second, redundant approval. + +**Non-Goals:** publishing from pull requests; publishing unreviewed code; changing what `@taskless/cli` releases require; pruning old nightly versions; changing the Vale detect/verify pipeline beyond its environment; making a nightly and a release co-installable. + +## Decisions + +### D1 — Build from `main`, never from a pull request + +An earlier draft published on PR update. Building from `main` — the same shape `vale-binaries.yml` already uses — is both simpler and the only version that is safe. + +**It removes the security problem instead of containing it.** A PR-triggered publish inverts `release.yml`'s split: contributor-authored changeset text would flow into a job holding an OIDC identity, and _unreviewed code_ would be published to npm under the `@taskless` scope. Making that safe needs a hard fork-PR exclusion plus a careful job split — a containment structure, where building from `main` means the failure cannot arise. The only code that can be published is code that already merged. + +**Tying builds to changeset edits does not work anyway.** The natural PR-side trigger is "the changeset changed," but a 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, because it looks like evidence. + +**Nothing is given up.** This tests what is unreleased rather than what is unmerged. Installing an unmerged PR was never available. + +- **Alternative — publish on PR update with a fork exclusion:** rejected. It publishes unreviewed code under our scope, and the changeset-edit trigger is measurably stale. +- **Alternative — a nightly cron rather than per-push:** rejected. A push is what changes the answer; a cron either re-publishes an unchanged SHA (the SHA gate suppresses it) or delays a build by up to a day for no gain. + +### D2 — A separate package, `@taskless/cli-nightly`, with `bin` unchanged + +Publishing prereleases into `@taskless/cli` would fill its version list with builds nobody should install deliberately, and every `npm view`, changelog, and release page would carry them. A separate name keeps the release history of the product clean. + +The rename is a **pack-time rewrite of `packages/cli/package.json`**, exactly as `vale-prepare.cjs` already stamps the Vale packages. The committed `package.json` is unchanged, so nothing about the ordinary release path is touched, and a nightly is byte-for-byte the same build as the release it anticipates apart from `name` and `version`. + +- **`bin` stays `taskless`.** A nightly is a drop-in for the real thing, so every documented invocation, every skill, and every recipe works unchanged against it. Installing both globally collides on the binary; that is not a supported configuration and does not need to be. +- **`optionalDependencies` are untouched.** The nightly points at the same published, pinned Vale and ast-grep platform packages. It does not fork them, and it does not get a nightly of them. +- **`--provenance` stays on.** The attestation matters more for an unattended publish, not less. + +### D3 — `n.m.k-yyyymmddhhmmssx` + +``` +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 an ASCII-lexical comparison of the prerelease identifier orders builds chronologically. A bare SHA does not sort at all, and "which nightly is newer" is the first question anyone asks. +- **The SHA identifies and dedupes.** It answers "should this run?" — if a published version ends in `x`, the commit already has a nightly and a re-run is a no-op. + +**The `x` is load-bearing, not decoration.** Semver compares a dot-separated prerelease identifier consisting only of digits _numerically_, and forbids a leading zero in a numeric identifier. `20260818123456` followed by a short SHA of `05b3c88` would, without the `x`, be a single all-digit identifier for roughly one commit in sixteen — intermittently invalid, which is the worst failure cadence available. The `x` makes the identifier alphanumeric, so the numeric rule never applies. + +**Dedupe is a list-and-filter, not a point lookup.** Because the timestamp precedes the SHA, the exact version string is not known before the run computes it. `npm view @taskless/cli-nightly versions --json` and test for a version ending in `x`. + +**`--tag latest` on publish is mandatory.** Every version here is a semver prerelease, and npm will not move `latest` onto a prerelease unless told to. Without it, `npm i @taskless/cli-nightly` resolves nothing useful — the package would have versions and no default. `vale-binaries.yml` already records this lesson; this is the second package to need it. + +This deliberately differs from `@taskless/vale-*`, which stamps `n.m.k-yyyymmddhhmmss` with no SHA. Vale republishes upstream binaries and has no commit of ours to key on. + +- **Alternative — `0.11.0-nightly.N` with an incrementing counter:** rejected. It needs state outside the build to know `N`, and it cannot answer "was this SHA built?" without a lookup table. +- **Alternative — SHA only:** rejected. Unsortable. +- **Alternative — timestamp only (the Vale scheme):** rejected. It cannot dedupe a re-run, and a workflow re-run would mint a second version of identical bytes. + +### D4 — Two gates, in order: empty `.changeset/`, then unbuilt SHA + +**Gate 1 — is `.changeset/` empty?** No changeset files means nothing is pending: `main` is at its released version and there is no future `n.m.k` to name. This is a directory listing. It needs no tooling, runs before anything is installed, and is the cheapest possible early exit on the overwhelmingly common push. + +It also makes the Version Packages PR merge **self-handling, with no special case**. That merge consumes every changeset and bumps `package.json`, so on that push the directory is empty, no nightly is built, and `release-cli.yml` publishes the real release instead. The two flows do not need to know about each other. + +Because gate 1 is a directory check, the workflow never depends on how `changeset status` behaves with nothing pending — a behavior we would otherwise have to pin down and keep pinned down. + +**Gate 2 — has this SHA already been published?** A version ending in `x` means the commit has a nightly. Re-runs, and any future trigger that fires twice for one commit, publish nothing. + +Only past both gates does the build run, taking `newVersion` from `changeset status` as `n.m.k`. + +**Reading the bump.** `changeset status --output=` writes: + +```json +[ + { + "name": "@taskless/cli", + "type": "minor", + "oldVersion": "0.10.2", + "newVersion": "0.11.0" + } +] +``` + +Two verified traps, both silent: **the path must be repo-relative** — an absolute `/tmp/...` path fails to write the file without failing the command — and **the JSON file is authoritative, not stdout**, which also carries unrelated workspace-version warnings. Read the file. + +**Chore merges.** A chore adds no changeset, so it never _starts_ a nightly. It will produce a new nightly if changesets are already pending, because the SHA moved and gate 2 is per-SHA. That is accepted: the nightly claims to be "this commit of `main`," and a chore does produce a new commit of `main`. If it becomes noisy, the fix is an additional gate on `packages/cli/**` having changed since the last nightly — deliberately not built now, because the cost is a version string nobody will notice. + +### D5 — Four workflows, one release design each + +`release.yml` currently holds two jobs with opposite trust properties, and its header 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` | `pnpm bump`, open/update the Version Packages PR | none | +| `release-cli.yml` | push to `main` | publish `@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` | publish `@taskless/cli-nightly` when changesets are pending and the SHA is unbuilt | `npm-autopublish` | + +**The `check` job travels with the publish it gates.** The credential-free "is main's version already on npm?" question is what keeps an OIDC-capable job from being instantiated on ordinary pushes. Separating it from `release-cli.yml` would leave a gate in one file and the thing it protects in another — the arrangement most likely to be broken by a later edit that reads only one of them. + +The split is otherwise a move, not a rewrite. Each file keeps the header comment explaining its own trust story, and the credential-free/credentialed boundary is unchanged in substance — only more legible, because a reader of `release-cli.yml` no longer has to work out which half of the file applies. + +### D6 — Concurrency narrows to the flow that needs it + +Today one group serializes `version` and `publish`. After the split: + +- **`release-cli-changeset.yml` keeps `concurrency: release-${{ github.ref }}`.** It is the flow that cares. Two pushes racing on the Version Packages PR branch is a real failure and the group prevents it. +- **`release-cli.yml` runs unserialized, deliberately.** Its credential-free gate makes a duplicate publish a no-op: the second run sees the version on npm and does nothing. The residual TOCTOU — two runs both observing "not published" — is handled the way `vale-binaries.yml` already handles it, by treating a publish failure as possibly-already-published rather than as an error. Serializing would buy nothing the gate does not already provide, at the cost of queueing releases behind unrelated pushes. +- **`release-cli-nightly.yml`** has the same property for the same reason: gate 2 is per-SHA, and two runs for one SHA cannot both publish. + +Stating this explicitly matters because the obvious reading of the split — "the group was on the file, so put it on all four files" — would serialize flows that have no reason to wait on each other. + +### D7 — `npm-autopublish`, and Vale moves into it + +`npm-production` has a required reviewer, so an unattended flow cannot use it. Rather than removing that reviewer or granting the nightly an exception, add a second environment, **`npm-autopublish`**, for flows that publish without a human click — and move Vale into it too. + +**Why Vale belongs there.** Its review gate is already in the right place, and the environment approval is a second copy of it. `vale-binaries.yml` only ever publishes a version a human reviewed in the manifest-update PR, digests and all; and `@taskless/cli` pins exact versions, so an auto-published Vale package reaches no user until someone deliberately bumps that pin. Re-approving each Vale version adds friction at a point where nothing is actually being decided — which is the kind of approval that gets clicked without being read, weakening the approvals that do matter. + +The boundary that results is worth stating as a rule: + +> **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` | + +`npm-autopublish` still earns its place with no reviewer: it is the audit and scoping boundary, it carries a branch policy restricting it to `main`, and it is where the npm trusted-publisher bindings live. + +- **Alternative — drop the reviewer from `npm-production` and share one environment:** rejected. That reviewer is the gate on what users get by default, which is precisely the thing the nightly does not need and the release does. +- **Alternative — a third environment, one per flow:** rejected. Two flows with identical properties do not need two names for the same policy. + +### D8 — Two prerequisites are human-gated, and the delivery shape is built around them + +Neither can be done by an implementer, tested around, or discovered late without stalling the work: + +1. **The `npm-autopublish` environment** must be created in repository settings, with a branch policy limiting it to `main` and no required reviewers. +2. **A trusted-publisher binding for `@taskless/cli-nightly`** must be registered on npm. Trusted publishing is configured per package and the package does not exist yet, so — exactly as `vale-binaries.yml` documents for its six names — the **first publish is a deliberate manual step by a maintainer**, who then registers the binding. There is no fallback token path, by design. + +Until both exist, the nightly workflow cannot be verified end to end: a run either fails on an unknown environment or fails the OIDC handshake. This is a sequencing constraint on the change, not a footnote in a task list. + +### D9 — Delivery shape: **stacked, merging forward**, in three PRs + +Each unit is independently safe in production, and the ordering is what lets the human-gated prerequisites be satisfied between merges rather than blocking everything. + +| # | PR | Contains | Safe alone? | +| --- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| 1 | Split the release workflows | `release.yml` → `release-cli-changeset.yml` + `release-cli.yml`, `vale-binaries.yml` → `release-vale.yml`, corrected header comments, concurrency per D6. **No environment change.** | Yes — a behavior-preserving reorganization of what already runs. | +| 2 | Move Vale to `npm-autopublish` | One `environment:` line in `release-vale.yml`, plus the spec delta. | Yes — but **only after** the `npm-autopublish` environment exists. | +| 3 | Add the nightly | `release-cli-nightly.yml`, the pack script, docs. | Yes — but **only after** the trusted-publisher binding exists. | + +The prerequisites slot between the units: create the environment before PR 2 merges; register the binding (with the one-time manual first publish) before PR 3 merges. Each PR is well under ~300 lines. Forward is correct rather than merely tidy — PR 1 leaves every existing flow working, and PRs 2 and 3 each add exactly one independent capability. The last one to merge archives the change; the changeset lives on PR 1, the bottom of the stack, and grows as each unit lands. + +If either prerequisite is not in place when its PR is ready, that PR waits. It does not merge with the environment referenced but absent, because the failure mode is a red release workflow on `main`, which is the thing everyone learns to ignore. + +## Risks / Trade-offs + +- **A nightly and a release both install `taskless`** → accepted and documented (D2). Installing both globally collides; a nightly is a drop-in, not a companion. +- **Nightly versions accumulate without bound** → out of scope by decision. Every version is a prerelease and none is ever resolved by a range, so the cost is registry noise, not a wrong install. +- **`--tag latest` on a prerelease looks alarming** → it is required, not a shortcut (D3). These are the only builds of this package; the prerelease component exists to make the version sortable and traceable, not to signal instability. +- **The four-way split multiplies the places a trust story can go stale** → mitigated by giving each file one design to describe, which is the point of the split. The stale-comment bug being fixed here is itself evidence that two designs in one header is the harder thing to keep true. +- **A chore merge mints a nightly while changesets are pending** → accepted (D4), with a `packages/cli/**` gate named as the remedy if it becomes noise. +- **The first nightly publish is manual** → unavoidable (D8); npm has nothing to bind a trusted publisher to until the name exists. `vale-binaries.yml` documents the same one-time step for its packages, including publishing the packed tarball rather than the directory so the name is not burned on a placeholder version. +- **`changeset status` changes its output shape** → the nightly would fail to read `newVersion` and the build would fail loudly rather than publishing a wrong version. Acceptable; a mis-stamped nightly is worse than a missing one. + +## Migration Plan + +PR 1 is a move: after it merges, `main` behaves exactly as before. PR 2 changes one environment reference. PR 3 adds a workflow that publishes a package nobody depends on. There is no data migration and no consumer-visible change to `@taskless/cli`. + +Rollback is per-unit and cheap: delete `release-cli-nightly.yml` (the published nightlies are inert), or point `release-vale.yml` back at `npm-production`. Reverting PR 1 restores `release.yml` wholesale. + +## Open Questions + +- Should the nightly also be published under a `nightly` dist-tag in addition to `latest`, so `npm i @taskless/cli-nightly@nightly` reads clearly even though it resolves the same version? +- Does the nightly need a README rewrite at pack time, or is the release README acceptable under a name it does not mention? +- When old-version cleanup is picked up separately, does it belong in `release-cli-nightly.yml` or in a scheduled workflow of its own? diff --git a/openspec/changes/nightly-cli-builds/proposal.md b/openspec/changes/nightly-cli-builds/proposal.md new file mode 100644 index 00000000..3e19606f --- /dev/null +++ b/openspec/changes/nightly-cli-builds/proposal.md @@ -0,0 +1,38 @@ +## Why + +Work that has merged to `main` but has not been released is unreachable. `@taskless/cli` is a workspace package with a build step, so there is no `npx` incantation against a branch, a tag, or a tarball that a person would realistically type — the only installable artifact is the last release. Between releases, "does the fix actually work when installed?" cannot be answered by anyone, including us. + +At the same time `release.yml` has grown two release designs in one file. The credential-free `version` job that consumes untrusted changeset text and the OIDC-credentialed `publish` job share a trigger, a concurrency group, and a header comment that is now wrong about the environment it describes. Adding a third design — an unattended nightly publish — to that file would make the trust story harder to read at exactly the moment it needs to be clearer. + +## What Changes + +- **Publish `@taskless/cli-nightly` from `main`.** Same source, same `bin: taskless`, published under a different name so nightlies never appear in `@taskless/cli`'s version history. Built from `main` only: the code has already passed review, and no contributor-authored text reaches the credentialed job. +- **Version as `n.m.k-yyyymmddhhmmssx`** (e.g. `0.11.0-20260818123456x05b3c88`), where `n.m.k` is the bump `main`'s pending changesets propose. The timestamp sorts; the SHA identifies and dedupes. +- **Gate on two questions, in order:** is `.changeset/` empty (nothing pending, nothing to build), and has this SHA already been published? +- **Split `release.yml` into four workflows,** one release design each: `release-cli-changeset.yml`, `release-cli.yml`, `release-vale.yml`, `release-cli-nightly.yml`. +- **Add an `npm-autopublish` environment** for flows that publish without a human click, and **move the Vale platform packages into it.** `@taskless/cli` keeps `npm-production` and its required reviewer. +- **Correct `release.yml`'s stale header claim** that `npm-production` has no required reviewers. It does — confirmed via `gh api repos/taskless/cli/environments` — and the comment has been asserting the opposite. + +**Two prerequisites are human-gated and cannot be worked around by an implementer:** the `npm-autopublish` environment must be created in repository settings, and a trusted-publisher binding for `@taskless/cli-nightly` must be registered on npm. Trusted publishing is configured per package and the package does not exist yet, so the first publish is a deliberate manual step. Until both are done the nightly workflow cannot be verified end to end. See design D7 and the delivery shape below. + +## Capabilities + +### New Capabilities + +- `cli-nightly-builds`: publishing every unreleased `main` commit as `@taskless/cli-nightly`, including the version scheme, the two gates, and the guarantee that a nightly is a drop-in for the release it anticipates. + +### Modified Capabilities + +- `infrastructure`: the release flow becomes one workflow per release design, each with a self-contained trust story; publishing environments are distinguished by whether a human click gates them; the concurrency guarantee is narrowed to the flow that needs it. +- `vale-binary-packages`: the Vale release moves from `npm-production` to `npm-autopublish`, making explicit that its review gate is the manifest-update pull request rather than an environment approval. + +## Impact + +- **Removed**: `.github/workflows/release.yml`. +- **Added**: `.github/workflows/release-cli-changeset.yml` (Version Packages PR, no credential), `.github/workflows/release-cli.yml` (publishes `@taskless/cli`, `npm-production`, approval), `.github/workflows/release-cli-nightly.yml` (nightly, `npm-autopublish`), and a nightly pack script under `.github/scripts/`. +- **Renamed**: `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml`, with its `publish` job moved to `npm-autopublish`. +- **Outside the repository**: the `npm-autopublish` GitHub environment, and an npm trusted-publisher binding for `@taskless/cli-nightly`. +- **Unchanged**: `packages/cli/package.json` as committed. The rename to `@taskless/cli-nightly` is a pack-time rewrite, the way `vale-prepare.cjs` already stamps the Vale packages. `optionalDependencies` are untouched — a nightly points at the same published, pinned Vale and ast-grep platform packages the release does. +- **Out of scope**: pruning old nightly versions from the registry. + +**Tracking:** taskless/cli#111 diff --git a/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md b/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md new file mode 100644 index 00000000..7cfe4abc --- /dev/null +++ b/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Unreleased work on the default branch is installable as a nightly package + +The CLI SHALL be published as `@taskless/cli-nightly` from commits on the default branch that carry unreleased work, so that merged-but-unreleased behavior can be installed and exercised. Nightlies SHALL NOT be published from pull requests: only code that has already merged, and therefore already passed review, is eligible. + +The nightly SHALL be built from the same source and the same build as the release it anticipates, differing only in the published package name and version. Its executable SHALL remain `taskless`, so every documented invocation works unchanged against a nightly. + +#### Scenario: A push to the default branch with unreleased work publishes a nightly + +- **WHEN** a commit is pushed to the default branch and release-pending metadata exists for the CLI +- **THEN** `@taskless/cli-nightly` SHALL be published for that commit + +#### Scenario: A pull request publishes nothing + +- **WHEN** a pull request is opened, updated, or synchronized +- **THEN** no nightly SHALL be published, and no job holding a publishing credential SHALL be instantiated for it + +#### Scenario: A nightly is a drop-in for the release it anticipates + +- **WHEN** a nightly is installed +- **THEN** it SHALL provide the `taskless` executable +- **AND** it SHALL resolve the same pinned platform dependencies as the corresponding release + +### Requirement: A nightly version names the release it anticipates, the time, and the commit + +A nightly version SHALL take the form `-x`, where `n.m.k` is the version the default branch's pending release metadata proposes, `yyyymmddhhmmss` is the build time, and `sha` is the short commit hash. + +The timestamp SHALL precede the commit hash, so that lexical comparison of the prerelease identifier orders builds chronologically. The prerelease identifier SHALL contain a non-digit separator between the timestamp and the hash, so that the identifier is never all-digits — an all-digit prerelease identifier is compared numerically and may not begin with a zero, which a hash beginning with `0` would otherwise violate. + +#### Scenario: A nightly version is stamped + +- **WHEN** pending release metadata proposes `0.11.0`, the build time is `2026-08-18T12:34:56Z`, and the short commit hash is `05b3c88` +- **THEN** the published version SHALL be `0.11.0-20260818123456x05b3c88` + +#### Scenario: A hash beginning with zero produces a valid version + +- **WHEN** the short commit hash consists only of digits and begins with `0` +- **THEN** the published version SHALL still be a valid semantic version + +#### Scenario: Nightlies sort chronologically + +- **WHEN** two nightlies of the same `n.m.k` are compared +- **THEN** the one built later SHALL sort after the one built earlier + +### Requirement: A nightly is installable by default + +Every nightly version is a semantic-version prerelease, which a registry does not place on the default install tag on its own. Publishing SHALL therefore explicitly assign the default tag, so that installing the package without a version installs the most recent nightly. + +#### Scenario: Installing without a version resolves the latest nightly + +- **WHEN** `@taskless/cli-nightly` is installed with no version or tag specified +- **THEN** the most recently published nightly SHALL be installed + +### Requirement: A nightly build is bounded by pending release metadata and by the commit + +Two gates SHALL decide whether a nightly is built, evaluated in this order. + +First, whether any release metadata is pending. When none is pending, nothing is unreleased, there is no proposed version to name, and no nightly SHALL be built. This gate SHALL be evaluated before any dependency installation, so the common case exits at the cheapest possible point. + +Second, whether this commit already has a nightly. Published versions SHALL be queried and tested for one whose prerelease identifier ends with the short commit hash; if one exists, no nightly SHALL be built. + +The proposed `n.m.k` SHALL be read from the release tool's structured output file rather than from its console output, which also carries unrelated diagnostics. + +#### Scenario: Nothing pending publishes nothing + +- **WHEN** a commit is pushed to the default branch and no release metadata is pending +- **THEN** no nightly SHALL be published +- **AND** the run SHALL exit before installing dependencies + +#### Scenario: The release merge publishes the release and no nightly + +- **WHEN** the version pull request merges, consuming all pending release metadata and bumping the package version +- **THEN** the real release SHALL be published +- **AND** no nightly SHALL be published, with no rule special-casing that commit + +#### Scenario: A re-run of an already-built commit publishes nothing + +- **WHEN** the nightly flow runs again for a commit that already has a published nightly +- **THEN** no nightly SHALL be published + +#### Scenario: A chore commit alongside pending work still yields a nightly + +- **WHEN** a commit that changes no CLI source is pushed while release metadata is pending +- **THEN** a nightly SHALL be published for that commit, because the commit — and therefore the artifact it describes — is new + +### Requirement: Nightlies do not enter the released package's history + +Nightlies SHALL be published under a package name distinct from the released CLI, so that the released package's version history contains only releases. The rename SHALL be applied when the publishable artifact is produced, leaving the package manifest in version control unchanged. + +#### Scenario: The released package carries no nightly versions + +- **WHEN** the published versions of the released CLI package are listed +- **THEN** no nightly version SHALL appear among them + +#### Scenario: The committed manifest is unchanged + +- **WHEN** the repository is inspected after a nightly is published +- **THEN** the CLI package manifest in version control SHALL still declare the released package name and version + +### Requirement: A nightly publish carries the same attestation as a release + +Nightly publishing SHALL authenticate with a short-lived credential minted for the run rather than a stored registry token, and SHALL attach build provenance. + +#### Scenario: A nightly is published with provenance + +- **WHEN** a nightly is published +- **THEN** the published version SHALL carry a build-provenance attestation +- **AND** no long-lived registry token SHALL be present in the environment diff --git a/openspec/changes/nightly-cli-builds/specs/infrastructure/spec.md b/openspec/changes/nightly-cli-builds/specs/infrastructure/spec.md new file mode 100644 index 00000000..692c019f --- /dev/null +++ b/openspec/changes/nightly-cli-builds/specs/infrastructure/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Each release flow lives in its own workflow file + +Every release flow SHALL be defined in a workflow file of its own, carrying exactly one release design and a self-contained account of its trust boundary. A workflow file SHALL NOT combine a flow that consumes contributor-authored text with a flow that holds a publishing credential. + +There SHALL be one workflow for opening the version pull request, one for publishing the CLI, one for publishing the Vale platform packages, and one for publishing the CLI nightly. + +#### Scenario: The version flow holds no credential + +- **WHEN** inspecting the workflow that runs the version bump and opens the version pull request +- **THEN** it SHALL request no OIDC identity, reference no publishing environment, and contain no publish step + +#### Scenario: Each publishing flow is separately readable + +- **WHEN** inspecting any workflow that publishes to the registry +- **THEN** its trust boundary — what it may publish, what gates it, and where its credential comes from — SHALL be documented in that file, without reference to another workflow's reasoning + +#### Scenario: Workflow documentation matches the live configuration + +- **WHEN** a workflow's comments describe the approval policy of the environment it uses +- **THEN** that description SHALL match the environment's configured policy + +### Requirement: A credential-free gate decides whether a credentialed publish job exists + +Each publishing flow SHALL decide whether there is anything to publish in a job holding no publishing credential and no OIDC identity, and SHALL instantiate the credentialed job only when that gate says yes. The gate SHALL live in the same workflow file as the job it protects. + +#### Scenario: An ordinary push instantiates no credentialed job + +- **WHEN** a commit is pushed to the default branch and nothing needs publishing +- **THEN** no job holding an OIDC identity or referencing a publishing environment SHALL run + +#### Scenario: The gate and the job it protects are not separated + +- **WHEN** inspecting a publishing workflow +- **THEN** the gate deciding whether to publish SHALL be defined in that same file + +### Requirement: Publishing environments are distinguished by whether a human approves each release + +The repository SHALL define two publishing environments. One SHALL require a human reviewer and SHALL be used by flows where approval decides what users receive by default. The other SHALL require no reviewer and SHALL be used by flows whose review gate is code review or a reviewed pull request, and whose output reaches no user until a separate reviewed change adopts it. + +The reviewer-free environment SHALL still carry a branch policy restricting it to the default branch, and SHALL remain the audit boundary and the binding point for the registry's trusted-publisher configuration. + +#### Scenario: The released CLI requires an approval + +- **WHEN** the released CLI is published +- **THEN** the run SHALL use the environment with a required reviewer + +#### Scenario: Unattended flows use the reviewer-free environment + +- **WHEN** a flow publishes without a human click +- **THEN** it SHALL use the reviewer-free environment +- **AND** that environment SHALL restrict deployments to the default branch + +#### Scenario: A publishing name has a trusted publisher before it is used + +- **WHEN** a workflow publishes a package name for the first time +- **THEN** a trusted-publisher binding for that name SHALL already be registered, since the binding is per package and cannot exist before the name does + +### Requirement: Release flows are serialized only where a race can corrupt shared state + +The workflow that opens and updates the version pull request SHALL run under a concurrency group, because two runs racing on that branch is a real failure. + +Publishing workflows SHALL NOT be serialized merely because they publish. Where a credential-free gate makes a duplicate run a no-op, the flow SHALL run unserialized, and the residual window in which two runs both observe "not yet published" SHALL be handled by treating a publish failure as possibly-already-published rather than as an error. + +#### Scenario: The version flow is serialized + +- **WHEN** two commits are pushed to the default branch in quick succession +- **THEN** the version pull request flow SHALL process them one at a time + +#### Scenario: A duplicate publish run is a no-op + +- **WHEN** two runs of a publishing flow evaluate the same commit or version +- **THEN** at most one artifact SHALL be published, and the other run SHALL neither fail nor publish a duplicate diff --git a/openspec/changes/nightly-cli-builds/specs/vale-binary-packages/spec.md b/openspec/changes/nightly-cli-builds/specs/vale-binary-packages/spec.md new file mode 100644 index 00000000..1a6ad06d --- /dev/null +++ b/openspec/changes/nightly-cli-builds/specs/vale-binary-packages/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Platform packages are released by their own workflow, tracking upstream + +Platform packages SHALL be versioned and published by a workflow dedicated to them, independent of the workflow that releases packages managed by changesets. That workflow SHALL compare the latest upstream Vale release against what the repository has already published, and SHALL publish only when upstream is ahead. + +A published-version check cannot bound these runs — every run stamps a previously unused timestamp — so the upstream comparison SHALL be what prevents redundant publishing. + +That workflow SHALL publish without a human approval step, using the reviewer-free publishing environment. Its review gate is the manifest-update pull request, where a human reviews the upstream version and every checksum before anything may be published; an environment approval would be a second copy of a gate that already exists, at a point where nothing is being decided. The publish remains bounded by the branch policy on that environment and by the requirement below that a published platform package changes no consumer. + +#### Scenario: Upstream unchanged publishes nothing + +- **WHEN** the workflow runs and the latest upstream Vale release is already published as a platform package +- **THEN** no package is versioned or published + +#### Scenario: A new upstream release opens a pull request rather than publishing + +- **WHEN** the workflow runs and upstream Vale is ahead of what the repository has published +- **THEN** it opens a pull request updating the pinned Vale version and the committed checksums, and publishes nothing + +#### Scenario: Merging the update publishes the set + +- **WHEN** that pull request is merged +- **THEN** every supported platform package is stamped with the same version and published together, verified against the checksums that were just reviewed + +#### Scenario: Publishing needs no approval click + +- **WHEN** the publish phase runs after a merged manifest update +- **THEN** it SHALL proceed without waiting for an environment approval +- **AND** it SHALL run in the reviewer-free publishing environment, restricted to the default branch + +#### Scenario: Ordinary pushes do not publish platform packages + +- **WHEN** a commit is pushed to the default branch +- **THEN** the changeset-managed release flow publishes no platform package diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md new file mode 100644 index 00000000..f856b43a --- /dev/null +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -0,0 +1,62 @@ +Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is the bottom PR and carries the changeset; groups 3 and 4 are the two PRs above it. Groups 2 and 5 are human-gated prerequisites and are **not** code — they must be done by a maintainer in GitHub and npm settings, between merges. + +## 0. Prerequisites and facts to confirm before writing anything + +- [ ] 0.1 Re-confirm the live environment configuration with `gh api repos/taskless/cli/environments` — the correction in 1.4 depends on `npm-production` still having `required_reviewers` +- [ ] 0.2 Confirm `changeset status --output=` on this repository writes `[{ name, type, oldVersion, newVersion }]`, and re-confirm both traps: an absolute path silently writes nothing, and stdout carries unrelated workspace-version warnings so the JSON file is the only source to read +- [ ] 0.3 Confirm the currently published `@taskless/cli-nightly` name is unclaimed on npm + +## 1. PR 1 — split the release workflows (no behavior change) + +- [ ] 1.1 Create `.github/workflows/release-cli-changeset.yml` from `release.yml`'s `version` job, keeping `concurrency: release-${{ github.ref }}` and the header text explaining why the job holds no credential and no OIDC identity (D5, D6) +- [ ] 1.2 Create `.github/workflows/release-cli.yml` from `release.yml`'s `check` and `publish` jobs, keeping them **in one file** — the credential-free gate is what keeps an OIDC-capable job from existing on ordinary pushes, and separating it from the job it protects is the arrangement most likely to be broken by a later partial edit (D5) +- [ ] 1.3 Deliberately omit a concurrency group from `release-cli.yml`, and say so in the header: the gate makes a duplicate publish a no-op, and the residual TOCTOU is handled by treating a publish failure as possibly-already-published, the way `vale-binaries.yml` already does (D6) +- [ ] 1.4 Correct the stale claim carried over from `release.yml`'s header — `npm-production` **does** have a required reviewer, so "No required reviewers (fully automatic once the Version Packages PR merges), by design" is false and must be replaced with what is actually configured and why +- [ ] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml` with no behavior change, preserving the whole header comment +- [ ] 1.6 Delete `.github/workflows/release.yml` +- [ ] 1.7 Check for references to the old filenames — branch protection required checks, `pr-check-openspec.yml`, `require-changeset.yml`, `stack-breadcrumb.yml`, README and docs — and update anything that names `release.yml` or `Vale Binaries`. A renamed workflow means a renamed check, and a required check that no longer reports blocks merges silently +- [ ] 1.8 Add the changeset for the whole change on this branch, before cutting the PRs above it; grow it as groups 3 and 4 land +- [ ] 1.9 Verify on merge that the Version Packages PR flow still opens/updates normally and that an ordinary push instantiates no OIDC-capable job + +## 2. Human-gated prerequisite — the `npm-autopublish` environment + +- [ ] 2.1 **Maintainer action, in GitHub repository settings:** create an environment named `npm-autopublish` with **no required reviewers** and a deployment branch policy restricting it to `main`. An implementer cannot do this and cannot test around it — a workflow referencing a missing environment fails the run (D7, D8) +- [ ] 2.2 Confirm via `gh api repos/taskless/cli/environments` that `npm-autopublish` exists, has no `required_reviewers`, and has the branch policy applied + +## 3. PR 2 — move Vale to `npm-autopublish` (depends on group 2) + +- [ ] 3.1 Change the `publish` job's `environment:` in `release-vale.yml` from `npm-production` to `npm-autopublish` +- [ ] 3.2 Update the header comment's "PUBLISHING IDENTITY" paragraph to name the new environment and to state the reason: the manifest-update PR is the review gate, and the CLI's exact pins mean an auto-published package reaches no user until someone bumps the pin (D7) +- [ ] 3.3 Confirm the npm trusted-publisher bindings for all six `@taskless/vale-*` packages still authorize the workflow after the environment change — the binding names the workflow, and an environment change must not invalidate it. If npm's binding is environment-scoped, re-register before merging +- [ ] 3.4 Verify with a `workflow_dispatch` `publish --force` run that the Vale set publishes with no approval click +- [ ] 3.5 Do not merge this PR before 2.2 passes + +## 4. PR 3 — the nightly (depends on group 5 for its first real run) + +- [ ] 4.1 Add a pack script under `.github/scripts/` that rewrites `packages/cli/package.json` at pack time to `name: @taskless/cli-nightly` and the stamped version, leaving `bin`, `optionalDependencies`, and the committed manifest untouched — the same shape `vale-prepare.cjs` uses (D2) +- [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2) +- [ ] 4.3 Keep the `x` separator and cover it with a test: a short SHA of all digits beginning with `0` must still produce a valid semantic version. This is the one detail most likely to be "simplified" away by a later reader who sees it as decoration (D3) +- [ ] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 as a **directory listing of `.changeset/`** that runs before any dependency install, and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4) +- [ ] 4.5 Keep the gates credential-free and in their own job, so the publish job — and therefore the OIDC identity — exists only for a run that will actually publish +- [ ] 4.6 Publish with `--provenance --access public --tag latest`. `--tag latest` is mandatory: every version is a prerelease and npm will not move the default tag onto one unless told to, so without it the package has versions and no default (D3) +- [ ] 4.7 Pass no untrusted text through `${{ }}` into any `run:` body; route computed values through `env:`, following `release-vale.yml`'s rule +- [ ] 4.8 Pin `npm` to the same version the other publish workflows pin, and keep `--ignore-scripts` on the install so no lifecycle code runs while the OIDC identity exists +- [ ] 4.9 Write the header comment for the file: why `main` and not pull requests (unreviewed code under the `@taskless` scope, and the inverted trust split), why the two gates are in that order, and why the Version Packages merge needs no special case +- [ ] 4.10 Document installing a nightly in the README — the package name, that `bin` is `taskless`, and that installing it alongside `@taskless/cli` globally collides and is unsupported +- [ ] 4.11 Archive the change on this PR, as the tip of the stack + +## 5. Human-gated prerequisite — trusted publishing for `@taskless/cli-nightly` + +- [ ] 5.1 **Maintainer action, one time:** publish the first `@taskless/cli-nightly` version manually so the name exists — npm has nothing to bind a trusted publisher to until it does. Publish the packed tarball produced by the script in 4.1, not the package directory, so the name is not burned on a placeholder version (the trap `vale-binaries.yml` documents) +- [ ] 5.2 **Maintainer action:** register the npm trusted-publisher binding for `@taskless/cli-nightly` against `release-cli-nightly.yml` and the `npm-autopublish` environment. There is no fallback token path, by design +- [ ] 5.3 Confirm the binding, then merge PR 3 + +## 6. Verify the acceptance criteria on `main` + +- [ ] 6.1 A push to `main` with pending changesets publishes `@taskless/cli-nightly@-x` with the default tag and provenance +- [ ] 6.2 A push to `main` with an empty `.changeset/` publishes no nightly **and exits before installing anything** — check the run log, not just the outcome +- [ ] 6.3 A workflow re-run on an already-built SHA publishes nothing +- [ ] 6.4 The merge of a Version Packages PR publishes the real release and no nightly, with no special case in either workflow +- [ ] 6.5 Vale publishes with no click; `@taskless/cli` still waits for one +- [ ] 6.6 `release-cli-changeset.yml` still holds the original concurrency group, and no other release workflow has acquired one +- [ ] 6.7 Install a published nightly in a clean directory and confirm `taskless --version` reports the nightly version and the CLI runs From 76c41d065b981058e50b518484000b5eff39dc43 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 00:44:38 -0700 Subject: [PATCH 2/4] docs(openspec): prove the credential path before migrating Vale onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- openspec/changes/nightly-cli-builds/design.md | 42 ++++++++++++++----- .../changes/nightly-cli-builds/proposal.md | 4 +- openspec/changes/nightly-cli-builds/tasks.md | 29 ++++++------- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 3796d40d..19abbfd3 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -146,21 +146,40 @@ Neither can be done by an implementer, tested around, or discovered late without 1. **The `npm-autopublish` environment** must be created in repository settings, with a branch policy limiting it to `main` and no required reviewers. 2. **A trusted-publisher binding for `@taskless/cli-nightly`** must be registered on npm. Trusted publishing is configured per package and the package does not exist yet, so — exactly as `vale-binaries.yml` documents for its six names — the **first publish is a deliberate manual step by a maintainer**, who then registers the binding. There is no fallback token path, by design. -Until both exist, the nightly workflow cannot be verified end to end: a run either fails on an unknown environment or fails the OIDC handshake. This is a sequencing constraint on the change, not a footnote in a task list. +Until both exist, the nightly workflow cannot be verified end to end: a run either fails on an unknown environment or fails the OIDC handshake. This is a sequencing constraint on the change, not a footnote in a task list. Both prerequisites are satisfied before the nightly unit, for the reason given in D9. -### D9 — Delivery shape: **stacked, merging forward**, in three PRs +### D9 — The nightly proves `npm-autopublish` before Vale is migrated onto it -Each unit is independently safe in production, and the ordering is what lets the human-gated prerequisites be satisfied between merges rather than blocking everything. +`npm-autopublish` is a new credential path. Nothing has ever published through it, and two things about it are untested: whether the environment is configured such that a run can actually deploy to it, and whether an npm trusted-publisher binding authorizes a workflow running under it. -| # | PR | Contains | Safe alone? | -| --- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| 1 | Split the release workflows | `release.yml` → `release-cli-changeset.yml` + `release-cli.yml`, `vale-binaries.yml` → `release-vale.yml`, corrected header comments, concurrency per D6. **No environment change.** | Yes — a behavior-preserving reorganization of what already runs. | -| 2 | Move Vale to `npm-autopublish` | One `environment:` line in `release-vale.yml`, plus the spec delta. | Yes — but **only after** the `npm-autopublish` environment exists. | -| 3 | Add the nightly | `release-cli-nightly.yml`, the pack script, docs. | Yes — but **only after** the trusted-publisher binding exists. | +That second one is a real risk, not a hypothetical. An npm trusted-publisher binding names the repository, the workflow file, and — where configured — the environment. The six `@taskless/vale-*` bindings were registered against `npm-production`. If they are environment-scoped, moving `release-vale.yml` to `npm-autopublish` means the first publish after that merge fails the OIDC handshake, and there is no stored `NPM_TOKEN` to fall back on because the absence of one is deliberate. The result would be **breaking a working release path in order to gain a convenience** — the worst available trade, and one only discovered after merge, since the failure is invisible until a Vale release actually needs publishing. -The prerequisites slot between the units: create the environment before PR 2 merges; register the binding (with the one-time manual first publish) before PR 3 merges. Each PR is well under ~300 lines. Forward is correct rather than merely tidy — PR 1 leaves every existing flow working, and PRs 2 and 3 each add exactly one independent capability. The last one to merge archives the change; the changeset lives on PR 1, the bottom of the stack, and grows as each unit lands. +So the nightly goes first. It exercises exactly the same machinery — the same environment, the same OIDC handshake, the same trusted-publishing model — on a package where failure costs nothing: `@taskless/cli-nightly` is new, nothing depends on it, no consumer resolves it, and a failed first publish blocks nobody and breaks no release. Whatever the environment or the binding model turns out to require is learned there, on a disposable target, and by the time Vale moves the destination is proven rather than hoped for. -If either prerequisite is not in place when its PR is ready, that PR waits. It does not merge with the environment referenced but absent, because the failure mode is a red release workflow on `main`, which is the thing everyone learns to ignore. +The general form, worth carrying past this change: + +> **Prove a new credential path on something disposable before migrating something that works onto it.** + +The check on whether Vale's bindings are environment-scoped (task 4.2) is not dropped by this ordering — it is _informed_ by it. After a nightly has published through `npm-autopublish`, that check has a known-good reference to compare against instead of being a question asked in the dark. + +- **Alternative — Vale first, because it is a one-line change:** rejected. Diff size is not risk. The one-line change is the one that can break a path people depend on; the workflow-sized change is the one that cannot. +- **Alternative — migrate Vale and keep a stored token as a fallback:** rejected. Introducing a long-lived credential to de-risk a migration away from human approval gives back more than the migration is worth. + +### D10 — Delivery shape: **stacked, merging forward**, in three PRs + +Each unit is independently safe in production, and the ordering is set by D9 — prove the new credential path, then migrate onto it. + +| # | PR | Contains | Safe alone? | +| --- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| 1 | Split the release workflows | `release.yml` → `release-cli-changeset.yml` + `release-cli.yml`, `vale-binaries.yml` → `release-vale.yml`, corrected header comments, concurrency per D6. **No environment change.** | Yes — a behavior-preserving reorganization of what already runs. | +| 2 | Add the nightly | `release-cli-nightly.yml`, the pack script, docs. | Yes — and its failure mode is contained: nothing depends on the package (D9). | +| 3 | Move Vale to `npm-autopublish` | One `environment:` line in `release-vale.yml`, plus the spec delta. | Yes — once PR 2 has demonstrated the environment and the handshake work end to end. | + +Both human-gated prerequisites (D8) sit before PR 2: the `npm-autopublish` environment must exist, and `@taskless/cli-nightly` must have a trusted-publisher binding registered after its one-time manual first publish. PR 3 requires no new prerequisite of its own — only the evidence PR 2 produced. + +Each PR is well under ~300 lines. Forward is correct rather than merely tidy: PR 1 leaves every existing flow working, PR 2 adds a package nobody consumes, and PR 3 changes one line on a path that has by then been proven. The last one to merge archives the change; the changeset lives on PR 1, the bottom of the stack, and grows as each unit lands. + +If a prerequisite is not in place when PR 2 is ready, that PR waits. It does not merge with the environment referenced but absent, because the failure mode is a red release workflow on `main`, which is the thing everyone learns to ignore. Likewise PR 3 does not merge on a nightly that has not actually published — a green workflow that never reached the publish step proves nothing about the handshake. ## Risks / Trade-offs @@ -171,10 +190,11 @@ If either prerequisite is not in place when its PR is ready, that PR waits. It d - **A chore merge mints a nightly while changesets are pending** → accepted (D4), with a `packages/cli/**` gate named as the remedy if it becomes noise. - **The first nightly publish is manual** → unavoidable (D8); npm has nothing to bind a trusted publisher to until the name exists. `vale-binaries.yml` documents the same one-time step for its packages, including publishing the packed tarball rather than the directory so the name is not burned on a placeholder version. - **`changeset status` changes its output shape** → the nightly would fail to read `newVersion` and the build would fail loudly rather than publishing a wrong version. Acceptable; a mis-stamped nightly is worse than a missing one. +- **Vale's trusted-publisher bindings turn out to be scoped to `npm-production`** → mitigated by ordering (D9): the nightly proves the environment and the handshake first, on a package whose failure is free, so the Vale migration is attempted with the answer already known rather than discovered by a broken release. ## Migration Plan -PR 1 is a move: after it merges, `main` behaves exactly as before. PR 2 changes one environment reference. PR 3 adds a workflow that publishes a package nobody depends on. There is no data migration and no consumer-visible change to `@taskless/cli`. +PR 1 is a move: after it merges, `main` behaves exactly as before. PR 2 adds a workflow that publishes a package nobody depends on. PR 3 changes one environment reference, onto a path PR 2 has already exercised. There is no data migration and no consumer-visible change to `@taskless/cli`. Rollback is per-unit and cheap: delete `release-cli-nightly.yml` (the published nightlies are inert), or point `release-vale.yml` back at `npm-production`. Reverting PR 1 restores `release.yml` wholesale. diff --git a/openspec/changes/nightly-cli-builds/proposal.md b/openspec/changes/nightly-cli-builds/proposal.md index 3e19606f..449fde55 100644 --- a/openspec/changes/nightly-cli-builds/proposal.md +++ b/openspec/changes/nightly-cli-builds/proposal.md @@ -13,7 +13,9 @@ At the same time `release.yml` has grown two release designs in one file. The cr - **Add an `npm-autopublish` environment** for flows that publish without a human click, and **move the Vale platform packages into it.** `@taskless/cli` keeps `npm-production` and its required reviewer. - **Correct `release.yml`'s stale header claim** that `npm-production` has no required reviewers. It does — confirmed via `gh api repos/taskless/cli/environments` — and the comment has been asserting the opposite. -**Two prerequisites are human-gated and cannot be worked around by an implementer:** the `npm-autopublish` environment must be created in repository settings, and a trusted-publisher binding for `@taskless/cli-nightly` must be registered on npm. Trusted publishing is configured per package and the package does not exist yet, so the first publish is a deliberate manual step. Until both are done the nightly workflow cannot be verified end to end. See design D7 and the delivery shape below. +**Two prerequisites are human-gated and cannot be worked around by an implementer:** the `npm-autopublish` environment must be created in repository settings, and a trusted-publisher binding for `@taskless/cli-nightly` must be registered on npm. Trusted publishing is configured per package and the package does not exist yet, so the first publish is a deliberate manual step. Until both are done the nightly workflow cannot be verified end to end. + +**Delivery is stacked, merging forward, in three PRs: the workflow split, then the nightly, then the Vale move.** The nightly comes before the Vale move deliberately. `npm-autopublish` is an unproven credential path, and Vale's six trusted-publisher bindings were registered against `npm-production` — if they turn out to be environment-scoped, migrating Vale first breaks a working release path with no stored token to fall back on. The nightly exercises the same environment and the same OIDC handshake on a package where a failed first publish costs nothing. See design D8 for the prerequisites, D9 for the ordering, and D10 for the shape. ## Capabilities diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index f856b43a..e6e57d71 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -1,4 +1,4 @@ -Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is the bottom PR and carries the changeset; groups 3 and 4 are the two PRs above it. Groups 2 and 5 are human-gated prerequisites and are **not** code — they must be done by a maintainer in GitHub and npm settings, between merges. +Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is the bottom PR and carries the changeset; groups 4 and 5 are the two PRs above it. Groups 2 and 3 are human-gated prerequisites and are **not** code — they must be done by a maintainer in GitHub and npm settings, between merges. ## 0. Prerequisites and facts to confirm before writing anything @@ -18,20 +18,18 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [ ] 1.8 Add the changeset for the whole change on this branch, before cutting the PRs above it; grow it as groups 3 and 4 land - [ ] 1.9 Verify on merge that the Version Packages PR flow still opens/updates normally and that an ordinary push instantiates no OIDC-capable job -## 2. Human-gated prerequisite — the `npm-autopublish` environment +## 2. Human-gated prerequisite — the `npm-autopublish` environment (before the nightly) - [ ] 2.1 **Maintainer action, in GitHub repository settings:** create an environment named `npm-autopublish` with **no required reviewers** and a deployment branch policy restricting it to `main`. An implementer cannot do this and cannot test around it — a workflow referencing a missing environment fails the run (D7, D8) - [ ] 2.2 Confirm via `gh api repos/taskless/cli/environments` that `npm-autopublish` exists, has no `required_reviewers`, and has the branch policy applied -## 3. PR 2 — move Vale to `npm-autopublish` (depends on group 2) +## 3. Human-gated prerequisite — trusted publishing for `@taskless/cli-nightly` -- [ ] 3.1 Change the `publish` job's `environment:` in `release-vale.yml` from `npm-production` to `npm-autopublish` -- [ ] 3.2 Update the header comment's "PUBLISHING IDENTITY" paragraph to name the new environment and to state the reason: the manifest-update PR is the review gate, and the CLI's exact pins mean an auto-published package reaches no user until someone bumps the pin (D7) -- [ ] 3.3 Confirm the npm trusted-publisher bindings for all six `@taskless/vale-*` packages still authorize the workflow after the environment change — the binding names the workflow, and an environment change must not invalidate it. If npm's binding is environment-scoped, re-register before merging -- [ ] 3.4 Verify with a `workflow_dispatch` `publish --force` run that the Vale set publishes with no approval click -- [ ] 3.5 Do not merge this PR before 2.2 passes +- [ ] 3.1 **Maintainer action, one time:** publish the first `@taskless/cli-nightly` version manually so the name exists — npm has nothing to bind a trusted publisher to until it does. Publish the packed tarball produced by the script in 4.1, not the package directory, so the name is not burned on a placeholder version (the trap `vale-binaries.yml` documents) +- [ ] 3.2 **Maintainer action:** register the npm trusted-publisher binding for `@taskless/cli-nightly` against `release-cli-nightly.yml` and the `npm-autopublish` environment. There is no fallback token path, by design +- [ ] 3.3 Confirm the binding, then merge PR 2 -## 4. PR 3 — the nightly (depends on group 5 for its first real run) +## 4. PR 2 — the nightly (depends on groups 2 and 3 for its first real run) - [ ] 4.1 Add a pack script under `.github/scripts/` that rewrites `packages/cli/package.json` at pack time to `name: @taskless/cli-nightly` and the stamped version, leaving `bin`, `optionalDependencies`, and the committed manifest untouched — the same shape `vale-prepare.cjs` uses (D2) - [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2) @@ -43,13 +41,16 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [ ] 4.8 Pin `npm` to the same version the other publish workflows pin, and keep `--ignore-scripts` on the install so no lifecycle code runs while the OIDC identity exists - [ ] 4.9 Write the header comment for the file: why `main` and not pull requests (unreviewed code under the `@taskless` scope, and the inverted trust split), why the two gates are in that order, and why the Version Packages merge needs no special case - [ ] 4.10 Document installing a nightly in the README — the package name, that `bin` is `taskless`, and that installing it alongside `@taskless/cli` globally collides and is unsupported -- [ ] 4.11 Archive the change on this PR, as the tip of the stack +- [ ] 4.11 Confirm a nightly actually published through `npm-autopublish` before PR 3 is opened — this run is what proves the environment and the OIDC handshake, and it is the whole reason the nightly precedes the Vale move (D9) -## 5. Human-gated prerequisite — trusted publishing for `@taskless/cli-nightly` +## 5. PR 3 — move Vale to `npm-autopublish` (depends on a proven nightly publish, group 4) -- [ ] 5.1 **Maintainer action, one time:** publish the first `@taskless/cli-nightly` version manually so the name exists — npm has nothing to bind a trusted publisher to until it does. Publish the packed tarball produced by the script in 4.1, not the package directory, so the name is not burned on a placeholder version (the trap `vale-binaries.yml` documents) -- [ ] 5.2 **Maintainer action:** register the npm trusted-publisher binding for `@taskless/cli-nightly` against `release-cli-nightly.yml` and the `npm-autopublish` environment. There is no fallback token path, by design -- [ ] 5.3 Confirm the binding, then merge PR 3 +- [ ] 5.1 Change the `publish` job's `environment:` in `release-vale.yml` from `npm-production` to `npm-autopublish` +- [ ] 5.2 Update the header comment's "PUBLISHING IDENTITY" paragraph to name the new environment and to state the reason: the manifest-update PR is the review gate, and the CLI's exact pins mean an auto-published package reaches no user until someone bumps the pin (D7) +- [ ] 5.3 Confirm the npm trusted-publisher bindings for all six `@taskless/vale-*` packages still authorize the workflow after the environment change — the binding names the workflow, and an environment change must not invalidate it. If npm's binding is environment-scoped, re-register before merging +- [ ] 5.4 Verify with a `workflow_dispatch` `publish --force` run that the Vale set publishes with no approval click +- [ ] 5.5 Do not merge this PR before a nightly has published through `npm-autopublish` (4.11). Moving Vale first would risk breaking a working release path to enable a convenience; moving it second means the destination is proven +- [ ] 5.6 Archive the change on this PR, as the tip of the stack ## 6. Verify the acceptance criteria on `main` From 6f7c50166faf523b76c7d37932285985b1b8c70e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 09:30:19 -0700 Subject: [PATCH 3/4] docs(openspec): close the gaps the review found in the nightly proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- openspec/changes/nightly-cli-builds/design.md | 10 +++++++++- .../specs/cli-nightly-builds/spec.md | 7 +++++++ openspec/changes/nightly-cli-builds/tasks.md | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 19abbfd3..61e6faf6 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -58,6 +58,10 @@ Reads as: _the future `0.11.0`, built at that time, from that commit._ Both halv **`--tag latest` on publish is mandatory.** Every version here is a semver prerelease, and npm will not move `latest` onto a prerelease unless told to. Without it, `npm i @taskless/cli-nightly` resolves nothing useful — the package would have versions and no default. `vale-binaries.yml` already records this lesson; this is the second package to need it. +**There is no tag bookkeeping beyond that, deliberately.** `latest` is correct in every instance this package ever produces: each publish is the newest build of `main`, and nobody resolves a nightly by range — the prerelease component exists to make the version sortable and traceable, not to offer a channel to pin. So there is no second dist-tag to maintain, no promotion step, and no state that can drift. + +The case worth naming, since a reviewer will reach for it: a publish that succeeds while the tag move does not, leaving a version on the registry that `latest` does not point at. Gate 2 tests existence, so a re-run on that same commit is skipped and does not repair it. That is accepted rather than mitigated. The condition lasts until the next nightly, which is the next push to `main` with changesets pending, and that publish sets `latest` correctly with no intervention. Building a re-tag path would add a repair mechanism, and a state for it to detect, for a window that closes on its own — and it is worth remembering that gate 2 skipping a re-run is right on its own terms: identical bytes, nothing new to publish. + This deliberately differs from `@taskless/vale-*`, which stamps `n.m.k-yyyymmddhhmmss` with no SHA. Vale republishes upstream binaries and has no commit of ours to key on. - **Alternative — `0.11.0-nightly.N` with an incrementing counter:** rejected. It needs state outside the build to know `N`, and it cannot answer "was this SHA built?" without a lookup table. @@ -89,7 +93,11 @@ Only past both gates does the build run, taking `newVersion` from `changeset sta ] ``` -Two verified traps, both silent: **the path must be repo-relative** — an absolute `/tmp/...` path fails to write the file without failing the command — and **the JSON file is authoritative, not stdout**, which also carries unrelated workspace-version warnings. Read the file. +Three traps, all silent. + +**The path must be repo-relative** — an absolute `/tmp/...` path fails to write the file without failing the command. **The JSON file is authoritative, not stdout**, which also carries unrelated workspace-version warnings. Read the file. + +And **the output is an array, so select by name, never by index.** It lists every package the pending changesets release. `[0]` is `@taskless/cli` only for as long as the CLI is the sole changesets-managed package, and the six `@taskless/vale-*` packages already sit in the changesets `ignore` list precisely because a second managed package is a thing that happens. The day one is added, `[0]` stamps the nightly with another package's version — a wrong version that publishes successfully and looks plausible. Match on `name === "@taskless/cli"`. **Chore merges.** A chore adds no changeset, so it never _starts_ a nightly. It will produce a new nightly if changesets are already pending, because the SHA moved and gate 2 is per-SHA. That is accepted: the nightly claims to be "this commit of `main`," and a chore does produce a new commit of `main`. If it becomes noisy, the fix is an additional gate on `packages/cli/**` having changed since the last nightly — deliberately not built now, because the cost is a version string nobody will notice. diff --git a/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md b/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md index 7cfe4abc..35447a77 100644 --- a/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md +++ b/openspec/changes/nightly-cli-builds/specs/cli-nightly-builds/spec.md @@ -26,6 +26,8 @@ The nightly SHALL be built from the same source and the same build as the releas A nightly version SHALL take the form `-x`, where `n.m.k` is the version the default branch's pending release metadata proposes, `yyyymmddhhmmss` is the build time, and `sha` is the short commit hash. +The pending release metadata describes every package it releases, so `n.m.k` SHALL be selected by matching the CLI package's name, and SHALL NOT be taken by position. Position is correct only while the CLI is the sole package under release management, and a version taken from another package publishes successfully while naming a release that was never proposed. + The timestamp SHALL precede the commit hash, so that lexical comparison of the prerelease identifier orders builds chronologically. The prerelease identifier SHALL contain a non-digit separator between the timestamp and the hash, so that the identifier is never all-digits — an all-digit prerelease identifier is compared numerically and may not begin with a zero, which a hash beginning with `0` would otherwise violate. #### Scenario: A nightly version is stamped @@ -38,6 +40,11 @@ The timestamp SHALL precede the commit hash, so that lexical comparison of the p - **WHEN** the short commit hash consists only of digits and begins with `0` - **THEN** the published version SHALL still be a valid semantic version +#### Scenario: The anticipated version is selected by package, not by position + +- **WHEN** the pending release metadata describes more than one package +- **THEN** the nightly version SHALL use the entry naming the CLI package + #### Scenario: Nightlies sort chronologically - **WHEN** two nightlies of the same `n.m.k` are compared diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index e6e57d71..532e9f5b 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -4,7 +4,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [ ] 0.1 Re-confirm the live environment configuration with `gh api repos/taskless/cli/environments` — the correction in 1.4 depends on `npm-production` still having `required_reviewers` - [ ] 0.2 Confirm `changeset status --output=` on this repository writes `[{ name, type, oldVersion, newVersion }]`, and re-confirm both traps: an absolute path silently writes nothing, and stdout carries unrelated workspace-version warnings so the JSON file is the only source to read -- [ ] 0.3 Confirm the currently published `@taskless/cli-nightly` name is unclaimed on npm +- [ ] 0.3 Confirm the `@taskless/cli-nightly` name is unclaimed on npm ## 1. PR 1 — split the release workflows (no behavior change) @@ -15,7 +15,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [ ] 1.5 Rename `.github/workflows/vale-binaries.yml` → `.github/workflows/release-vale.yml` with no behavior change, preserving the whole header comment - [ ] 1.6 Delete `.github/workflows/release.yml` - [ ] 1.7 Check for references to the old filenames — branch protection required checks, `pr-check-openspec.yml`, `require-changeset.yml`, `stack-breadcrumb.yml`, README and docs — and update anything that names `release.yml` or `Vale Binaries`. A renamed workflow means a renamed check, and a required check that no longer reports blocks merges silently -- [ ] 1.8 Add the changeset for the whole change on this branch, before cutting the PRs above it; grow it as groups 3 and 4 land +- [ ] 1.8 Add the changeset on this branch, before cutting the PRs above it, describing **this PR's scope only** — the workflow split. The stack merges forward, so `CLAUDE.md` requires each unit to extend the changeset with what it actually landed rather than the base promising the whole change up front; a reviewer reading it should see only what has merged. Groups 4 and 5 each extend the same file (never add a second changeset) - [ ] 1.9 Verify on merge that the Version Packages PR flow still opens/updates normally and that an ordinary push instantiates no OIDC-capable job ## 2. Human-gated prerequisite — the `npm-autopublish` environment (before the nightly) @@ -32,7 +32,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is ## 4. PR 2 — the nightly (depends on groups 2 and 3 for its first real run) - [ ] 4.1 Add a pack script under `.github/scripts/` that rewrites `packages/cli/package.json` at pack time to `name: @taskless/cli-nightly` and the stamped version, leaving `bin`, `optionalDependencies`, and the committed manifest untouched — the same shape `vale-prepare.cjs` uses (D2) -- [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2) +- [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2). **Select the entry by `name === "@taskless/cli"`, never `[0]`** — the output is an array of every package the pending changesets release, and index 0 is only the CLI while it is the sole changesets-managed package. Taking `[0]` stamps the nightly with another package's version the day a second one is added, and nothing fails loudly when it does - [ ] 4.3 Keep the `x` separator and cover it with a test: a short SHA of all digits beginning with `0` must still produce a valid semantic version. This is the one detail most likely to be "simplified" away by a later reader who sees it as decoration (D3) - [ ] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 as a **directory listing of `.changeset/`** that runs before any dependency install, and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4) - [ ] 4.5 Keep the gates credential-free and in their own job, so the publish job — and therefore the OIDC identity — exists only for a run that will actually publish From 512110b8076140ffbb2e65a5227e5e8f37f7c33b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 13:14:34 -0700 Subject: [PATCH 4/4] docs(openspec): gate 1 cannot be a directory listing, and group 3 needs PR 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- openspec/changes/nightly-cli-builds/design.md | 6 ++++-- openspec/changes/nightly-cli-builds/proposal.md | 2 +- openspec/changes/nightly-cli-builds/tasks.md | 12 +++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 61e6faf6..7588faab 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -70,7 +70,9 @@ This deliberately differs from `@taskless/vale-*`, which stamps `n.m.k-yyyymmddh ### D4 — Two gates, in order: empty `.changeset/`, then unbuilt SHA -**Gate 1 — is `.changeset/` empty?** No changeset files means nothing is pending: `main` is at its released version and there is no future `n.m.k` to name. This is a directory listing. It needs no tooling, runs before anything is installed, and is the cheapest possible early exit on the overwhelmingly common push. +**Gate 1 — are any changesets pending?** No pending changeset means nothing is unreleased: `main` is at its released version and there is no future `n.m.k` to name. This runs before anything is installed, and is the cheapest possible early exit on the overwhelmingly common push. + +**It is not a bare directory listing.** `.changeset/` always contains `README.md` and `config.json` — `changesets init` writes both and nothing removes them — so the directory is never empty and a literal emptiness test would answer "pending" on every push, including the one case this gate exists to handle. The question is whether any `.changeset/*.md` other than `README.md` exists. `require-changeset.yml` already counts them exactly that way (`grep -viE '/README\.md$'`); use the same rule rather than inventing a second one. It also makes the Version Packages PR merge **self-handling, with no special case**. That merge consumes every changeset and bumps `package.json`, so on that push the directory is empty, no nightly is built, and `release-cli.yml` publishes the real release instead. The two flows do not need to know about each other. @@ -168,7 +170,7 @@ The general form, worth carrying past this change: > **Prove a new credential path on something disposable before migrating something that works onto it.** -The check on whether Vale's bindings are environment-scoped (task 4.2) is not dropped by this ordering — it is _informed_ by it. After a nightly has published through `npm-autopublish`, that check has a known-good reference to compare against instead of being a question asked in the dark. +The check on whether Vale's bindings are environment-scoped (task 5.3) is not dropped by this ordering — it is _informed_ by it. After a nightly has published through `npm-autopublish`, that check has a known-good reference to compare against instead of being a question asked in the dark. - **Alternative — Vale first, because it is a one-line change:** rejected. Diff size is not risk. The one-line change is the one that can break a path people depend on; the workflow-sized change is the one that cannot. - **Alternative — migrate Vale and keep a stored token as a fallback:** rejected. Introducing a long-lived credential to de-risk a migration away from human approval gives back more than the migration is worth. diff --git a/openspec/changes/nightly-cli-builds/proposal.md b/openspec/changes/nightly-cli-builds/proposal.md index 449fde55..1b9dc472 100644 --- a/openspec/changes/nightly-cli-builds/proposal.md +++ b/openspec/changes/nightly-cli-builds/proposal.md @@ -8,7 +8,7 @@ At the same time `release.yml` has grown two release designs in one file. The cr - **Publish `@taskless/cli-nightly` from `main`.** Same source, same `bin: taskless`, published under a different name so nightlies never appear in `@taskless/cli`'s version history. Built from `main` only: the code has already passed review, and no contributor-authored text reaches the credentialed job. - **Version as `n.m.k-yyyymmddhhmmssx`** (e.g. `0.11.0-20260818123456x05b3c88`), where `n.m.k` is the bump `main`'s pending changesets propose. The timestamp sorts; the SHA identifies and dedupes. -- **Gate on two questions, in order:** is `.changeset/` empty (nothing pending, nothing to build), and has this SHA already been published? +- **Gate on two questions, in order:** are any changesets pending — any `.changeset/*.md` other than `README.md` (nothing pending, nothing to build), and has this SHA already been published? - **Split `release.yml` into four workflows,** one release design each: `release-cli-changeset.yml`, `release-cli.yml`, `release-vale.yml`, `release-cli-nightly.yml`. - **Add an `npm-autopublish` environment** for flows that publish without a human click, and **move the Vale platform packages into it.** `@taskless/cli` keeps `npm-production` and its required reviewer. - **Correct `release.yml`'s stale header claim** that `npm-production` has no required reviewers. It does — confirmed via `gh api repos/taskless/cli/environments` — and the comment has been asserting the opposite. diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index 532e9f5b..2c6fd21e 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -1,4 +1,4 @@ -Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is the bottom PR and carries the changeset; groups 4 and 5 are the two PRs above it. Groups 2 and 3 are human-gated prerequisites and are **not** code — they must be done by a maintainer in GitHub and npm settings, between merges. +Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is the bottom PR and carries the changeset; groups 4 and 5 are the two PRs above it. Groups 2 and 3 are human-gated and are **not** code — a maintainer in GitHub and npm settings, between merges. ## 0. Prerequisites and facts to confirm before writing anything @@ -25,16 +25,18 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is ## 3. Human-gated prerequisite — trusted publishing for `@taskless/cli-nightly` -- [ ] 3.1 **Maintainer action, one time:** publish the first `@taskless/cli-nightly` version manually so the name exists — npm has nothing to bind a trusted publisher to until it does. Publish the packed tarball produced by the script in 4.1, not the package directory, so the name is not burned on a placeholder version (the trap `vale-binaries.yml` documents) +**This group runs while PR 2 is open and unmerged, not before it exists.** It needs the pack script from task 4.1, which PR 2 introduces — so unlike group 2, it cannot be done ahead of the code. The order is: open PR 2, run its pack script from that branch, do the one-time publish and binding below, then merge PR 2. Vale's equivalent step reads as a clean prerequisite only because `vale-prepare.cjs` was already on `main` when it was written; nothing is on `main` here yet. + +- [ ] 3.1 **Maintainer action, one time:** publish the first `@taskless/cli-nightly` version manually so the name exists — npm has nothing to bind a trusted publisher to until it does. Run the pack script from task 4.1 **on PR 2's branch** and publish the tarball it produces, not the package directory, so the name is not burned on a placeholder version (the trap `vale-binaries.yml` documents) - [ ] 3.2 **Maintainer action:** register the npm trusted-publisher binding for `@taskless/cli-nightly` against `release-cli-nightly.yml` and the `npm-autopublish` environment. There is no fallback token path, by design -- [ ] 3.3 Confirm the binding, then merge PR 2 +- [ ] 3.3 Confirm the binding, then merge PR 2 — the binding must exist before the first automated publish, or PR 2 merges into a workflow whose first run fails the OIDC handshake ## 4. PR 2 — the nightly (depends on groups 2 and 3 for its first real run) - [ ] 4.1 Add a pack script under `.github/scripts/` that rewrites `packages/cli/package.json` at pack time to `name: @taskless/cli-nightly` and the stamped version, leaving `bin`, `optionalDependencies`, and the committed manifest untouched — the same shape `vale-prepare.cjs` uses (D2) - [ ] 4.2 Compute the version as `-x`, reading `newVersion` from the `changeset status --output=` JSON **file** at a repo-relative path (D3, 0.2). **Select the entry by `name === "@taskless/cli"`, never `[0]`** — the output is an array of every package the pending changesets release, and index 0 is only the CLI while it is the sole changesets-managed package. Taking `[0]` stamps the nightly with another package's version the day a second one is added, and nothing fails loudly when it does - [ ] 4.3 Keep the `x` separator and cover it with a test: a short SHA of all digits beginning with `0` must still produce a valid semantic version. This is the one detail most likely to be "simplified" away by a later reader who sees it as decoration (D3) -- [ ] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 as a **directory listing of `.changeset/`** that runs before any dependency install, and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4) +- [ ] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 as a **listing of `.changeset/*.md` excluding `README.md`** that runs before any dependency install (`.changeset/` also permanently holds `README.md` and `config.json`, so a bare emptiness test is never true — `require-changeset.yml` already counts them this way with `grep -viE '/README\.md$'`), and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4) - [ ] 4.5 Keep the gates credential-free and in their own job, so the publish job — and therefore the OIDC identity — exists only for a run that will actually publish - [ ] 4.6 Publish with `--provenance --access public --tag latest`. `--tag latest` is mandatory: every version is a prerelease and npm will not move the default tag onto one unless told to, so without it the package has versions and no default (D3) - [ ] 4.7 Pass no untrusted text through `${{ }}` into any `run:` body; route computed values through `env:`, following `release-vale.yml`'s rule @@ -55,7 +57,7 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is ## 6. Verify the acceptance criteria on `main` - [ ] 6.1 A push to `main` with pending changesets publishes `@taskless/cli-nightly@-x` with the default tag and provenance -- [ ] 6.2 A push to `main` with an empty `.changeset/` publishes no nightly **and exits before installing anything** — check the run log, not just the outcome +- [ ] 6.2 A push to `main` carrying no pending changeset — `.changeset/` holding only `README.md` and `config.json` — publishes no nightly **and exits before installing anything** — check the run log, not just the outcome - [ ] 6.3 A workflow re-run on an already-built SHA publishes nothing - [ ] 6.4 The merge of a Version Packages PR publishes the real release and no nightly, with no special case in either workflow - [ ] 6.5 Vale publishes with no click; `@taskless/cli` still waits for one