From 39deac6bfaf9074fcf061718d98d536769a90394 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 22:35:31 -0700 Subject: [PATCH 1/3] feat(ci): publish unreleased main as @taskless/cli-nightly Add release-cli-nightly.yml and the pack script behind it. Every push to main with changesets pending publishes the CLI under a second name at -x, so merged-but-unreleased work is installable. Two credential-free gates, in their own job, decide whether the publish job exists at all: pending changesets (before any install) and whether the commit already has a nightly. The rename happens at pack time, so the committed manifest and @taskless/cli's version history are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .changeset/nightly-cli-builds.md | 16 + .github/scripts/nightly-pack.cjs | 309 ++++++++++++++++++ .github/scripts/nightly-pack.test.cjs | 208 ++++++++++++ .github/workflows/release-cli-nightly.yml | 252 ++++++++++++++ .gitignore | 6 + README.md | 25 ++ openspec/changes/nightly-cli-builds/design.md | 11 +- openspec/changes/nightly-cli-builds/tasks.md | 20 +- 8 files changed, 834 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/nightly-pack.cjs create mode 100644 .github/scripts/nightly-pack.test.cjs create mode 100644 .github/workflows/release-cli-nightly.yml diff --git a/.changeset/nightly-cli-builds.md b/.changeset/nightly-cli-builds.md index e0acb3c8..87170503 100644 --- a/.changeset/nightly-cli-builds.md +++ b/.changeset/nightly-cli-builds.md @@ -23,3 +23,19 @@ Neither is a required check. The header comments also get one correction: they claimed `npm-production` had no required reviewers, and it has had one all along, so a release has always waited for a human approval that the file said was not there. + +Publish unreleased work on `main` as `@taskless/cli-nightly`. + +Every push to `main` that has changesets pending now publishes the CLI under a +second package name, stamped `-x` — so +merged-but-unreleased behavior is installable with `npx @taskless/cli-nightly`. +A nightly is the same build as the release it anticipates and keeps the +`taskless` executable, so it is a drop-in; the rename happens at pack time, so +`@taskless/cli`'s own version history stays releases-only. Installing both +globally collides on the binary and is unsupported. + +Two credential-free gates decide whether anything is built — pending changesets +first (before any install), then whether the commit already has a nightly — so +the publishing job is never instantiated on an ordinary push, and the merge of a +Version Packages PR publishes the real release and no nightly with no rule +special-casing it. diff --git a/.github/scripts/nightly-pack.cjs b/.github/scripts/nightly-pack.cjs new file mode 100644 index 00000000..7c0a4604 --- /dev/null +++ b/.github/scripts/nightly-pack.cjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * @taskless/cli-nightly — compute the nightly version and pack the CLI under + * the nightly name. + * + * A nightly is byte-for-byte the same build as the release it anticipates, + * differing only in `name` and `version` (design D2). Both are applied HERE, at + * pack time, to a copy of packages/cli/package.json that is written, packed, + * and then restored — the committed manifest is never left rewritten, so + * nothing about the ordinary release path is touched. `bin`, `files`, + * `dependencies`, and `optionalDependencies` are carried through untouched: the + * nightly resolves the same pinned Vale and ast-grep platform packages as the + * release, and still installs a `taskless` executable. + * + * This file is both a module and an entry point: the pure functions below are + * unit-tested by nightly-pack.test.cjs with `node --test` and no build step + * (the same arrangement as vale-release.cjs), while `main()` runs only when the + * file is invoked directly. + * + * Usage: + * node .github/scripts/nightly-pack.cjs --status --sha [--out ] + * + * --status the JSON file written by `changeset status --output=`. + * MUST be a repo-relative path when produced: `--output` resolves + * against the process working directory with no special case for a + * leading `/`, so `--output=/tmp/status.json` means + * `/tmp/status.json` and fails with ENOENT from the repo root. + * --sha the short commit hash to stamp into the version. + * --out where to write the .tgz (default: .nightly-dist at the repo root). + * + * Writes `version` and `tarball` to $GITHUB_OUTPUT when it is set. + */ + +const { + appendFileSync, + mkdirSync, + readFileSync, + writeFileSync, +} = require("node:fs"); +const { join, resolve } = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const REPO_ROOT = resolve(__dirname, "..", ".."); + +/** The package the nightly is built from, and the name it is published under. */ +const SOURCE_PACKAGE = "@taskless/cli"; +const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; + +/** A released CLI version: plain `major.minor.patch`, no prerelease. */ +const PLAIN_VERSION_PATTERN = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; + +/** An abbreviated git object name. */ +const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/; + +/** + * The official semver 2.0.0 grammar, from semver.org. Used as an assertion in + * `buildNightlyVersion` rather than only in a test: a version that npm would + * reject should never be produced in the first place. + */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** Is `text` a valid semantic version? */ +function isValidVersion(text) { + return SEMVER_PATTERN.test(String(text ?? "")); +} + +/** `2026-08-18T12:34:56Z` → `20260818123456`, always UTC, always 14 digits. */ +function formatStampTimestamp(date) { + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`not a usable date: ${JSON.stringify(date)}`); + } + return parsed.toISOString().replaceAll(/\D/g, "").slice(0, 14); +} + +/** + * Pick the version the pending changesets propose for the CLI. + * + * `changeset status --output=` writes an OBJECT whose per-package entries live + * under `releases`, not an array at the root. Selection is BY NAME, never by + * index: `releases` lists every package the pending changesets release, and + * `[0]` is the CLI only for as long as the CLI is the sole changesets-managed + * package. 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]` would stamp the nightly with another + * package's version — a wrong version that publishes successfully and looks + * plausible. + */ +function selectProposedVersion(status, packageName = SOURCE_PACKAGE) { + const releases = status && status.releases; + if (!Array.isArray(releases)) { + throw new Error( + "changeset status output has no `releases` array — the file is an object, not an array; did the output shape change?" + ); + } + const release = releases.find((entry) => entry && entry.name === packageName); + if (!release) { + throw new Error( + `changeset status proposes no release for ${packageName} (found: ${releases + .map((entry) => entry && entry.name) + .join(", ")})` + ); + } + if (!PLAIN_VERSION_PATTERN.test(String(release.newVersion ?? ""))) { + throw new Error( + `newVersion for ${packageName} is not major.minor.patch: ${JSON.stringify(release.newVersion)}` + ); + } + return release.newVersion; +} + +/** + * `-x` (design D3). + * + * The timestamp leads because it is fixed-width, so a lexical comparison of the + * prerelease identifier orders builds chronologically; the sha trails because + * it is what gate 2 matches on to decide whether a commit already has a + * nightly. + * + * THE `x` IS LOAD-BEARING. It is the thing a later reader is most likely to + * "simplify" away, so both failure modes it prevents are named here and both + * are covered in nightly-pack.test.cjs: + * + * `.` A dot starts a NEW prerelease identifier. A short sha + * of all digits beginning with `0` then forms a numeric + * identifier with a leading zero, which semver forbids — + * an INVALID version for roughly one commit in sixteen, + * which is the worst failure cadence available. + * `` A bare concatenation is still *valid* (the timestamp's + * leading digit is never 0), but for an all-digit sha it + * is a single numeric identifier of 21 digits. Semver + * compares numeric identifiers numerically, and that + * exceeds what a double can represent exactly — so + * ordering, the whole reason the timestamp leads, stops + * being reliable, silently. + * + * `x` makes the identifier alphanumeric, so the numeric rule never applies and + * comparison stays lexical for every sha. + */ +function buildNightlyVersion({ baseVersion, date, shortSha }) { + if (!PLAIN_VERSION_PATTERN.test(String(baseVersion ?? ""))) { + throw new Error( + `base version is not major.minor.patch: ${JSON.stringify(baseVersion)}` + ); + } + const sha = String(shortSha ?? "").toLowerCase(); + if (!SHORT_SHA_PATTERN.test(sha)) { + throw new Error( + `not an abbreviated commit hash: ${JSON.stringify(shortSha)}` + ); + } + const version = `${baseVersion}-${formatStampTimestamp(date)}x${sha}`; + if (!isValidVersion(version)) { + throw new Error(`refusing to stamp an invalid version: ${version}`); + } + return version; +} + +/** + * Return the manifest to publish: the committed one with `name` and `version` + * replaced and every other field — `bin`, `files`, `dependencies`, + * `optionalDependencies`, `engines`, `exports` — carried through by value. + */ +function applyNightlyIdentity(packageJson, version) { + return { ...packageJson, name: NIGHTLY_PACKAGE, version }; +} + +/** + * Does any published version belong to this commit? Gate 2, as a pure function + * over what `npm view versions --json` returned. + * + * `--json` yields an ARRAY for a package with several versions and a bare + * STRING for one with exactly one — which the nightly package will be, once, + * right after its bootstrap publish. Normalizing both is the difference between + * a working gate and one that skips every build for a day. + */ +function hasNightlyForSha(versions, shortSha) { + const sha = String(shortSha ?? "").toLowerCase(); + if (!SHORT_SHA_PATTERN.test(sha)) { + throw new Error( + `not an abbreviated commit hash: ${JSON.stringify(shortSha)}` + ); + } + const list = + typeof versions === "string" + ? [versions] + : Array.isArray(versions) + ? versions + : []; + return list.some((version) => String(version).endsWith(`x${sha}`)); +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.length === 0 || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseArguments(argv) { + const options = { out: join(REPO_ROOT, ".nightly-dist") }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--status") { + index += 1; + options.status = resolve(requireValue(argv, index, "--status")); + } else if (argument === "--sha") { + index += 1; + options.sha = requireValue(argv, index, "--sha"); + } else if (argument === "--out") { + index += 1; + options.out = resolve(requireValue(argv, index, "--out")); + } else { + throw new Error(`unknown argument: ${argument}`); + } + } + if (!options.status) { + throw new Error("--status is required"); + } + if (!options.sha) { + throw new Error("--sha is required"); + } + return options; +} + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + const status = JSON.parse(readFileSync(options.status, "utf8")); + const version = buildNightlyVersion({ + baseVersion: selectProposedVersion(status), + date: new Date(), + shortSha: options.sha, + }); + + const packageDirectory = join(REPO_ROOT, "packages", "cli"); + const packageJsonPath = join(packageDirectory, "package.json"); + const committed = readFileSync(packageJsonPath, "utf8"); + const nightly = applyNightlyIdentity(JSON.parse(committed), version); + + console.log(`${NIGHTLY_PACKAGE}@${version}`); + mkdirSync(options.out, { recursive: true }); + + // Written, packed, restored. `npm pack` reads the manifest off disk, so the + // rewrite has to be real — but it lives only for the length of the pack, and + // the restore is in `finally` so a failed pack does not strand a rewritten + // manifest in the working tree. + writeFileSync(packageJsonPath, `${JSON.stringify(nightly, null, 2)}\n`); + let packed; + try { + packed = spawnSync( + "npm", + ["pack", "--ignore-scripts", "--json", "--pack-destination", options.out], + { cwd: packageDirectory, encoding: "utf8" } + ); + } finally { + writeFileSync(packageJsonPath, committed); + } + + if (packed.error) { + throw packed.error; + } + if (packed.status !== 0) { + throw new Error( + `npm pack exited with status ${packed.status}\n${packed.stderr}` + ); + } + + // npm reports the filename it chose; deriving it from the package name would + // be re-deriving what the tool already told us. + const [entry] = JSON.parse(packed.stdout); + const tarball = join(options.out, entry.filename); + console.log(`packed ${tarball}`); + + setOutput("version", version); + setOutput("tarball", tarball); +} + +module.exports = { + NIGHTLY_PACKAGE, + SOURCE_PACKAGE, + applyNightlyIdentity, + buildNightlyVersion, + formatStampTimestamp, + hasNightlyForSha, + isValidVersion, + selectProposedVersion, +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`\nnightly-pack failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/nightly-pack.test.cjs b/.github/scripts/nightly-pack.test.cjs new file mode 100644 index 00000000..e005db00 --- /dev/null +++ b/.github/scripts/nightly-pack.test.cjs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const { + NIGHTLY_PACKAGE, + SOURCE_PACKAGE, + applyNightlyIdentity, + buildNightlyVersion, + formatStampTimestamp, + hasNightlyForSha, + isValidVersion, + selectProposedVersion, +} = require("./nightly-pack.cjs"); + +/** The CLI manifest as committed, so these tests fail if it drifts out of shape. */ +const COMMITTED_CLI_MANIFEST = JSON.parse( + readFileSync( + join(__dirname, "..", "..", "packages", "cli", "package.json"), + "utf8" + ) +); + +const DATE = "2026-08-18T12:34:56.000Z"; + +test("formatStampTimestamp renders 14 UTC digits", () => { + assert.equal(formatStampTimestamp(DATE), "20260818123456"); + assert.equal(formatStampTimestamp("2026-01-02T03:04:05Z"), "20260102030405"); + assert.throws(() => formatStampTimestamp("not a date"), /not a usable date/); +}); + +test("buildNightlyVersion stamps -x", () => { + assert.equal( + buildNightlyVersion({ + baseVersion: "0.11.0", + date: DATE, + shortSha: "05b3c88", + }), + "0.11.0-20260818123456x05b3c88" + ); +}); + +// The `x` separator, and the two ways removing it breaks. Both alternatives are +// checked against the same semver grammar the stamper asserts with, so this +// test states exactly what the separator buys rather than asserting it exists. +test("an all-digit sha beginning with 0 still yields a valid semantic version", () => { + const version = buildNightlyVersion({ + baseVersion: "0.11.0", + date: DATE, + shortSha: "0123456", + }); + assert.equal(version, "0.11.0-20260818123456x0123456"); + assert.ok(isValidVersion(version), `${version} must be a valid semver`); + + // A dot would start a second prerelease identifier, making `0123456` a + // numeric identifier with a leading zero — which semver forbids outright. + assert.equal(isValidVersion("0.11.0-20260818123456.0123456"), false); + + // A bare concatenation stays valid, but becomes one 21-digit NUMERIC + // identifier: semver compares those numerically, and 21 digits is past exact + // double precision, so chronological ordering silently stops being reliable. + assert.ok(isValidVersion("0.11.0-202608181234560123456")); + // Two distinct 21-digit identifiers that a numeric comparison cannot tell + // apart, because both collapse onto the same double: + assert.equal( + Number("202608181234560123456") === Number("202608181234560123457"), + true + ); +}); + +test("nightlies of one base version sort chronologically by string comparison", () => { + const earlier = buildNightlyVersion({ + baseVersion: "0.11.0", + date: "2026-08-18T12:34:56Z", + shortSha: "ffffff0", + }); + const later = buildNightlyVersion({ + baseVersion: "0.11.0", + date: "2026-08-19T00:00:00Z", + shortSha: "0000001", + }); + assert.ok(earlier < later, `${earlier} must sort before ${later}`); +}); + +test("buildNightlyVersion refuses input it cannot stamp correctly", () => { + assert.throws( + () => + buildNightlyVersion({ + baseVersion: "0.11.0-20260818123456x05b3c88", + date: DATE, + shortSha: "05b3c88", + }), + /not major\.minor\.patch/, + "an already-stamped version must not be stamped twice" + ); + assert.throws( + () => + buildNightlyVersion({ + baseVersion: "0.11.0", + date: DATE, + shortSha: "zz", + }), + /not an abbreviated commit hash/ + ); +}); + +test("selectProposedVersion matches the CLI by name, never by position", () => { + const status = { + changesets: [{ id: "wild-jars-repeat", releases: [], summary: "…" }], + releases: [ + { + name: "@taskless/some-other-package", + type: "major", + oldVersion: "1.0.0", + changesets: ["wild-jars-repeat"], + newVersion: "2.0.0", + }, + { + name: SOURCE_PACKAGE, + type: "minor", + oldVersion: "0.10.2", + changesets: ["wild-jars-repeat"], + newVersion: "0.11.0", + }, + ], + }; + assert.equal(selectProposedVersion(status), "0.11.0"); +}); + +test("selectProposedVersion fails loudly on a shape it does not recognize", () => { + // The pre-measurement guess: an array at the root rather than an object. + assert.throws( + () => + selectProposedVersion([{ name: SOURCE_PACKAGE, newVersion: "0.11.0" }]), + /no `releases` array/ + ); + assert.throws( + () => selectProposedVersion({ releases: [] }), + /proposes no release for @taskless\/cli/ + ); + assert.throws( + () => + selectProposedVersion({ + releases: [{ name: SOURCE_PACKAGE, newVersion: "0.11" }], + }), + /not major\.minor\.patch/ + ); +}); + +test("applyNightlyIdentity renames and restamps, and changes nothing else", () => { + const nightly = applyNightlyIdentity( + COMMITTED_CLI_MANIFEST, + "0.11.0-20260818123456x05b3c88" + ); + + assert.equal(nightly.name, NIGHTLY_PACKAGE); + assert.equal(nightly.version, "0.11.0-20260818123456x05b3c88"); + + // A nightly is a drop-in: same executable name, same pinned platform deps. + assert.deepEqual(nightly.bin, COMMITTED_CLI_MANIFEST.bin); + assert.deepEqual(nightly.bin, { taskless: "./dist/index.js" }); + assert.deepEqual( + nightly.optionalDependencies, + COMMITTED_CLI_MANIFEST.optionalDependencies + ); + assert.deepEqual(nightly.dependencies, COMMITTED_CLI_MANIFEST.dependencies); + assert.deepEqual(nightly.files, COMMITTED_CLI_MANIFEST.files); + assert.deepEqual(nightly.exports, COMMITTED_CLI_MANIFEST.exports); + + // Every key survives, and the input object is not mutated. + assert.deepEqual( + Object.keys(nightly).sort(), + Object.keys(COMMITTED_CLI_MANIFEST).sort() + ); + assert.equal(COMMITTED_CLI_MANIFEST.name, SOURCE_PACKAGE); +}); + +test("hasNightlyForSha matches on the trailing x", () => { + const versions = [ + "0.11.0-20260818123456x05b3c88", + "0.11.0-20260819010203xdeadbee", + ]; + assert.equal(hasNightlyForSha(versions, "05b3c88"), true); + assert.equal(hasNightlyForSha(versions, "DEADBEE"), true); + assert.equal(hasNightlyForSha(versions, "0123456"), false); + + // `npm view versions --json` yields a bare STRING when exactly one + // version is published — which this package is, once, right after its + // bootstrap publish. + assert.equal( + hasNightlyForSha("0.11.0-20260818123456x05b3c88", "05b3c88"), + true + ); + + // A 404 (no such package) reaches the gate as an empty list, not a crash. + assert.equal(hasNightlyForSha([], "05b3c88"), false); + + // A sha is a prefix of a longer one only in the argument, never in the match: + // the version's identifier ends at the sha, so no partial match can occur. + assert.equal( + hasNightlyForSha(["0.11.0-20260818123456x05b3c880"], "05b3c88"), + false + ); +}); diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml new file mode 100644 index 00000000..3fb386c4 --- /dev/null +++ b/.github/workflows/release-cli-nightly.yml @@ -0,0 +1,252 @@ +# SPDX-License-Identifier: MIT +# Publish @taskless/cli-nightly — the CLI as it stands on main, between releases. +# +# A nightly is the same build as the release it anticipates, published under a +# different name and a stamped prerelease version (design D2/D3). The rename +# happens at pack time in .github/scripts/nightly-pack.cjs; the committed +# packages/cli/package.json is never changed, so @taskless/cli's own version +# history contains releases and nothing else. +# +# PUSH TO main, NEVER A PULL REQUEST. This is a security property, not a +# convenience. A PR-triggered publish would invert the split release-cli.yml +# exists to maintain: contributor-authored text would flow into a job holding an +# OIDC identity, and UNREVIEWED code would be published to npm under the +# @taskless scope. Building from main means the failure cannot arise — the only +# code that can be published is code that already merged. (A PR-side trigger is +# also measurably stale: a changeset is written early and the implementation +# lands after it, by 7 and 11 commits on the two branches measured for D1.) +# +# TWO GATES, IN THIS ORDER (design D4): +# +# 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 is a file listing. It runs BEFORE any dependency install, +# because it is false on the overwhelmingly common push and there is no +# cheaper place to exit. +# +# 2. Does this commit already have a nightly? Versions ending in `x` +# belong to this commit, so a re-run — or any future trigger that fires +# twice for one commit — publishes nothing. +# +# The order matters: gate 1 costs a `find`, gate 2 costs a registry round trip. +# Only past both does anything get installed, built, or credentialed. +# +# THE VERSION PACKAGES MERGE NEEDS NO SPECIAL CASE, and that falls out of gate 1 +# rather than being arranged. That merge consumes every changeset and bumps +# packages/cli/package.json, so on that push .changeset/ holds only its README +# and config, gate 1 is false, and no nightly is built — while release-cli.yml +# sees a version npm has not got and publishes the real release. Neither +# workflow knows about the other, and neither has a rule naming the other's +# commit. +# +# GATES ARE CREDENTIAL-FREE AND LIVE IN THEIR OWN JOB, exactly as in +# release-cli.yml. The point is not saving CI minutes: it is that the +# OIDC-capable `publish` job is never INSTANTIATED for a run that will not +# publish. Keep the two jobs in this file together for the same reason +# release-cli.yml keeps its `check` and `publish` together — a later edit that +# reads only the publish half would see `id-token: write` with no visible reason +# for the `needs:` and drop it. +# +# NO CONCURRENCY GROUP, deliberately (design D6). Gate 2 is per-SHA, and two +# runs for one SHA cannot both publish; the `npm view` guard immediately before +# `npm publish` closes the remaining window between the gate job's answer and +# the publish, the same way release-cli.yml and release-vale.yml do. +# +# PUBLISHING IDENTITY: npm trusted publishing, a short-lived OIDC-minted token +# bound to the `npm-autopublish` environment, with no stored NPM_TOKEN anywhere. +# `npm-autopublish` has no required reviewer — an unattended flow cannot use +# npm-production, which does — and earns that with a deployment branch policy +# restricting it to main. Approval gates what users get by default; code review +# gates what gets published, and for a nightly the code review already happened +# on the merge (design D7). The trusted-publisher binding is registered PER +# PACKAGE on npmjs.com and there is nothing to bind until the name exists, so +# the FIRST publish of @taskless/cli-nightly is a deliberate one-time manual +# step by a maintainer. There is no fallback token path here on purpose. +# +# BOOTSTRAPPING THE PACKAGE NAME, once: +# +# pnpm exec changeset status --output=nightly-status.json +# node .github/scripts/nightly-pack.cjs \ +# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)" +# npm publish --access public --tag latest .nightly-dist/*.tgz +# +# Publish the packed tarball rather than the package directory: a bare +# `npm publish` in packages/cli would burn the name on @taskless/cli's committed +# name and version. Provenance is omitted from the manual step (it needs a CI +# OIDC identity); register the trusted publisher afterwards and every later +# publish gets it. +# +# Action refs are pinned to commit SHAs; the trailing comment records the tag. + +name: Release CLI Nightly + +on: + push: + branches: [main] + +# No workflow-wide grants; each job asks for exactly what it needs. +permissions: {} + +jobs: + gate: + name: "nightly gate" + runs-on: ubuntu-latest + permissions: + contents: read # checkout only + outputs: + should_publish: ${{ steps.gate.outputs.should_publish }} + short_sha: ${{ steps.gate.outputs.short_sha }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # nothing here writes to git + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # No dependency install in this job, by design — gate 1 is the reason the + # workflow can decide before installing anything, and nightly-pack.cjs is + # zero-dependency CommonJS, so gate 2 can reuse its tested matcher without + # one either. + - id: gate + run: | + set -euo pipefail + + # GATE 1 — are any changesets pending? + # + # NOT a bare directory listing: .changeset/ permanently holds README.md + # and config.json, so it is never empty and `ls | wc -l` would report + # "pending" on every push forever. The question is whether any + # .changeset/*.md OTHER than the template README exists. + # require-changeset.yml counts them with exactly this rule; keep the + # two in agreement rather than inventing a second definition of "a + # changeset." + pending=$(find .changeset -maxdepth 1 -name '*.md' | grep -viE '/README\.md$' || true) + if [ -z "$pending" ]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "No pending changesets — main is at its released version, so there is no nightly to build." + exit 0 + fi + echo "Pending changeset(s):" + echo "$pending" | sed 's/^/ - /' + + # A FIXED abbreviation length. `git rev-parse --short` scales the + # length with the size of the repository, so an unpinned length would + # eventually produce an 8-character sha that no longer matches the + # 7-character suffix of every nightly published before it — gate 2 + # would stop deduping, silently, at a moment unrelated to any change + # here. + short_sha=$(git rev-parse --short=7 HEAD) + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + + # GATE 2 — does this commit already have a nightly? + # + # A list-and-filter rather than a point lookup: the timestamp precedes + # the sha in the version, so the exact string is not known until the + # build computes it. + # + # The `|| versions='[]'` REPLACES the captured output rather than + # appending to it, and that is the whole point: `npm view --json` on a + # 404 prints an error OBJECT to stdout and exits non-zero, so the + # obvious `$(npm view … || echo '[]')` yields that object followed by + # `[]` — measured — which is not parseable JSON. (hasNightlyForSha + # also treats a non-array, non-string value as no versions, so the + # error object alone would be handled; this keeps the JSON valid.) + versions=$(npm view @taskless/cli-nightly versions --json 2>/dev/null) || versions='[]' + if SHORT_SHA="$short_sha" node -e ' + const { hasNightlyForSha } = require("./.github/scripts/nightly-pack.cjs"); + let raw = ""; + process.stdin + .on("data", (chunk) => { raw += chunk; }) + .on("end", () => { + const versions = JSON.parse(raw.trim() || "[]"); + process.exit(hasNightlyForSha(versions, process.env.SHORT_SHA) ? 0 : 1); + }); + ' <<< "$versions"; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "A nightly ending in x${short_sha} is already published — nothing to do." + else + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "Will build a nightly for ${short_sha}." + fi + + publish: + name: Publish the nightly to npm + # Gate on the credential-free job: this job — and therefore the OIDC + # identity and the npm-autopublish environment — only exists for a run that + # will actually publish. + needs: gate + if: needs.gate.outputs.should_publish == 'true' + runs-on: ubuntu-latest + # The scoping and audit boundary for the nightly, and where the npm trusted + # publisher for @taskless/cli-nightly is bound. No required reviewer, by + # design; its deployment branch policy restricts it to main. + environment: npm-autopublish + permissions: + contents: read # checkout only + id-token: write # OIDC → short-lived npm auth + build provenance + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # publish authenticates via OIDC, not git creds + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + # --ignore-scripts: no dependency lifecycle code runs while an OIDC + # identity is available in this job. + - run: pnpm install --frozen-lockfile --ignore-scripts + + # OIDC trusted publishing and provenance need npm >= 11.5.1. Pinned to the + # same version release-cli.yml and release-vale.yml pin, not @latest, so + # publish behavior cannot change unreviewed; bump all three together. + - run: npm install -g npm@12.0.1 --ignore-scripts + + - run: pnpm --filter @taskless/cli build + + # The JSON FILE is authoritative, not stdout: `changeset status` also + # emits one workspace-version warning per @taskless/vale-* package per + # changeset (18 lines in the current tree). The path must be + # REPO-RELATIVE — `--output` resolves against the working directory with + # no special case for a leading `/`, so `--output=/tmp/status.json` means + # `/tmp/status.json` and fails with ENOENT from the repo root. + - run: pnpm exec changeset status --output=nightly-status.json + + # Rewrites packages/cli/package.json to the nightly name and the stamped + # version, packs, and restores the manifest. The sha comes from the gate + # job so both gates and the stamp describe the same commit at the same + # abbreviation length, and it is routed through `env:` rather than + # interpolated into the shell body. + - id: pack + env: + SHORT_SHA: ${{ needs.gate.outputs.short_sha }} + run: | + node .github/scripts/nightly-pack.cjs \ + --status nightly-status.json \ + --sha "$SHORT_SHA" \ + --out .nightly-dist + + # `--tag latest` is required, not cosmetic: every version here is a semver + # prerelease by construction, and npm will not move the default tag onto a + # prerelease unless told to. Without it the package would have versions + # and no default, and `npm i @taskless/cli-nightly` would resolve nothing. + # These are not preview builds — they are the only builds of this package. + # + # The `npm view` guard immediately before the publish is what makes the + # missing concurrency group safe. Gate 2 runs in a SEPARATE JOB, so + # between its answer and this line another run can publish for the same + # commit; asking again at the moment it matters is idempotent rather than + # merely ordered. release-cli.yml and release-vale.yml guard the same way. + - name: Publish (skipping a version already on npm) + env: + NIGHTLY_VERSION: ${{ steps.pack.outputs.version }} + NIGHTLY_TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + set -euo pipefail + if npm view "@taskless/cli-nightly@${NIGHTLY_VERSION}" version >/dev/null 2>&1; then + echo "@taskless/cli-nightly@${NIGHTLY_VERSION} is already published — nothing to do." + else + npm publish --provenance --access public --tag latest "$NIGHTLY_TARBALL" + fi diff --git a/.gitignore b/.gitignore index d502dd69..435e22e5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,12 @@ node_modules/ # Vale platform-package tarballs (npm pack output from vale-prepare.cjs) .vale-dist/ +# Nightly CLI tarball + the changeset status file it reads (nightly-pack.cjs). +# `changeset status --output=` resolves against the working directory, so this +# lands at the repo root by design; an absolute path would not work. +.nightly-dist/ +nightly-status.json + # Misc tmp # Auto-generated by dotagents — do not commit these files. diff --git a/README.md b/README.md index 42de46fd..11e1df90 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,31 @@ git push origin vx.y.z Tagging started at `v0.9.0`; earlier releases were not tagged. +### Nightly builds + +Work that has merged to `main` but is not yet released is published as +**`@taskless/cli-nightly`**, so unreleased behavior can be installed and +exercised without waiting for a release: + +```bash +npx @taskless/cli-nightly@latest --version # or: npm i -g @taskless/cli-nightly +``` + +- **The executable is `taskless`**, the same as the release. Every documented + invocation, skill, and recipe works unchanged against a nightly. +- **Because the binary name is the same, a nightly and `@taskless/cli` collide + when both are installed globally.** That is not a supported configuration: a + nightly is a drop-in for the release it anticipates, not a companion to it. + Use one or the other globally, or install the nightly into a project. +- Versions look like `0.11.0-20260818123456x05b3c88` — the release the nightly + anticipates, the UTC build time, and the commit it was built from. Every one + of them is a prerelease, and the newest always carries the `latest` tag, so + installing with no version gives you the most recent nightly. +- A nightly is published on each push to `main` that has changesets pending, and + is the same build as the release it anticipates, differing only in package + name and version. When the Version Packages PR merges, the changesets are + consumed and the real `@taskless/cli` release publishes instead. + ### Adding a new topic recipe In v0.7+, new agent-facing instructions are added as **recipes**, not skills. To add a recipe: diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index dfecb88b..993dd998 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -52,7 +52,12 @@ Reads as: _the future `0.11.0`, built at that time, from that commit._ Both halv - **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. +**The `x` is load-bearing, not decoration**, and it defends against two different simplifications — measured against the semver grammar while building this, because the first version of this paragraph conflated them: + +- **`.`** — a dot starts a _new_ prerelease identifier, so an all-digit short SHA beginning with `0` becomes a numeric identifier with a leading zero, which semver forbids outright. Invalid for roughly one commit in sixteen: intermittent, the worst failure cadence available. +- **``** — a bare concatenation is still _valid_ (the timestamp's leading digit is never `0`), but for an all-digit SHA it is a single 21-digit numeric identifier. Semver compares numeric identifiers numerically, and 21 digits is past what a double represents exactly, so chronological ordering — the entire reason the timestamp leads — stops being reliable without anything failing. + +The `x` makes the identifier alphanumeric, so the numeric rule never applies and comparison stays lexical for every SHA. **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`. @@ -70,9 +75,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 — 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. +**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. It needs no tooling, 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 is a file listing but **not a bare directory listing**, and the difference is the whole gate. `.changeset/` permanently holds `README.md` and `config.json` — `changesets init` writes both and nothing removes them — so the directory is never empty and `ls | wc -l` would report "pending" on every push forever, including the one case this gate exists to handle: the Version Packages merge. The question is whether any `.changeset/*.md` other than the template `README.md` exists — the rule `require-changeset.yml` already uses (`grep -viE '/README\.md$'`). Reuse it rather than minting a second definition of "a changeset." 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. diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index e91d2d13..d721f36e 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -33,16 +33,16 @@ 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). **Select the entry from `releases` by `name === "@taskless/cli"`, never `[0]`** — the file is an object whose `releases` array lists 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 **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 -- [ ] 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 +- [x] 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) +- [x] 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 from `releases` by `name === "@taskless/cli"`, never `[0]`** — the file is an object whose `releases` array lists 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 +- [x] 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). **Measured while building it:** the leading-zero rule bites only if the separator becomes a `.` — a bare concatenation stays _valid_ (the timestamp's leading digit is never `0`) but forms a 21-digit numeric identifier past exact double precision, so it breaks ordering instead of validity. The test covers both alternatives against the semver grammar rather than only the dotted one +- [x] 4.4 Create `.github/workflows/release-cli-nightly.yml` triggered on push to `main`, with gate 1 running before any dependency install and gate 2 as `npm view @taskless/cli-nightly versions --json` filtered for a version ending in `x` (D4). **Gate 1 is not a bare directory listing:** `.changeset/` permanently holds `README.md` and `config.json`, so it is never empty; the question is whether any `.changeset/*.md` other than `README.md` exists, which is exactly how `require-changeset.yml` counts them (`grep -viE '/README\.md$'`) — reuse that rule rather than inventing a second one for the same question. **Also measured:** `npm view --json` on a 404 prints an error object to _stdout_ and exits non-zero, so `$(npm view … || echo '[]')` yields unparseable JSON — the fallback must replace the capture, not append to it +- [x] 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 +- [x] 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) +- [x] 4.7 Pass no untrusted text through `${{ }}` into any `run:` body; route computed values through `env:`, following `release-vale.yml`'s rule +- [x] 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 +- [x] 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 +- [x] 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 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. PR 3 — move Vale to `npm-autopublish` (depends on a proven nightly publish, group 4) From b537e2757f1ba41bde87ca2880d555cdd23c7f0a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 19 Aug 2026 23:14:12 -0700 Subject: [PATCH 2/3] feat(ci): ship the nightly its own README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm always includes README.md in a tarball regardless of `files`, so the nightly was publishing @taskless/cli's documentation under a different package name — install instructions for a package the reader did not install, with nothing saying so. Someone arriving from a search would follow them and never learn this is a prerelease of something else. The pack swap now covers the README the same way it covers the manifest: written, packed, restored in `finally`, so the committed file is never left rewritten. Deliberately minimal. It names the package, links to the real one for documentation and support, explains what the version string encodes, and carries the one warning a reader can act on destructively — that the release and the nightly collide on the `taskless` executable and installing both globally is unsupported. It does not restate anything from the CLI's README, because a copy would have to be kept in sync with a file it was copied from. --- .github/scripts/nightly-pack.cjs | 41 +++++++++++++++++++++++++++ .github/scripts/nightly-pack.test.cjs | 15 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/.github/scripts/nightly-pack.cjs b/.github/scripts/nightly-pack.cjs index 7c0a4604..dba169ec 100644 --- a/.github/scripts/nightly-pack.cjs +++ b/.github/scripts/nightly-pack.cjs @@ -179,6 +179,41 @@ function applyNightlyIdentity(packageJson, version) { * right after its bootstrap publish. Normalizing both is the difference between * a working gate and one that skips every build for a day. */ +/** + * The README the nightly ships, replacing the CLI's own. + * + * npm always includes README.md in a tarball regardless of `files`, so without + * this the nightly's package page shows @taskless/cli's documentation — + * install instructions for a different package, under a name that is not the + * one being read about. Someone landing there from a search would follow them + * and never learn this is a prerelease of something else. + * + * Deliberately minimal: it says what the package is, points at the real one, + * and does not duplicate documentation that would then need to stay in sync + * with a file it was copied from. + */ +function buildNightlyReadme(version) { + return [ + `# ${NIGHTLY_PACKAGE}`, + "", + `Nightly build of [\`${SOURCE_PACKAGE}\`](https://www.npmjs.com/package/${SOURCE_PACKAGE}) — the same source and the same build, published from an unreleased commit on \`main\`.`, + "", + "**For documentation, installation, and support, see " + + `[\`${SOURCE_PACKAGE}\`](https://www.npmjs.com/package/${SOURCE_PACKAGE}).**`, + "", + "## What this is", + "", + `This build is \`${version}\`. The version reads as \`-x\`, so it names the release it anticipates, when it was built, and the commit it came from.`, + "", + `It installs the same \`taskless\` executable as the release. **Do not install both globally** — they collide on that name, and that configuration is not supported.`, + "", + "Nightlies exist to exercise merged-but-unreleased work. They are not release candidates and carry no stability guarantee.", + "", + `Source: https://github.com/taskless/cli`, + "", + ].join("\n"); +} + function hasNightlyForSha(versions, shortSha) { const sha = String(shortSha ?? "").toLowerCase(); if (!SHORT_SHA_PATTERN.test(sha)) { @@ -257,7 +292,11 @@ function main() { // rewrite has to be real — but it lives only for the length of the pack, and // the restore is in `finally` so a failed pack does not strand a rewritten // manifest in the working tree. + const readmePath = join(packageDirectory, "README.md"); + const committedReadme = readFileSync(readmePath, "utf8"); + writeFileSync(packageJsonPath, `${JSON.stringify(nightly, null, 2)}\n`); + writeFileSync(readmePath, buildNightlyReadme(version)); let packed; try { packed = spawnSync( @@ -267,6 +306,7 @@ function main() { ); } finally { writeFileSync(packageJsonPath, committed); + writeFileSync(readmePath, committedReadme); } if (packed.error) { @@ -294,6 +334,7 @@ module.exports = { applyNightlyIdentity, buildNightlyVersion, formatStampTimestamp, + buildNightlyReadme, hasNightlyForSha, isValidVersion, selectProposedVersion, diff --git a/.github/scripts/nightly-pack.test.cjs b/.github/scripts/nightly-pack.test.cjs index e005db00..12c423ee 100644 --- a/.github/scripts/nightly-pack.test.cjs +++ b/.github/scripts/nightly-pack.test.cjs @@ -15,6 +15,7 @@ const { hasNightlyForSha, isValidVersion, selectProposedVersion, + buildNightlyReadme, } = require("./nightly-pack.cjs"); /** The CLI manifest as committed, so these tests fail if it drifts out of shape. */ @@ -206,3 +207,17 @@ test("hasNightlyForSha matches on the trailing x", () => { false ); }); + +test("the nightly ships its own README, not the CLI's", () => { + const version = "0.11.0-20260818123456x05b3c88"; + const readme = buildNightlyReadme(version); + + // It has to name itself, or the package page reads as documentation for a + // package the reader did not install. + assert.match(readme, /^# @taskless\/cli-nightly/); + // And it has to point somewhere useful rather than restating the docs. + assert.match(readme, /npmjs\.com\/package\/@taskless\/cli/); + assert.ok(readme.includes(version), "names the build it describes"); + // The collision is the one thing a reader can get wrong destructively. + assert.match(readme, /[Dd]o not install both globally/); +}); From 21c5b819cf741eb17c9ff5694a88913a8e95c001 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 20 Aug 2026 14:20:34 -0700 Subject: [PATCH 3/3] feat(ci): point a nightly's own skills at the nightly package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nightly shipped @taskless/cli's skill and recipe content verbatim, so an agent following it ran `npx @taskless/cli` — the released CLI. Someone installs a nightly to exercise unreleased behavior and their agent silently uses the released binary. Nothing errors; the instructions are simply for a different package. Adds a `nightly` build target beside `dev` and `self`, rewriting the baked invocation to `npx @taskless/cli-nightly@` through the same `__TASKLESS_CLI__` define the other targets already use. Target resolution moves to `scripts/build-target.ts` so it can be unit-tested over an explicit environment rather than this process. The version is computed ONCE and shared. `nightly-pack.cjs --print-version` stamps it; the build reads TASKLESS_NIGHTLY_VERSION and the pack takes `--version`, which is the only version input pack mode accepts — it rejects `--status`/`--sha` so it cannot derive a second one. Two `new Date()` calls a build apart would ship instructions naming a version that was never published, so this is enforced rather than observed. A missing or malformed version fails the build; falling back to `npx @taskless/cli` would silently reintroduce the bug being fixed. `build:nightly` emits to `dist`, unlike `dev`/`self`, because `files: ["dist"]` is what npm packs — so it overwrites a local prod build, which the comment says. Also closes a fail-open in gate 2, found in review on #122. Any non-zero exit from the version query landed in the "no nightly found" branch, so unparseable output meant publish. Since the version carries a timestamp, a re-run after a parse failure mints a different version for the same commit and publishes it — two nightlies for one SHA, no error anywhere, by the gate whose only job is suppression. Now three-way: present means skip, parsed-and-absent means build, unparseable means fail. The 404 carve-out stays, because on bootstrap day the package genuinely does not exist. The bootstrap block in the header gains the build step it was missing; following it literally packed a tarball with no dist/. --- .changeset/nightly-cli-builds.md | 15 ++ .github/scripts/nightly-pack.cjs | 161 +++++++++++++++--- .github/scripts/nightly-pack.test.cjs | 150 ++++++++++++++++ .github/workflows/release-cli-nightly.yml | 134 +++++++++++---- openspec/changes/nightly-cli-builds/design.md | 14 ++ .../specs/cli-nightly-builds/spec.md | 36 ++++ openspec/changes/nightly-cli-builds/tasks.md | 2 + package.json | 1 + packages/cli/package.json | 1 + packages/cli/scripts/build-target.ts | 155 +++++++++++++++++ packages/cli/src/util/invocation.ts | 15 +- packages/cli/test/build-target.test.ts | 94 ++++++++++ packages/cli/vite.config.ts | 77 +++------ 13 files changed, 746 insertions(+), 109 deletions(-) create mode 100644 packages/cli/scripts/build-target.ts create mode 100644 packages/cli/test/build-target.test.ts diff --git a/.changeset/nightly-cli-builds.md b/.changeset/nightly-cli-builds.md index 87170503..9e16c805 100644 --- a/.changeset/nightly-cli-builds.md +++ b/.changeset/nightly-cli-builds.md @@ -39,3 +39,18 @@ first (before any install), then whether the commit already has a nightly — so the publishing job is never instantiated on an ordinary push, and the merge of a Version Packages PR publishes the real release and no nightly with no rule special-casing it. + +A nightly now ships instructions for itself. The skills, commands, and recipes +a nightly installs name `npx @taskless/cli-nightly@` — pinned to the +build being installed — instead of `npx @taskless/cli`. Previously a nightly +carried the released CLI's text verbatim, so an agent following it ran the +released binary: no error, just instructions for a different package, on a +build installed precisely to exercise unreleased behavior. The version is +stamped once and passed to both the build and the pack, so the version the +instructions name is always the version on npm, and a nightly build without a +valid version fails rather than falling back. + +The nightly's duplicate-suppression gate also now fails closed. An unreadable +registry response used to read as "this commit has no nightly", and since each +build stamps a fresh timestamp, a re-run after one would have published a +second nightly for the same commit successfully and silently. diff --git a/.github/scripts/nightly-pack.cjs b/.github/scripts/nightly-pack.cjs index dba169ec..9b453cc7 100644 --- a/.github/scripts/nightly-pack.cjs +++ b/.github/scripts/nightly-pack.cjs @@ -20,18 +20,37 @@ * (the same arrangement as vale-release.cjs), while `main()` runs only when the * file is invoked directly. * - * Usage: - * node .github/scripts/nightly-pack.cjs --status --sha [--out ] + * TWO MODES, AND THE VERSION IS COMPUTED IN EXACTLY ONE OF THEM. * - * --status the JSON file written by `changeset status --output=`. - * MUST be a repo-relative path when produced: `--output` resolves - * against the process working directory with no special case for a - * leading `/`, so `--output=/tmp/status.json` means - * `/tmp/status.json` and fails with ENOENT from the repo root. - * --sha the short commit hash to stamp into the version. - * --out where to write the .tgz (default: .nightly-dist at the repo root). + * node .github/scripts/nightly-pack.cjs --print-version --status --sha + * node .github/scripts/nightly-pack.cjs --version [--out ] * - * Writes `version` and `tarball` to $GITHUB_OUTPUT when it is set. + * --print-version stamp the version from the pending changesets, the current + * UTC time, and the sha; print it and set the `version` + * output. Packs nothing. + * --status the JSON file written by `changeset status --output=`. + * MUST be a repo-relative path when produced: `--output` + * resolves against the process working directory with no + * special case for a leading `/`, so + * `--output=/tmp/status.json` means `/tmp/status.json` + * and fails with ENOENT from the repo root. + * --sha the short commit hash to stamp into the version. + * --version the already-stamped version to pack under. + * --out where to write the .tgz (default: .nightly-dist at the + * repo root). + * + * The split exists because the version has a SECOND consumer: the CLI build + * bakes `npx @taskless/cli-nightly@` into every skill, command, and + * recipe it emits (TASKLESS_NIGHTLY_VERSION, see + * packages/cli/scripts/build-target.ts). A version computed independently in + * each place is computed from a different clock, so the shipped instructions + * would name a version that was never published — an agent sent to a package + * that 404s, or worse, silently to `@taskless/cli`. Pack mode therefore CANNOT + * recompute: it takes `--version` and rejects `--status`/`--sha` outright, + * rather than merely happening not to look at the clock. + * + * Writes `version` (both modes) and `tarball` (pack mode) to $GITHUB_OUTPUT + * when it is set. */ const { @@ -214,6 +233,54 @@ function buildNightlyReadme(version) { ].join("\n"); } +/** + * Turn what `npm view versions --json` actually produced into a list of + * versions — or throw, because gate 2 has no safe default. + * + * THREE OUTCOMES, NOT TWO. The gate's job is suppression, so "I could not tell" + * must not collapse into "nothing published." It would not surface as a failed + * publish either: the version carries a timestamp, so a re-run after a parse + * failure mints a DIFFERENT version for the SAME commit and publishes it + * successfully — two nightlies for one sha, no error anywhere. The blanket + * `|| versions='[]'` this replaces did exactly that for any registry hiccup. + * + * exit 0, JSON array or bare string → those versions. `--json` yields a bare + * STRING for a package with exactly one version, which the nightly package + * is once, right after its bootstrap publish. + * exit != 0 with an E404 error object → an empty list. The package genuinely + * does not exist yet; this is the bootstrap day and the only non-zero exit + * that means "nothing published." + * anything else — unparseable output, an empty body, a different error code, + * an unexpected shape → THROW, and let the caller fail the job. + */ +function parseVersionsResponse(raw, exitStatus) { + const text = String(raw ?? "").trim(); + const status = Number(exitStatus ?? 0); + let parsed; + if (text.length > 0) { + try { + parsed = JSON.parse(text); + } catch { + throw new Error( + `npm view did not return JSON (exit ${status}): ${text.slice(0, 200)}` + ); + } + } + + if (status !== 0) { + if (parsed && parsed.error && parsed.error.code === "E404") return []; + throw new Error( + `npm view failed with exit ${status} and no E404 — refusing to assume this commit has no nightly: ${text.slice(0, 200)}` + ); + } + + if (typeof parsed === "string") return [parsed]; + if (Array.isArray(parsed)) return parsed; + throw new Error( + `npm view returned neither a version list nor a version (exit ${status}): ${text.slice(0, 200)}` + ); +} + function hasNightlyForSha(versions, shortSha) { const sha = String(shortSha ?? "").toLowerCase(); if (!SHORT_SHA_PATTERN.test(sha)) { @@ -238,16 +305,33 @@ function requireValue(argv, index, flag) { return value; } +/** + * Parse argv into one of the two modes, and refuse anything that would let the + * pack recompute a version the build has already been given. + * + * `printVersion: true` → `{ status, sha }`. `printVersion: false` → + * `{ version, out }`. The flags of one mode are an ERROR in the other; a pack + * that quietly ignored `--sha` would be a pack that could be handed a stale + * version and a fresh sha and publish the disagreement. + */ function parseArguments(argv) { - const options = { out: join(REPO_ROOT, ".nightly-dist") }; + const options = { + out: join(REPO_ROOT, ".nightly-dist"), + printVersion: false, + }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; - if (argument === "--status") { + if (argument === "--print-version") { + options.printVersion = true; + } else if (argument === "--status") { index += 1; options.status = resolve(requireValue(argv, index, "--status")); } else if (argument === "--sha") { index += 1; options.sha = requireValue(argv, index, "--sha"); + } else if (argument === "--version") { + index += 1; + options.version = requireValue(argv, index, "--version"); } else if (argument === "--out") { index += 1; options.out = resolve(requireValue(argv, index, "--out")); @@ -255,11 +339,34 @@ function parseArguments(argv) { throw new Error(`unknown argument: ${argument}`); } } - if (!options.status) { - throw new Error("--status is required"); + + if (options.printVersion) { + if (options.version) { + throw new Error("--version is not accepted with --print-version"); + } + if (!options.status) { + throw new Error("--status is required with --print-version"); + } + if (!options.sha) { + throw new Error("--sha is required with --print-version"); + } + return options; } - if (!options.sha) { - throw new Error("--sha is required"); + + if (!options.version) { + throw new Error( + "--version is required; stamp it once with --print-version and pass the same value to the build and to this pack" + ); + } + if (options.status || options.sha) { + throw new Error( + "--status/--sha are only for --print-version; packing uses the version it was given and never recomputes one" + ); + } + if (!isValidVersion(options.version)) { + throw new Error( + `--version is not a valid semantic version: ${JSON.stringify(options.version)}` + ); } return options; } @@ -273,13 +380,21 @@ function setOutput(key, value) { function main() { const options = parseArguments(process.argv.slice(2)); - const status = JSON.parse(readFileSync(options.status, "utf8")); - const version = buildNightlyVersion({ - baseVersion: selectProposedVersion(status), - date: new Date(), - shortSha: options.sha, - }); + if (options.printVersion) { + const status = JSON.parse(readFileSync(options.status, "utf8")); + const version = buildNightlyVersion({ + baseVersion: selectProposedVersion(status), + date: new Date(), + shortSha: options.sha, + }); + // Only the version on stdout, so `$(… --print-version …)` is usable. + console.log(version); + setOutput("version", version); + return; + } + + const version = options.version; const packageDirectory = join(REPO_ROOT, "packages", "cli"); const packageJsonPath = join(packageDirectory, "package.json"); const committed = readFileSync(packageJsonPath, "utf8"); @@ -337,6 +452,8 @@ module.exports = { buildNightlyReadme, hasNightlyForSha, isValidVersion, + parseArguments, + parseVersionsResponse, selectProposedVersion, }; diff --git a/.github/scripts/nightly-pack.test.cjs b/.github/scripts/nightly-pack.test.cjs index 12c423ee..7024a9ce 100644 --- a/.github/scripts/nightly-pack.test.cjs +++ b/.github/scripts/nightly-pack.test.cjs @@ -14,6 +14,8 @@ const { formatStampTimestamp, hasNightlyForSha, isValidVersion, + parseArguments, + parseVersionsResponse, selectProposedVersion, buildNightlyReadme, } = require("./nightly-pack.cjs"); @@ -221,3 +223,151 @@ test("the nightly ships its own README, not the CLI's", () => { // The collision is the one thing a reader can get wrong destructively. assert.match(readme, /[Dd]o not install both globally/); }); + +// The two modes, and the one property that matters between them: the version is +// stamped ONCE (--print-version) and handed to both the CLI build and the pack. +// If the pack could stamp its own, the two would read different clocks, and the +// skills shipped inside the tarball would name a version that was never +// published. +test("packing takes a version and cannot compute one", () => { + const options = parseArguments([ + "--version", + "0.11.0-20260818123456x05b3c88", + "--out", + ".nightly-dist", + ]); + assert.equal(options.printVersion, false); + assert.equal(options.version, "0.11.0-20260818123456x05b3c88"); + + // The inputs a version could be recomputed from are rejected outright, rather + // than accepted-and-ignored. + assert.throws( + () => + parseArguments([ + "--version", + "0.11.0-20260818123456x05b3c88", + "--sha", + "05b3c88", + ]), + /only for --print-version/ + ); + assert.throws( + () => + parseArguments([ + "--version", + "0.11.0-20260818123456x05b3c88", + "--status", + "nightly-status.json", + ]), + /only for --print-version/ + ); + + // And packing without one is an error, never a stamped-on-the-spot fallback. + assert.throws(() => parseArguments(["--out", ".nightly-dist"]), /--version/); + assert.throws( + () => parseArguments(["--version", "not-a-version"]), + /not a valid semantic version/ + ); +}); + +test("--print-version stamps from the status file and the sha", () => { + const options = parseArguments([ + "--print-version", + "--status", + "nightly-status.json", + "--sha", + "05b3c88", + ]); + assert.equal(options.printVersion, true); + assert.equal(options.sha, "05b3c88"); + assert.match(options.status, /nightly-status\.json$/); + + assert.throws( + () => parseArguments(["--print-version", "--sha", "05b3c88"]), + /--status is required/ + ); + assert.throws( + () => parseArguments(["--print-version", "--status", "s.json"]), + /--sha is required/ + ); + assert.throws( + () => + parseArguments([ + "--print-version", + "--status", + "s.json", + "--sha", + "05b3c88", + "--version", + "0.11.0-20260818123456x05b3c88", + ]), + /not accepted with --print-version/ + ); +}); + +// Gate 2 fails CLOSED. The three outcomes are distinct, and "could not tell" +// is not "nothing published" — a re-run after an unreadable response would +// stamp a new timestamp for the same commit and publish a second nightly for +// it, successfully and silently, which is the one thing this gate exists to +// prevent. +test("parseVersionsResponse separates found, not-found, and unreadable", () => { + // exit 0, a list — the ordinary case. + assert.deepEqual( + parseVersionsResponse('["0.11.0-20260818123456x05b3c88"]', 0), + ["0.11.0-20260818123456x05b3c88"] + ); + + // exit 0, a bare STRING — what `--json` yields for a package with exactly one + // version, which this package is right after its bootstrap publish. + assert.deepEqual( + parseVersionsResponse('"0.11.0-20260818123456x05b3c88"', 0), + ["0.11.0-20260818123456x05b3c88"] + ); + + // The one legitimate non-zero exit: the package does not exist yet. npm + // prints this object to STDOUT and exits 1 (measured against a real 404). + assert.deepEqual( + parseVersionsResponse( + JSON.stringify({ + error: { + code: "E404", + summary: + "Not Found - GET https://registry.npmjs.org/@taskless%2fcli-nightly", + }, + }), + 1 + ), + [] + ); + + // Everything else raises rather than reporting an empty list. + assert.throws( + () => parseVersionsResponse("502 Bad Gateway", 0), + /did not return JSON/, + "non-JSON output must not read as no versions" + ); + assert.throws( + () => parseVersionsResponse('["0.11.0-2026', 0), + /did not return JSON/, + "truncated output must not read as no versions" + ); + assert.throws( + () => parseVersionsResponse("", 0), + /neither a version list nor a version/, + "an empty body must not read as no versions" + ); + assert.throws( + () => + parseVersionsResponse( + JSON.stringify({ error: { code: "EAI_AGAIN" } }), + 1 + ), + /no E404/, + "a network failure must not read as no versions" + ); + assert.throws( + () => parseVersionsResponse("", 1), + /no E404/, + "a bare non-zero exit must not read as no versions" + ); +}); diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index 3fb386c4..c4ef6386 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -63,13 +63,33 @@ # the FIRST publish of @taskless/cli-nightly is a deliberate one-time manual # step by a maintainer. There is no fallback token path here on purpose. # -# BOOTSTRAPPING THE PACKAGE NAME, once: +# ONE VERSION, TWO CONSUMERS. The stamped version is not only the published +# version: the CLI build bakes `npx @taskless/cli-nightly@` into every +# skill, command, and recipe the nightly ships, so an agent reading them calls +# the package the user actually installed rather than the released +# `@taskless/cli`. That means the version has to exist BEFORE the build, and the +# build and the pack must use the same one — two `new Date()` calls a build +# apart would ship instructions naming a version that was never published. +# `nightly-pack.cjs --print-version` stamps it once; the build reads it from +# TASKLESS_NIGHTLY_VERSION and the pack takes it as `--version`, which is the +# only version input pack mode accepts (it rejects `--status`/`--sha`, so it +# cannot recompute one). # +# BOOTSTRAPPING THE PACKAGE NAME, once — the same three inputs in the same +# order, including the build, which produces the `dist/` the tarball carries: +# +# pnpm install --frozen-lockfile # pnpm exec changeset status --output=nightly-status.json -# node .github/scripts/nightly-pack.cjs \ -# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)" +# VERSION=$(node .github/scripts/nightly-pack.cjs --print-version \ +# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)") +# TASKLESS_NIGHTLY_VERSION="$VERSION" pnpm --filter @taskless/cli build:nightly +# node .github/scripts/nightly-pack.cjs --version "$VERSION" # npm publish --access public --tag latest .nightly-dist/*.tgz # +# `build:nightly` emits to `packages/cli/dist` — the same directory as an +# ordinary build, because `files: ["dist"]` is what npm packs — so it overwrites +# a local prod build. Run `pnpm --filter @taskless/cli build` afterwards. +# # Publish the packed tarball rather than the package directory: a bare # `npm publish` in packages/cli would burn the name on @taskless/cli's committed # name and version. Provenance is omitted from the manual step (it needs a CI @@ -145,30 +165,59 @@ jobs: # the sha in the version, so the exact string is not known until the # build computes it. # - # The `|| versions='[]'` REPLACES the captured output rather than - # appending to it, and that is the whole point: `npm view --json` on a - # 404 prints an error OBJECT to stdout and exits non-zero, so the - # obvious `$(npm view … || echo '[]')` yields that object followed by - # `[]` — measured — which is not parseable JSON. (hasNightlyForSha - # also treats a non-array, non-string value as no versions, so the - # error object alone would be handled; this keeps the JSON valid.) - versions=$(npm view @taskless/cli-nightly versions --json 2>/dev/null) || versions='[]' - if SHORT_SHA="$short_sha" node -e ' - const { hasNightlyForSha } = require("./.github/scripts/nightly-pack.cjs"); + # THE ANSWER IS THREE-WAY, NOT TWO. "Already built", "not built", and + # "could not tell" are different, and the third must fail the job. + # A gate whose only job is suppression must not fail open: the version + # carries a timestamp, so a re-run after an unreadable registry + # response mints a DIFFERENT version for the SAME commit and publishes + # it successfully — two nightlies for one sha, with no error anywhere. + # An earlier `|| versions='[]'` did exactly that for any hiccup. + # + # The npm exit status is captured and handed to the classifier rather + # than being collapsed here, because ONE non-zero exit is legitimate: + # `npm view --json` on a 404 prints an `{"error":{"code":"E404"}}` + # object to stdout (measured) and exits 1, which on the bootstrap day + # genuinely means "nothing published yet." Every other failure — a + # different error code, truncated output, anything unparseable — + # raises. parseVersionsResponse in nightly-pack.cjs draws that line + # and is unit-tested; this step only routes its exit code. + set +e + versions=$(npm view @taskless/cli-nightly versions --json 2>/dev/null) + npm_status=$? + SHORT_SHA="$short_sha" NPM_STATUS="$npm_status" node -e ' + const { hasNightlyForSha, parseVersionsResponse } = require("./.github/scripts/nightly-pack.cjs"); let raw = ""; process.stdin .on("data", (chunk) => { raw += chunk; }) .on("end", () => { - const versions = JSON.parse(raw.trim() || "[]"); - process.exit(hasNightlyForSha(versions, process.env.SHORT_SHA) ? 0 : 1); + try { + const versions = parseVersionsResponse(raw, process.env.NPM_STATUS); + process.exit(hasNightlyForSha(versions, process.env.SHORT_SHA) ? 0 : 1); + } catch (error) { + console.error(error.message); + // 2, never 1: exit 1 is the "no nightly for this sha" answer, + // and an uncaught throw would be indistinguishable from it. + process.exit(2); + } }); - ' <<< "$versions"; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "A nightly ending in x${short_sha} is already published — nothing to do." - else - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "Will build a nightly for ${short_sha}." - fi + ' <<< "$versions" + gate_status=$? + set -e + + case "$gate_status" in + 0) + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "A nightly ending in x${short_sha} is already published — nothing to do." + ;; + 1) + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "Will build a nightly for ${short_sha}." + ;; + *) + echo "::error::Could not determine whether ${short_sha} already has a nightly; refusing to publish a possible duplicate." + exit 1 + ;; + esac publish: name: Publish the nightly to npm @@ -204,28 +253,53 @@ jobs: # publish behavior cannot change unreviewed; bump all three together. - run: npm install -g npm@12.0.1 --ignore-scripts - - run: pnpm --filter @taskless/cli build - # The JSON FILE is authoritative, not stdout: `changeset status` also # emits one workspace-version warning per @taskless/vale-* package per # changeset (18 lines in the current tree). The path must be # REPO-RELATIVE — `--output` resolves against the working directory with # no special case for a leading `/`, so `--output=/tmp/status.json` means # `/tmp/status.json` and fails with ENOENT from the repo root. + # + # This now runs BEFORE the build, because the build needs the version. - run: pnpm exec changeset status --output=nightly-status.json - # Rewrites packages/cli/package.json to the nightly name and the stamped - # version, packs, and restores the manifest. The sha comes from the gate - # job so both gates and the stamp describe the same commit at the same + # THE VERSION IS STAMPED HERE, ONCE, and every later step is handed it. + # Both the build and the pack consume it; if each computed its own, they + # would read the clock a build apart and the skills inside the tarball + # would name a version that was never published. The sha comes from the + # gate job so the gates and the stamp describe the same commit at the same # abbreviation length, and it is routed through `env:` rather than # interpolated into the shell body. - - id: pack + - id: version env: SHORT_SHA: ${{ needs.gate.outputs.short_sha }} run: | node .github/scripts/nightly-pack.cjs \ + --print-version \ --status nightly-status.json \ - --sha "$SHORT_SHA" \ + --sha "$SHORT_SHA" + + # `build:nightly` bakes `npx @taskless/cli-nightly@` into every + # skill, command, and recipe it emits, so an agent following a nightly's + # instructions calls the package the user installed rather than the + # released @taskless/cli. The target REFUSES to build without a valid + # TASKLESS_NIGHTLY_VERSION — falling back to the released invocation would + # be the silent bug this exists to prevent. Output goes to + # packages/cli/dist, the directory `files: ["dist"]` packs. + - env: + TASKLESS_NIGHTLY_VERSION: ${{ steps.version.outputs.version }} + run: pnpm --filter @taskless/cli build:nightly + + # Rewrites packages/cli/package.json to the nightly name and the stamped + # version, packs, and restores the manifest. Pack mode takes the version + # and cannot derive one: it rejects --status/--sha outright, so it is not + # possible for this tarball's version to differ from the one built above. + - id: pack + env: + NIGHTLY_VERSION: ${{ steps.version.outputs.version }} + run: | + node .github/scripts/nightly-pack.cjs \ + --version "$NIGHTLY_VERSION" \ --out .nightly-dist # `--tag latest` is required, not cosmetic: every version here is a semver @@ -241,7 +315,7 @@ jobs: # merely ordered. release-cli.yml and release-vale.yml guard the same way. - name: Publish (skipping a version already on npm) env: - NIGHTLY_VERSION: ${{ steps.pack.outputs.version }} + NIGHTLY_VERSION: ${{ steps.version.outputs.version }} NIGHTLY_TARBALL: ${{ steps.pack.outputs.tarball }} run: | set -euo pipefail diff --git a/openspec/changes/nightly-cli-builds/design.md b/openspec/changes/nightly-cli-builds/design.md index 993dd998..a6694f40 100644 --- a/openspec/changes/nightly-cli-builds/design.md +++ b/openspec/changes/nightly-cli-builds/design.md @@ -38,6 +38,18 @@ Publishing prereleases into `@taskless/cli` would fill its version list with bui 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. + + **One correction to that, found while implementing it.** "Works unchanged" holds for a `taskless …` invocation, and does _not_ hold for the `npx @taskless/cli …` form the skills, commands, and recipes are written in. Shipped verbatim, a nightly instructs an agent to `npx @taskless/cli` — the **released** CLI. Someone installs a nightly precisely to exercise unreleased behavior, and their agent silently runs the released binary instead. Nothing errors; the instructions are simply for a different package. + + So a nightly is a **build target**, not only a pack-time rename. `packages/cli/vite.config.ts` already rewrites that invocation for the `dev` and `self` targets via the `__TASKLESS_CLI__` define; `nightly` joins them and rewrites it to `npx @taskless/cli-nightly@`, **pinned to the exact version being published** — a floating `@taskless/cli-nightly` would send the agent to whatever nightly is newest, which is not the build whose skills it is reading. + + Three consequences follow, each of which had to be decided rather than inherited: + - **The version is stamped once and passed to both steps.** It is now an input to the build, not only an output of the pack. Computing it in each place means two `new Date()` calls a build apart, so the skills would advertise one version while the tarball carried another — every instruction in the nightly naming a version that does not exist on npm. `nightly-pack.cjs` therefore grew a `--print-version` mode, and its pack mode takes `--version` and **rejects `--status`/`--sha`**: it is not possible for the pack to recompute, rather than merely unlikely to. + - **A missing or malformed version fails the build.** The plausible fallback — `npx @taskless/cli` — is the exact bug this exists to fix, and it fails silently. A build error is the only acceptable behavior. + - **`nightly` emits to `dist`**, unlike `dev`/`self`, because `files: ["dist"]` and `bin: ./dist/index.js` are what npm packs. A local `build:nightly` therefore overwrites a prod build; the build prints that at the call site rather than leaving it to be discovered when `pnpm cli` starts naming a nightly. + + No build notice is prepended for `nightly`. The `dev`/`self` banner exists because their invocation is a filesystem path that may not exist yet; a published, version-pinned package always resolves, and the invocation already reads `@taskless/cli-nightly@`, which says what a banner would — permanently, in the body of every installed skill. + - **`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. @@ -85,6 +97,8 @@ Because gate 1 is a directory check, the workflow never depends on how `changese **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. +**Gate 2 answers three ways, and fails closed.** "Already built", "not built", and "could not tell" are different answers, and the third fails the job. The first implementation collapsed the third into the second — a blanket `|| versions='[]'`, plus an uncaught `JSON.parse` whose non-zero exit landed in the same branch as "no nightly found" — which fails **open** on the one gate whose entire job is suppression. The consequence is not a failed publish: because the version carries a timestamp, a re-run after an unreadable registry response mints a _different_ version for the _same_ commit and publishes it successfully. Two nightlies for one SHA, no error anywhere. The one non-zero exit that legitimately means "nothing published" is a 404 — `npm view --json` prints an `{"error":{"code":"E404"}}` object to stdout and exits 1, which is the bootstrap day — and distinguishing that from a parse failure is the whole of the fix. The classification lives in `parseVersionsResponse` in `nightly-pack.cjs`, unit-tested, and the workflow step only routes its exit code (`0` skip, `1` build, anything else fail). + Only past both gates does the build run, taking `newVersion` from `changeset status` as `n.m.k`. **Reading the bump.** `changeset status --output=` writes: 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 35447a77..2b7a3fd5 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 @@ -22,6 +22,29 @@ The nightly SHALL be built from the same source and the same build as the releas - **THEN** it SHALL provide the `taskless` executable - **AND** it SHALL resolve the same pinned platform dependencies as the corresponding release +### Requirement: A nightly's shipped instructions name the nightly package + +The skills, commands, and recipes a nightly installs SHALL instruct an agent to invoke the nightly package, pinned to the version that shipped them, rather than the released package. A nightly is installed to exercise unreleased behavior; instructions naming the released package would send the agent to a different build with nothing reporting an error. + +The version named in those instructions SHALL be the same version the nightly is published under. It SHALL therefore be determined once and supplied to both the build and the packaging step, and the packaging step SHALL NOT be able to determine it independently — a version determined twice is determined from two different clocks, and the instructions would name a version that was never published. + +When a nightly build is requested without a valid version, the build SHALL fail. It SHALL NOT emit instructions naming the released package. + +#### Scenario: A nightly's recipes name the nightly package + +- **WHEN** an agent requests a recipe from an installed nightly +- **THEN** the rendered recipe SHALL invoke the nightly package at the version that nightly was published under + +#### Scenario: The published version and the instructed version agree + +- **WHEN** a nightly is published +- **THEN** the version its shipped instructions name SHALL be the version it was published under + +#### Scenario: A nightly build without a valid version fails + +- **WHEN** a nightly build is requested and no valid version is supplied +- **THEN** the build SHALL fail with an error naming the missing input + ### 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. @@ -67,6 +90,8 @@ First, whether any release metadata is pending. When none is pending, nothing is 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. +This second gate SHALL distinguish three outcomes: a nightly exists for the commit, no nightly exists for it, and the published versions could not be determined. The third SHALL fail the run rather than being treated as the second. Because each build stamps a fresh timestamp, treating an undeterminable answer as "none exists" publishes a second, differently-versioned nightly for the same commit and reports no error. A response indicating the package does not exist yet SHALL be treated as "no nightly exists", since it is the state before the first publish. + 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 @@ -86,6 +111,17 @@ The proposed `n.m.k` SHALL be read from the release tool's structured output fil - **WHEN** the nightly flow runs again for a commit that already has a published nightly - **THEN** no nightly SHALL be published +#### Scenario: An undeterminable published-version list fails the run + +- **WHEN** the published versions cannot be determined, and the response does not indicate that the package is unpublished +- **THEN** the run SHALL fail +- **AND** no nightly SHALL be published + +#### Scenario: An unpublished package is not a failure + +- **WHEN** the nightly package has never been published +- **THEN** the gate SHALL treat the commit as having no nightly and the run SHALL continue + #### 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 diff --git a/openspec/changes/nightly-cli-builds/tasks.md b/openspec/changes/nightly-cli-builds/tasks.md index d721f36e..dc9fe42b 100644 --- a/openspec/changes/nightly-cli-builds/tasks.md +++ b/openspec/changes/nightly-cli-builds/tasks.md @@ -44,6 +44,8 @@ Delivery shape: **stacked, merging forward**, three PRs (design D9). Group 1 is - [x] 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 - [x] 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 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) +- [x] 4.12 Give the CLI build a `nightly` target so a nightly's shipped skills, commands, and recipes name `npx @taskless/cli-nightly@` rather than `npx @taskless/cli` (D2). Extend `resolveBuildTarget`/`resolveCliInvocation`/`OUT_DIRS`, reading the version from `TASKLESS_NIGHTLY_VERSION`, and **fail the build when it is missing or malformed** — falling back to the released invocation is the silent bug this fixes. `nightly` emits to `dist`, unlike `dev`/`self`, because that is what `files: ["dist"]` packs, so it overwrites a local prod build; say so at the call site. **The version must be stamped exactly once and passed to both the build and the pack** — hence `--print-version`, and a pack mode that takes `--version` and rejects `--status`/`--sha` so it _cannot_ recompute from a second clock +- [x] 4.13 Close the fail-open in gate 2 (Copilot review on PR #122): any unreadable `npm view` response landed in the same branch as "no nightly found", so a re-run after a registry hiccup would stamp a new timestamp for the same commit and publish a duplicate nightly successfully, with no error. Classify three ways — present, absent, unreadable — keeping the E404-on-stdout case as a genuine "nothing published yet", and fail the job on anything else ## 5. PR 3 — move Vale to `npm-autopublish` (depends on a proven nightly publish, group 4) diff --git a/package.json b/package.json index dbd9e4cb..2e953391 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "pnpm build:compile", "build:compile": "turbo run build", "build:dev": "pnpm --filter @taskless/cli build:dev", + "build:nightly": "pnpm --filter @taskless/cli build:nightly", "build:self": "run-s build:self:compile build:self:install", "build:self:compile": "pnpm --filter @taskless/cli build:self", "build:self:install": "node packages/cli/dist-self/index.js init --no-interactive", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6b0d1a5e..e14d94cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "vite build && tsc -p tsconfig.prompts.json", "build:dev": "TASKLESS_BUILD_TARGET=dev vite build", + "build:nightly": "TASKLESS_BUILD_TARGET=nightly vite build && tsc -p tsconfig.prompts.json", "build:self": "TASKLESS_BUILD_TARGET=self vite build", "generate:api": "openapi-typescript https://app.taskless.io/cli/api/__schema -o src/generated/api.d.ts", "generate:ast-grep-schema": "tsx scripts/fetch-ast-grep-schema.ts", diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts new file mode 100644 index 00000000..dc56dced --- /dev/null +++ b/packages/cli/scripts/build-target.ts @@ -0,0 +1,155 @@ +import { resolve } from "node:path"; + +/** + * How a build target decides what CLI invocation is baked into the skill, + * command, and recipe content it emits. + * + * This lives beside `vite.config.ts` rather than inside it so the resolution is + * unit-testable: every function here is pure over an explicit environment, + * which is the only way to assert that a `nightly` build with no version fails + * instead of silently emitting the released invocation. + */ + +/** The npm package a nightly is published under (see `nightly-pack.cjs`). */ +const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; + +/** + * The env var carrying the exact version a nightly build is being made for. + * + * It is REQUIRED for the `nightly` target and is never computed here. The + * version is stamped once — by `nightly-pack.cjs --print-version` — and handed + * to both the build and the pack, because a version computed twice is computed + * from two different clocks: the build would advertise `…x` at one + * timestamp while the published tarball carried another, and every instruction + * in the nightly would name a version that does not exist on npm. + */ +export const NIGHTLY_VERSION_ENV = "TASKLESS_NIGHTLY_VERSION"; + +/** + * Each build target emits to its own directory so prod, dev, and self builds + * never overwrite one another. Keyed by TASKLESS_BUILD_TARGET; anything other + * than a key here is treated as prod. + * + * `nightly` IS THE EXCEPTION: it emits to `dist`, the same directory as prod, + * and a local `build:nightly` therefore OVERWRITES a prod build in place. That + * is not an oversight — the nightly tarball is packed from `packages/cli` with + * `files: ["dist"]` and `bin: ./dist/index.js`, so `dist` is the only directory + * npm would carry. Run `pnpm --filter @taskless/cli build` afterwards to get an + * ordinary `dist` back; `pnpm cli` runs whatever is there. + */ +export const OUT_DIRS = { + prod: "dist", + dev: "dist-dev", + self: "dist-self", + nightly: "dist", +} as const; + +export type BuildTarget = keyof typeof OUT_DIRS; + +/** The official semver 2.0.0 grammar, from semver.org. */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** A build environment: `process.env`, or a literal in a test. */ +export type BuildEnvironment = Record; + +export function resolveBuildTarget(environment: BuildEnvironment): BuildTarget { + const target = environment.TASKLESS_BUILD_TARGET; + return target === "dev" || target === "self" || target === "nightly" + ? target + : "prod"; +} + +export function resolveOutputDirectory(environment: BuildEnvironment): string { + return OUT_DIRS[resolveBuildTarget(environment)]; +} + +/** + * The version a `nightly` build is stamped for, or a thrown error. + * + * THIS MUST NEVER FALL BACK. A nightly whose version is missing or malformed + * has no correct invocation to emit, and the plausible-looking fallback — + * `npx @taskless/cli` — is precisely the bug the nightly target exists to fix: + * an agent sent to the released package after someone installed a nightly to + * exercise unreleased behavior. A build error is loud; a wrong string is not. + */ +export function resolveNightlyVersion(environment: BuildEnvironment): string { + const version = environment[NIGHTLY_VERSION_ENV]; + if (version === undefined || version.length === 0) { + throw new Error( + `TASKLESS_BUILD_TARGET=nightly requires ${NIGHTLY_VERSION_ENV}; ` + + `compute it once with "node .github/scripts/nightly-pack.cjs --print-version …" ` + + `and pass the same value to the build and the pack.` + ); + } + if (!SEMVER_PATTERN.test(version)) { + throw new Error( + `${NIGHTLY_VERSION_ENV} is not a valid semantic version: ${JSON.stringify(version)}` + ); + } + return version; +} + +/** + * The CLI invocation baked into emitted skill/command/recipe content, chosen by + * the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self/ + * build:nightly): + * - prod (default): the published `npx @taskless/cli` + * - dev: an absolute path, for validating this build from another repo + * - self: a repo-root-relative path, for dogfooding inside this repo + * - nightly: `npx @taskless/cli-nightly@`, PINNED to the exact + * version being published — a floating `@taskless/cli-nightly` would send + * an agent to whatever nightly is newest, which is not the build whose + * skills it is reading. + * The dev/self paths point at their own output directory (dist-dev/dist-self). + * + * `packageDirectory` is `packages/cli`, used only by the `dev` target, which + * emits an absolute path. + */ +export function resolveCliInvocation( + environment: BuildEnvironment, + packageDirectory: string +): string { + switch (resolveBuildTarget(environment)) { + case "self": { + return `node packages/cli/${OUT_DIRS.self}/index.js`; + } + case "dev": { + return `node ${resolve(packageDirectory, OUT_DIRS.dev, "index.js")}`; + } + case "nightly": { + return `npx ${NIGHTLY_PACKAGE}@${resolveNightlyVersion(environment)}`; + } + default: { + return "npx @taskless/cli"; + } + } +} + +/** + * A one-time banner prepended to canonical skill/command bodies for local + * builds, so an agent that's told to call the local CLI knows how to produce it + * if the build artifact is missing. Empty for prod (no banner is emitted). + * + * ALSO EMPTY FOR `nightly`, deliberately. The banner exists for one reason: + * `dev`/`self` invocations name a filesystem path that may not exist yet, and + * the agent needs to be told how to create it. A nightly's invocation is a + * published, version-pinned package that `npx` resolves on any machine, so + * there is no such failure to pre-empt — and the invocation itself already + * reads `@taskless/cli-nightly@`, which says everything a banner + * would. Adding one would put a permanent build-provenance notice into the body + * of every installed skill, where it is noise on every read. + */ +export function resolveCliNotice( + environment: BuildEnvironment, + packageDirectory: string +): string { + const target = resolveBuildTarget(environment); + if (target !== "self" && target !== "dev") return ""; + const rebuild = target === "self" ? "pnpm build:self" : "pnpm build:dev"; + return ( + `> **Local Taskless build.** The commands below call a locally built CLI ` + + `(\`${resolveCliInvocation(environment, packageDirectory)}\`). If that ` + + `path does not exist yet, run \`${rebuild}\` from the repo root first.` + ); +} diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index 73473b79..54f8a737 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -1,8 +1,11 @@ /** * The published CLI invocation baked into skill, command, and recipe source. - * Build targets other than prod rewrite it to a local path so a locally built + * Build targets other than prod rewrite it: to a local path so a locally built * CLI can be dogfooded in this repo (`build:self`) or validated from another - * repo (`build:dev`). See `vite.config.ts` and the root `package.json` scripts. + * repo (`build:dev`), and to `npx @taskless/cli-nightly@` for a + * nightly, whose shipped instructions must name the package the reader actually + * installed rather than the released one. See `scripts/build-target.ts`, + * `vite.config.ts`, and the root `package.json` scripts. */ const PROD_INVOCATION = "npx @taskless/cli"; @@ -11,9 +14,11 @@ const PROD_INVOCATION = "npx @taskless/cli"; * invocation (`__TASKLESS_CLI__`). * * A no-op for prod builds, where the define equals {@link PROD_INVOCATION}, so - * emitted content stays byte-identical to source. For `dev`/`self` builds it - * swaps both the bare form and the `@latest`-pinned form (the version-pinned - * form first, so the bare replacement can't leave a dangling `@latest`). + * emitted content stays byte-identical to source. For `dev`/`self`/`nightly` + * builds it swaps both the bare form and the `@latest`-pinned form (the + * version-pinned form first, so the bare replacement can't leave a dangling + * `@latest` — which for a nightly would read + * `npx @taskless/cli-nightly@@latest`). */ export function applyCliInvocation(content: string): string { if (__TASKLESS_CLI__ === PROD_INVOCATION) return content; diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts new file mode 100644 index 00000000..5886eeef --- /dev/null +++ b/packages/cli/test/build-target.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + NIGHTLY_VERSION_ENV, + OUT_DIRS, + resolveBuildTarget, + resolveCliInvocation, + resolveCliNotice, + resolveOutputDirectory, +} from "../scripts/build-target"; + +/** Stand-in for `packages/cli`; only the `dev` target reads it. */ +const PACKAGE_DIR = "/repo/packages/cli"; + +const NIGHTLY_VERSION = "0.11.0-20260818123456x05b3c88"; + +const nightlyEnvironment = { + TASKLESS_BUILD_TARGET: "nightly", + [NIGHTLY_VERSION_ENV]: NIGHTLY_VERSION, +}; + +describe("build target resolution", () => { + it("treats an unset or unknown target as prod", () => { + expect(resolveBuildTarget({})).toBe("prod"); + expect(resolveBuildTarget({ TASKLESS_BUILD_TARGET: "nightlies" })).toBe( + "prod" + ); + expect(resolveCliInvocation({}, PACKAGE_DIR)).toBe("npx @taskless/cli"); + expect(resolveCliNotice({}, PACKAGE_DIR)).toBe(""); + }); + + it("keeps the local targets pointed at their own output directories", () => { + const self = { TASKLESS_BUILD_TARGET: "self" }; + expect(resolveCliInvocation(self, PACKAGE_DIR)).toBe( + `node packages/cli/${OUT_DIRS.self}/index.js` + ); + expect(resolveCliNotice(self, PACKAGE_DIR)).toContain("pnpm build:self"); + + const developmentTarget = { TASKLESS_BUILD_TARGET: "dev" }; + expect(resolveCliInvocation(developmentTarget, PACKAGE_DIR)).toBe( + `node ${PACKAGE_DIR}/${OUT_DIRS.dev}/index.js` + ); + expect(resolveCliNotice(developmentTarget, PACKAGE_DIR)).toContain( + "pnpm build:dev" + ); + }); +}); + +describe("the nightly target", () => { + // The whole point of the target: a nightly's skills, commands, and recipes + // must send an agent to the package the reader installed. Without the + // version, `npx @taskless/cli-nightly` would float to whatever nightly is + // newest — a different build from the one whose instructions are being read. + it("names the nightly package pinned to the exact published version", () => { + expect(resolveCliInvocation(nightlyEnvironment, PACKAGE_DIR)).toBe( + `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` + ); + }); + + // `dist`, unlike dev/self — the tarball is packed with `files: ["dist"]` and + // `bin: ./dist/index.js`, so a nightly build overwrites a prod build. + it("emits to dist, the same directory as prod", () => { + expect(resolveOutputDirectory(nightlyEnvironment)).toBe(OUT_DIRS.prod); + expect(OUT_DIRS.nightly).toBe("dist"); + }); + + // dev/self carry a banner because their invocation is a path that may not + // exist. A published, version-pinned package always resolves, and the + // invocation already says which package it is. + it("emits no build notice", () => { + expect(resolveCliNotice(nightlyEnvironment, PACKAGE_DIR)).toBe(""); + }); + + // The failure that matters. Falling back to `npx @taskless/cli` here would + // reintroduce exactly the bug this target fixes, and would do it silently: + // the build would succeed and ship instructions for the released package. + it("fails the build when the version is missing or malformed", () => { + for (const environment of [ + { TASKLESS_BUILD_TARGET: "nightly" }, + { TASKLESS_BUILD_TARGET: "nightly", [NIGHTLY_VERSION_ENV]: "" }, + { TASKLESS_BUILD_TARGET: "nightly", [NIGHTLY_VERSION_ENV]: "0.11" }, + { TASKLESS_BUILD_TARGET: "nightly", [NIGHTLY_VERSION_ENV]: "latest" }, + { + TASKLESS_BUILD_TARGET: "nightly", + [NIGHTLY_VERSION_ENV]: "0.11.0-20260818123456.0123456", + }, + ]) { + expect( + () => resolveCliInvocation(environment, PACKAGE_DIR), + `${JSON.stringify(environment[NIGHTLY_VERSION_ENV])} must fail the build, not fall back` + ).toThrowError(new RegExp(NIGHTLY_VERSION_ENV)); + } + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index decdb460..06b5af1b 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -7,60 +7,33 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { SKILL_CATALOG } from "./src/install/catalog"; +import { + OUT_DIRS, + resolveBuildTarget, + resolveCliInvocation, + resolveCliNotice, + resolveOutputDirectory, +} from "./scripts/build-target"; const pkg = JSON.parse( readFileSync(resolve(import.meta.dirname, "package.json"), "utf8") ) as { version: string }; -// Each build target emits to its own directory so prod, dev, and self builds -// never overwrite one another. Keyed by TASKLESS_BUILD_TARGET; anything other -// than "dev"/"self" is treated as prod. -const OUT_DIRS = { - prod: "dist", - dev: "dist-dev", - self: "dist-self", -} as const; +// Target resolution lives in ./scripts/build-target.ts so it can be unit-tested +// (test/build-target.test.ts) over an explicit environment. Everything below +// binds those pure functions to this process and this package directory. +const buildTarget = resolveBuildTarget(process.env); +const outDir = resolveOutputDirectory(process.env); +const cliInvocation = resolveCliInvocation(process.env, import.meta.dirname); +const cliNotice = resolveCliNotice(process.env, import.meta.dirname); -function resolveBuildTarget(): keyof typeof OUT_DIRS { - const target = process.env.TASKLESS_BUILD_TARGET; - return target === "dev" || target === "self" ? target : "prod"; -} - -function resolveOutDir(): string { - return OUT_DIRS[resolveBuildTarget()]; -} - -// The CLI invocation baked into emitted skill/command/recipe content, chosen -// by the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self): -// - prod (default): the published `npx @taskless/cli` -// - dev: an absolute path, for validating this build from another repo -// - self: a repo-root-relative path, for dogfooding inside this repo -// The dev/self paths point at their own output directory (dist-dev/dist-self). -function resolveCliInvocation(): string { - switch (resolveBuildTarget()) { - case "self": { - return `node packages/cli/${OUT_DIRS.self}/index.js`; - } - case "dev": { - return `node ${resolve(import.meta.dirname, OUT_DIRS.dev, "index.js")}`; - } - default: { - return "npx @taskless/cli"; - } - } -} - -// A one-time banner prepended to canonical skill/command bodies for non-prod -// builds, so an agent that's told to call the local CLI knows how to produce it -// if the build artifact is missing. Empty for prod (no banner is emitted). -function resolveCliNotice(): string { - const target = process.env.TASKLESS_BUILD_TARGET; - if (target !== "self" && target !== "dev") return ""; - const rebuild = target === "self" ? "pnpm build:self" : "pnpm build:dev"; - return ( - `> **Local Taskless build.** The commands below call a locally built CLI ` + - `(\`${resolveCliInvocation()}\`). If that path does not exist yet, run ` + - `\`${rebuild}\` from the repo root first.` +// A nightly emits to `dist` — the same directory as prod, because that is what +// the tarball carries (see OUT_DIRS). Say so at the call site rather than +// leaving it to be discovered when `pnpm cli` starts naming a nightly. +if (buildTarget === "nightly") { + console.warn( + `[taskless] nightly build (${cliInvocation}) — emitting to ${OUT_DIRS.nightly}/, ` + + `overwriting any prod build there. Run "pnpm --filter @taskless/cli build" to restore it.` ); } @@ -160,7 +133,7 @@ function shebang(): Plugin { writeBundle(options, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { if (isBinEntry(chunk)) { - const outPath = resolve(options.dir ?? resolveOutDir(), fileName); + const outPath = resolve(options.dir ?? outDir, fileName); chmodSync(outPath, 0o755); } } @@ -249,8 +222,8 @@ function assertPromptsGraph(): Plugin { export default defineConfig({ define: { __VERSION__: JSON.stringify(pkg.version), - __TASKLESS_CLI__: JSON.stringify(resolveCliInvocation()), - __TASKLESS_CLI_NOTICE__: JSON.stringify(resolveCliNotice()), + __TASKLESS_CLI__: JSON.stringify(cliInvocation), + __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), }, plugins: [ tsconfigPaths(), @@ -259,7 +232,7 @@ export default defineConfig({ assertPromptsGraph(), ], build: { - outDir: resolveOutDir(), + outDir, lib: { entry: { [BIN_ENTRY]: resolve(import.meta.dirname, "src/index.ts"),