From 38195f2fe8d52d3c8d5479a9ac24043866546b31 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 12:01:36 -0700 Subject: [PATCH] fix(nightly): skip a superseded publish, and stamp the version from the commit A nightly run held in `waiting` on npm-autopublish could be released after a newer commit's nightly published, and its `--tag latest` would roll installers back to older code. The publish step now asks npm, immediately before publishing, whether a nightly with a later timestamp exists, and skips if so. That check is only sound with a commit-date stamp: the old stamp read the clock after the environment wait, so a stalled run would have minted the newest timestamp on npm. `--print-version` now requires `--date` (the committer date); `main` is rebase-merge only, so stamps follow the order of `main`. The breadcrumb job is gated on the new `published` output, and labels the stamp as the commit time. --- .github/scripts/nightly-breadcrumb.cjs | 23 +-- .github/scripts/nightly-breadcrumb.test.cjs | 6 +- .github/scripts/nightly-pack.cjs | 104 +++++++++-- .github/scripts/nightly-pack.test.cjs | 109 ++++++++++- .github/workflows/release-cli-nightly.yml | 129 ++++++++++--- .../proposal.md | 55 ++++++ .../specs/cli-nightly-builds/spec.md | 175 ++++++++++++++++++ .../tasks.md | 24 +++ openspec/specs/cli-nightly-builds/spec.md | 67 ++++++- 9 files changed, 621 insertions(+), 71 deletions(-) create mode 100644 openspec/changes/archive/2026-10-06-nightly-superseded-skip/proposal.md create mode 100644 openspec/changes/archive/2026-10-06-nightly-superseded-skip/specs/cli-nightly-builds/spec.md create mode 100644 openspec/changes/archive/2026-10-06-nightly-superseded-skip/tasks.md diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs index 2e0da668..e546ec51 100644 --- a/.github/scripts/nightly-breadcrumb.cjs +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -15,14 +15,14 @@ * `npx @taskless/cli-nightly@0.11.0-20260818123456x05b3c88` * * **Built from:** 05b3c88 - * **Built at:** 2026-08-18 12:34:56 + * **Committed at:** 2026-08-18 12:34:56 * * * EVERY FACT IN THAT REGION COMES FROM THE STAMPED VERSION, and there is no * second source for any of them. The version is stamped exactly once per run * (nightly-pack.cjs `--print-version`) because the build bakes it into the - * skills the tarball ships; re-reading the clock here would print a "Built at" - * that disagrees with the version printed one line above it, and re-reading + * skills the tarball ships; re-reading the clock or git here would print a + * "Committed at" that could disagree with the version printed one line above it, and re-reading * `git rev-parse` would print a sha the published package does not carry. The * version already encodes both — `-x` — so this * file parses them back out rather than being handed a second opinion. @@ -136,7 +136,7 @@ const STAMP_PATTERN = /-(\d{14})x([0-9a-f]{7,40})$/; * Split a stamped nightly version back into the two facts it encodes. * * Throws rather than degrading: a version this cannot parse is not a nightly - * version, and rendering "Built at: unknown" next to an install line would put + * version, and rendering "Committed at: unknown" next to an install line would put * a plausible-looking breadcrumb on a pull request describing a build nobody * can account for. */ @@ -148,32 +148,32 @@ function parseStampedVersion(version) { ); } const [, stamp, sha] = match; - const builtAt = [ + const committedAt = [ `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}`, `${stamp.slice(8, 10)}:${stamp.slice(10, 12)}:${stamp.slice(12, 14)}`, ].join(" "); // The stamp is UTC by construction (formatStampTimestamp uses toISOString), // so this is a reformat, not a conversion — no clock and no timezone is // consulted anywhere in this file. - const parsed = new Date(`${builtAt.replace(" ", "T")}Z`); + const parsed = new Date(`${committedAt.replace(" ", "T")}Z`); if (Number.isNaN(parsed.getTime())) { throw new Error(`stamped version carries an impossible time: ${version}`); } // `stamp` is the raw 14 digits: fixed-width and UTC, so a lexical compare of // two of them orders the builds (see isNewerBuild). - return { builtAt, shortSha: sha, stamp }; + return { committedAt, shortSha: sha, stamp }; } /** Render the region for `version`, markers included, with no trailing newline. */ function renderRegion(version) { - const { builtAt, shortSha } = parseStampedVersion(version); + const { committedAt, shortSha } = parseStampedVersion(version); return [ REGION_OPEN, "### Build Info", `\`npx ${NIGHTLY_PACKAGE}@${version}\``, "", `**Built from:** ${shortSha}`, - `**Built at:** ${builtAt}`, + `**Committed at:** ${committedAt}`, REGION_CLOSE, ].join("\n"); } @@ -299,8 +299,9 @@ function readRegionVersion(body) { * A concurrency group would be the wrong instrument: it would also cancel * in-progress PUBLISHES, trading a cosmetic staleness for a lost package. The * comparison is on the 14-digit UTC stamp, which is fixed-width, so a lexical - * compare orders builds chronologically for any base version (design D3 chose - * that layout for exactly this). + * compare orders builds for any base version (design D3 chose that layout for + * exactly this). The stamp is the commit date, so that order is the order of + * `main` (#474), not the order the runs happened to finish in. */ function isNewerBuild(candidate, existing) { if (existing === undefined) { diff --git a/.github/scripts/nightly-breadcrumb.test.cjs b/.github/scripts/nightly-breadcrumb.test.cjs index f1957541..cf5bbc68 100644 --- a/.github/scripts/nightly-breadcrumb.test.cjs +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -34,9 +34,9 @@ const STACK_REGION = [ const DESCRIPTION = "# Releases\n\n## @taskless/cli@0.11.0\n\n### Minor Changes"; -test("parseStampedVersion reads the build time and sha back out of the version", () => { +test("parseStampedVersion reads the commit time and sha back out of the version", () => { assert.deepEqual(parseStampedVersion(VERSION), { - builtAt: "2026-08-18 12:34:56", + committedAt: "2026-08-18 12:34:56", shortSha: "05b3c88", // The raw stamp is kept so builds can be ordered without re-parsing. stamp: "20260818123456", @@ -64,7 +64,7 @@ test("renderRegion names the package that is actually published", () => { "`npx @taskless/cli-nightly@0.11.0-20260818123456x05b3c88`", "", "**Built from:** 05b3c88", - "**Built at:** 2026-08-18 12:34:56", + "**Committed at:** 2026-08-18 12:34:56", "", ].join("\n") ); diff --git a/.github/scripts/nightly-pack.cjs b/.github/scripts/nightly-pack.cjs index 754b6953..55d7ff74 100644 --- a/.github/scripts/nightly-pack.cjs +++ b/.github/scripts/nightly-pack.cjs @@ -22,12 +22,12 @@ * * TWO MODES, AND THE VERSION IS COMPUTED IN EXACTLY ONE OF THEM. * - * node .github/scripts/nightly-pack.cjs --print-version --status --sha + * node .github/scripts/nightly-pack.cjs --print-version --status --sha --date * node .github/scripts/nightly-pack.cjs --version [--out ] * - * --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. + * --print-version stamp the version from the pending changesets, the + * commit's date, 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 @@ -35,6 +35,9 @@ * `--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. + * --date the commit's committer date (`git log -1 --format=%cI`), + * which becomes the version's timestamp. NOT the clock: see + * hasNewerNightly for why the stamp must follow `main`. * --version the already-stamped version to pack under. * --out where to write the .tgz (default: .nightly-dist at the * repo root). @@ -46,7 +49,7 @@ * 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, + * recompute: it takes `--version` and rejects `--status`/`--sha`/`--date` outright, * rather than merely happening not to look at the clock. * * Writes `version` (both modes) and `tarball` (pack mode) to $GITHUB_OUTPUT @@ -222,7 +225,7 @@ function buildNightlyReadme(version) { "", "## 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.`, + `This build is \`${version}\`. The version reads as \`-x\`, so it names the release it anticipates, when its commit landed on \`main\`, 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.`, "", @@ -235,14 +238,16 @@ function buildNightlyReadme(version) { /** * Turn what `npm view versions --json` actually produced into a list of - * versions — or throw, because gate 2 has no safe default. + * versions — or throw, because neither caller has a 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. + * THREE OUTCOMES, NOT TWO. Both callers suppress — gate 2 a duplicate, the + * publish step a superseded build (hasNewerNightly) — so "I could not tell" + * must not collapse into "nothing published." Neither would surface as a + * failure: read as empty, the superseded check waves an older commit through + * and it takes `latest`, successfully and with no error anywhere. While the + * stamp was the wall clock, the same fail-open in gate 2 minted a second + * version for one commit; 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 @@ -297,6 +302,61 @@ function hasNightlyForSha(versions, shortSha) { return list.some((version) => String(version).endsWith(`x${sha}`)); } +/** + * The stamped prerelease identifier, anchored to the end: `-<14 digits>x`. + * nightly-breadcrumb.cjs reads the same grammar with the same pattern. + */ +const STAMP_PATTERN = /-(\d{14})x([0-9a-f]{7,40})$/; + +function readStamp(version) { + const match = STAMP_PATTERN.exec(String(version ?? "")); + if (!match) { + throw new Error( + `not a stamped nightly version: ${JSON.stringify(version)} (expected -x)` + ); + } + return match[1]; +} + +/** + * Has a LATER commit already published a nightly? The superseded check (#474), + * run immediately before `npm publish`. + * + * Every publish moves `latest`, so a run that reaches `npm publish` after a + * newer commit's nightly would roll `npm i @taskless/cli-nightly` back to older + * code. That is not hypothetical: a publish job sat 19 minutes in `waiting` on + * the npm-autopublish environment while a newer commit landed behind it. The + * answer is to skip, since the newer nightly already contains this commit. + * + * THIS IS ONLY SOUND BECAUSE THE STAMP IS THE COMMIT DATE. A wall-clock stamp + * is read AFTER the environment wait, so the stalled older run would mint the + * newest timestamp on npm and this check would wave it through — in exactly the + * case it exists for. `main` is rebase-merge only, so a committer date is when + * GitHub landed the commit, and the stamp follows the order of `main`. + * + * Compares the TIMESTAMP ALONE, not the full version. The base `n.m.k` comes + * from the pending changesets and can go DOWN — drop a minor changeset and the + * next commit proposes a patch — so a semver comparison would rank that newer + * commit's nightly below an older one. Strictly greater: an equal stamp is the + * same commit re-run (the exact-version guard after this handles it), or two + * commits landed in one second, where neither is known to supersede the other. + * + * A published version this cannot read throws rather than being skipped: + * everything under @taskless/cli-nightly came from this script, so an + * unreadable entry means the grammar changed, and guessing would let ordering + * fail silently. + */ +function hasNewerNightly(versions, candidate) { + const own = readStamp(candidate); + const list = + typeof versions === "string" + ? [versions] + : Array.isArray(versions) + ? versions + : []; + return list.some((version) => readStamp(version) > own); +} + /** * The tarball `npm pack --json` says it wrote, from either shape npm emits. * @@ -375,6 +435,9 @@ function parseArguments(argv) { } else if (argument === "--sha") { index += 1; options.sha = requireValue(argv, index, "--sha"); + } else if (argument === "--date") { + index += 1; + options.date = requireValue(argv, index, "--date"); } else if (argument === "--version") { index += 1; options.version = requireValue(argv, index, "--version"); @@ -396,6 +459,14 @@ function parseArguments(argv) { if (!options.sha) { throw new Error("--sha is required with --print-version"); } + if (!options.date) { + throw new Error( + "--date is required with --print-version; pass the commit's date, never the clock" + ); + } + // Fail here rather than after a build: an unparseable date is a broken + // input, and buildNightlyVersion would only reject it later. + formatStampTimestamp(options.date); return options; } @@ -404,9 +475,9 @@ function parseArguments(argv) { "--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) { + if (options.status || options.sha || options.date) { throw new Error( - "--status/--sha are only for --print-version; packing uses the version it was given and never recomputes one" + "--status/--sha/--date are only for --print-version; packing uses the version it was given and never recomputes one" ); } if (!isValidVersion(options.version)) { @@ -431,7 +502,7 @@ function main() { const status = JSON.parse(readFileSync(options.status, "utf8")); const version = buildNightlyVersion({ baseVersion: selectProposedVersion(status), - date: new Date(), + date: options.date, shortSha: options.sha, }); // Only the version on stdout, so `$(… --print-version …)` is usable. @@ -495,6 +566,7 @@ module.exports = { buildNightlyVersion, formatStampTimestamp, buildNightlyReadme, + hasNewerNightly, hasNightlyForSha, isValidVersion, parseArguments, diff --git a/.github/scripts/nightly-pack.test.cjs b/.github/scripts/nightly-pack.test.cjs index 5b090275..26ab1926 100644 --- a/.github/scripts/nightly-pack.test.cjs +++ b/.github/scripts/nightly-pack.test.cjs @@ -12,6 +12,7 @@ const { applyNightlyIdentity, buildNightlyVersion, formatStampTimestamp, + hasNewerNightly, hasNightlyForSha, isValidVersion, parseArguments, @@ -252,6 +253,16 @@ test("packing takes a version and cannot compute one", () => { ]), /only for --print-version/ ); + assert.throws( + () => + parseArguments([ + "--version", + "0.11.0-20260818123456x05b3c88", + "--date", + DATE, + ]), + /only for --print-version/ + ); assert.throws( () => parseArguments([ @@ -271,26 +282,57 @@ test("packing takes a version and cannot compute one", () => { ); }); -test("--print-version stamps from the status file and the sha", () => { +test("--print-version stamps from the status file, the sha, and the commit date", () => { const options = parseArguments([ "--print-version", "--status", "nightly-status.json", "--sha", "05b3c88", + "--date", + "2026-08-18T12:34:56+00:00", ]); assert.equal(options.printVersion, true); assert.equal(options.sha, "05b3c88"); + assert.equal(options.date, "2026-08-18T12:34:56+00:00"); assert.match(options.status, /nightly-status\.json$/); assert.throws( - () => parseArguments(["--print-version", "--sha", "05b3c88"]), + () => + parseArguments(["--print-version", "--sha", "05b3c88", "--date", DATE]), /--status is required/ ); assert.throws( - () => parseArguments(["--print-version", "--status", "s.json"]), + () => + parseArguments(["--print-version", "--status", "s.json", "--date", DATE]), /--sha is required/ ); + // No clock fallback: a missing date is an error, because a stamp read after + // the environment wait is exactly what lets a stalled run outrank a newer one. + assert.throws( + () => + parseArguments([ + "--print-version", + "--status", + "s.json", + "--sha", + "05b3c88", + ]), + /--date is required/ + ); + assert.throws( + () => + parseArguments([ + "--print-version", + "--status", + "s.json", + "--sha", + "05b3c88", + "--date", + "yesterday-ish", + ]), + /not a usable date/ + ); assert.throws( () => parseArguments([ @@ -306,11 +348,62 @@ test("--print-version stamps from the status file and the sha", () => { ); }); -// 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. +// `git log --format=%cI` prints the committer's local offset, not UTC. The stamp +// must normalize it, or two commits a few minutes apart from different +// timezones would sort by wall-clock digits instead of by when they landed. +test("formatStampTimestamp normalizes an offset commit date to UTC", () => { + assert.equal( + formatStampTimestamp("2026-10-06T11:23:45-07:00"), + "20261006182345" + ); +}); + +// The superseded check (#474): a publish released late from an environment +// wait must not take `latest` from a newer commit's nightly. +test("hasNewerNightly is true only when a later commit already published", () => { + const older = "0.12.0-20261006182345xe8b2153"; + const newer = "0.12.0-20261006183653xde43cf6"; + assert.equal(hasNewerNightly([older, newer], older), true); + assert.equal(hasNewerNightly([older], newer), false); + assert.equal(hasNewerNightly([], older), false); + // `npm view --json` returns a bare string for a package with one version. + assert.equal(hasNewerNightly(newer, older), true); +}); + +test("hasNewerNightly orders by timestamp, never by base version", () => { + // A changeset was removed, so the newer commit proposes a LOWER base. Semver + // would rank it below the older nightly; the commit order says otherwise. + const olderMinor = "0.12.0-20261006182345xe8b2153"; + const newerPatch = "0.11.1-20261006183653xde43cf6"; + assert.equal(hasNewerNightly([newerPatch], olderMinor), true); + assert.equal(hasNewerNightly([olderMinor], newerPatch), false); +}); + +test("hasNewerNightly treats an equal stamp as not newer", () => { + // A re-run of the same commit — the exact-version guard handles that one. + const version = "0.12.0-20261006182345xe8b2153"; + assert.equal(hasNewerNightly([version], version), false); + // Two commits landed in the same second: neither supersedes the other. + assert.equal( + hasNewerNightly(["0.12.0-20261006182345xabcdef0"], version), + false + ); +}); + +test("hasNewerNightly refuses a version it cannot read", () => { + assert.throws( + () => hasNewerNightly(["0.12.0"], "0.12.0-20261006182345xe8b2153"), + /not a stamped nightly version/ + ); + assert.throws( + () => hasNewerNightly([], "0.12.0"), + /not a stamped nightly version/ + ); +}); + +// Fails CLOSED. The three outcomes are distinct, and "could not tell" is not +// "nothing published" — read as empty, the superseded check would let an older +// commit publish over a newer one and take `latest`, successfully and silently. test("parseVersionsResponse separates found, not-found, and unreadable", () => { // exit 0, a list — the ordinary case. assert.deepEqual( diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index c4f946f7..15afeb0b 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -135,10 +135,30 @@ # 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. +# NO CONCURRENCY GROUP, deliberately (design D6). A group with +# `cancel-in-progress` would also cancel a publish already running. 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. +# +# RUNS FOR DIFFERENT SHAs ARE ORDERED AT PUBLISH TIME INSTEAD (#474). Every +# publish moves `latest`, so an older commit's run that reaches `npm publish` +# after a newer commit's nightly would roll users back to older code. It +# happens: a publish job sat 19 minutes in `waiting` on npm-autopublish (no +# reviewer, nothing to approve, a GitHub-side stall) while a newer commit landed +# and was due to publish behind it. So right before publishing, the run asks npm +# whether a LATER stamp is already there, and if so skips — the newer nightly +# contains this commit. The check cannot live in the gate job: the gate answers +# before the environment wait, which is exactly when a newer commit arrives. +# +# That ordering only holds because the version is stamped from the COMMIT DATE, +# not the clock. A wall-clock stamp is read after the wait, so the stalled run +# would mint the newest timestamp on npm and pass the check it exists to fail. +# `main` is rebase-merge only, so a committer date is when GitHub landed the +# commit. See hasNewerNightly in nightly-pack.cjs. A stalled run still shows as +# `waiting` until GitHub releases it; it then publishes nothing, so nobody has +# to cancel it by hand. # # PUBLISHING IDENTITY: npm trusted publishing, a short-lived OIDC-minted token # bound to the `npm-autopublish` environment, with no stored NPM_TOKEN anywhere. @@ -156,9 +176,9 @@ # 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 +# build and the pack must use the same one — two stamps computed independently +# could disagree and 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). @@ -212,7 +232,8 @@ # pnpm install --frozen-lockfile # pnpm exec changeset status --output=nightly-status.json # VERSION=$(node .github/scripts/nightly-pack.cjs --print-version \ -# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)") +# --status nightly-status.json --sha "$(git rev-parse --short=7 HEAD)" \ +# --date "$(git log -1 --format=%cI 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 @@ -347,11 +368,13 @@ jobs: # # 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. + # A gate whose only job is suppression must not fail open. While the + # stamp was the wall clock, a re-run after an unreadable registry + # response minted a DIFFERENT version for the SAME commit and + # published it — an earlier `|| versions='[]'` did exactly that. The + # stamp is now the commit date, so the exact-version guard before + # `npm publish` would catch that one, but the rule stands: "could not + # tell" is never "nothing there". # # The npm exit status is captured and handed to the classifier rather # than being collapsed here, because ONE non-zero exit is legitimate: @@ -411,8 +434,13 @@ jobs: # (see "ONE VERSION, TWO CONSUMERS" above) and passed on, never recomputed — # a second stamp would read a second clock and name a version that was # never published. + # + # `published` is false when the run was superseded by a newer commit's + # nightly and published nothing, so the breadcrumb never announces a + # version that is not on npm. outputs: version: ${{ steps.version.outputs.version }} + published: ${{ steps.publish.outputs.published }} # 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. @@ -538,20 +566,27 @@ jobs: - run: pnpm exec changeset status --output=nightly-status.json # 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. + # Both the build and the pack consume it; if each computed its own, the + # skills inside the tarball could 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. + # + # The timestamp is the COMMIT DATE, never the clock: this step runs after + # the environment wait, and the publish step's superseded check is only + # sound if a stalled run cannot stamp itself newer than a commit that + # landed behind it (header, "RUNS FOR DIFFERENT SHAs"). `%cI` carries the + # committer's offset; the stamp normalizes it to UTC. - id: version env: SHORT_SHA: ${{ needs.gate.outputs.short_sha }} run: | + set -euo pipefail node .github/scripts/nightly-pack.cjs \ --print-version \ --status nightly-status.json \ - --sha "$SHORT_SHA" + --sha "$SHORT_SHA" \ + --date "$(git log -1 --format=%cI HEAD)" # `build:nightly` bakes `npx @taskless/cli-nightly@` into every # skill, command, and recipe it emits, so an agent following a nightly's @@ -566,7 +601,7 @@ jobs: # 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 + # and cannot derive one: it rejects --status/--sha/--date outright, so it is not # possible for this tarball's version to differ from the one built above. - id: pack env: @@ -587,17 +622,62 @@ jobs: # 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) + # + # The superseded check runs first, here and not earlier, because this is + # the last moment before `latest` moves (header, "RUNS FOR DIFFERENT + # SHAs"). It reads the version list through the same three-way classifier + # as gate 2: an unreadable registry fails the step rather than reading as + # "nothing newer", which would publish over a newer nightly. + - id: publish + name: Publish (skipping a version already on npm, or a superseded one) env: NIGHTLY_VERSION: ${{ steps.version.outputs.version }} NIGHTLY_TARBALL: ${{ steps.pack.outputs.tarball }} run: | set -euo pipefail + + set +e + versions=$(npm view @taskless/cli-nightly versions --json 2>/dev/null) + npm_status=$? + NPM_STATUS="$npm_status" node -e ' + const { hasNewerNightly, parseVersionsResponse } = require("./.github/scripts/nightly-pack.cjs"); + let raw = ""; + process.stdin + .on("data", (chunk) => { raw += chunk; }) + .on("end", () => { + try { + const versions = parseVersionsResponse(raw, process.env.NPM_STATUS); + process.exit(hasNewerNightly(versions, process.env.NIGHTLY_VERSION) ? 0 : 1); + } catch (error) { + console.error(error.message); + // 2, never 1: exit 1 is the "nothing newer" answer, and an + // uncaught throw would be indistinguishable from it. + process.exit(2); + } + }); + ' <<< "$versions" + newer_status=$? + set -e + + case "$newer_status" in + 0) + echo "published=false" >> "$GITHUB_OUTPUT" + echo "A newer commit's nightly is already on npm, and it contains this commit — skipping ${NIGHTLY_VERSION} so \`latest\` does not move backwards." + exit 0 + ;; + 1) ;; + *) + echo "::error::Could not determine whether a newer nightly than ${NIGHTLY_VERSION} is published; refusing to publish over one." + exit 1 + ;; + esac + 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 + echo "published=true" >> "$GITHUB_OUTPUT" # A THIRD JOB, AND IT HOLDS NO CREDENTIAL. Writing a pull request body needs # `pull-requests: write`, and adding that to `publish` would widen what a @@ -611,7 +691,9 @@ jobs: # # `needs: publish` is the "block on a successful publish" requirement: a job # whose dependency was skipped does not run, so a suppressed nightly (either - # gate false) never reaches this, and neither does a failed publish. + # gate false) never reaches this, and neither does a failed publish. A + # SUPERSEDED publish succeeds without publishing, so it is excluded by the + # `published` output instead. # # NO OPEN VERSION PACKAGES PULL REQUEST IS NORMAL AND MUST SUCCEED QUIETLY. # `changeset-release/main` exists only while changesets are pending, and a @@ -623,6 +705,7 @@ jobs: breadcrumb: name: Link the nightly on the Version Packages PR needs: publish + if: needs.publish.outputs.published == 'true' runs-on: ubuntu-latest permissions: contents: read # checkout only — the script lives in this repo diff --git a/openspec/changes/archive/2026-10-06-nightly-superseded-skip/proposal.md b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/proposal.md new file mode 100644 index 00000000..1a11035b --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/proposal.md @@ -0,0 +1,55 @@ +## Why + +Each commit that passes `Validate` on `main` starts its own nightly run, and +nothing orders those runs. On 2026-10-06 the publish job for `e8b2153` sat 19 +minutes in `waiting` on the `npm-autopublish` environment. The environment has +no reviewer and no wait timer, so nobody could approve the run, and it had to be +cancelled by hand (#474). `de43cf6` landed during the wait. Had the stalled run +been released, it would have published with `--tag latest` after the newer +nightly and moved `npm i @taskless/cli-nightly` back to older code. + +The version stamp made this worse. Its timestamp was read from the clock inside +the publish job, after the environment wait, so the stalled run would have +stamped itself the newest nightly on npm. + +## What Changes + +- **`cli-nightly-builds`**: new requirement "A superseded nightly is not + published". Right before `npm publish`, the run checks npm for a nightly with + a later timestamp and skips if one exists. Timestamps are compared alone, + since `n.m.k` can go down between commits. If npm can't be read, the run + fails. +- **`cli-nightly-builds`**: the version requirement now stamps the commit's + committer date (UTC), not the build time. `main` is rebase-merge only, so a + committer date is when GitHub landed the commit, and stamps follow the order + of `main`. +- **`cli-nightly-builds`**: gate 2's rationale no longer depends on a fresh + timestamp per build. The announcement requirement now says "commit time" and + is skipped for a superseded run. +- `nightly-pack.cjs` takes `--date` (required, no clock fallback) and gains + `hasNewerNightly`. The workflow's publish step runs the check and exposes a + `published` output that the breadcrumb job is gated on. The breadcrumb's + label changes from "Built at" to "Committed at". + +Rejected: checking whether the SHA is still the tip of `main`, in the gate +job. The gate answers before the environment wait, which is when the newer +commit arrived, so it would not have caught the incident. It also costs the +older commit its nightly when the newer commit fails `Validate`. A job-level +concurrency group was also rejected: it is unknown whether a job held in +`waiting` keeps the slot, and if it does, one stall would block every later +nightly. + +## Impact + +- `.github/scripts/nightly-pack.cjs`, `.github/scripts/nightly-breadcrumb.cjs`, + their tests, and `.github/workflows/release-cli-nightly.yml`. +- Re-running a commit now produces the same version, which the existing + exact-version guard skips. +- A run held in `waiting` still shows as waiting until GitHub releases it. It + then publishes nothing, so nobody has to cancel it. +- No changeset: CI only, nothing ships in `@taskless/cli`. + +## Delivery shape + +**Single PR.** One workflow, two scripts, their tests, and the spec delta fit in +one small diff, and the archive lands with it. diff --git a/openspec/changes/archive/2026-10-06-nightly-superseded-skip/specs/cli-nightly-builds/spec.md b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/specs/cli-nightly-builds/spec.md new file mode 100644 index 00000000..5f539b1a --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/specs/cli-nightly-builds/spec.md @@ -0,0 +1,175 @@ +## ADDED Requirements + +### Requirement: A superseded nightly is not published + +Every publish assigns the default install tag, so a publish that lands after a newer commit's nightly would move installers back to older code. Immediately before publishing, the run SHALL query the published versions and SHALL NOT publish when any published nightly's timestamp is later than its own; the newer nightly already contains its commit. Such a run SHALL succeed, and SHALL report that it was superseded. + +The check SHALL be made at the moment of publishing and not only when the run decides to build, because a run can be held between the two, and a newer commit landing during that hold is the case the check exists for. + +Timestamps SHALL be compared alone, not whole versions. The anticipated `n.m.k` can decrease between commits when pending release metadata is removed, and a whole-version comparison would rank the newer commit's nightly below an older one. An equal timestamp SHALL NOT count as later. + +The check SHALL distinguish three outcomes: a later nightly exists, none exists, and the published versions could not be determined. The third SHALL fail the run rather than be treated as the second, since treating it as "none exists" publishes over a newer nightly and reports no error. + +The check SHALL NOT be implemented by cancelling runs: a mechanism that cancels a superseded run can also cancel a publish already in progress. + +#### Scenario: A run held past a newer commit's publish publishes nothing + +- **WHEN** a run for an older commit reaches publishing after a newer commit's nightly was published +- **THEN** no nightly SHALL be published for the older commit +- **AND** the default install tag SHALL still name the newer commit's nightly +- **AND** the run SHALL succeed + +#### Scenario: A newer nightly with a lower anticipated version still supersedes + +- **WHEN** the published nightly with the later timestamp anticipates a lower `n.m.k` than the run's own version +- **THEN** the run SHALL NOT publish + +#### Scenario: Nothing newer publishes normally + +- **WHEN** no published nightly has a later timestamp than the run's own version +- **THEN** the nightly SHALL be published + +#### Scenario: An undeterminable list fails the superseded check + +- **WHEN** the published versions cannot be determined at the moment of publishing +- **THEN** the run SHALL fail +- **AND** no nightly SHALL be published + +## MODIFIED Requirements + +### 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 commit's committer date in UTC, and `sha` is the short commit hash. + +The timestamp SHALL be the commit's date and SHALL NOT be read from the clock of the run that builds it. A run can be held between deciding to publish and publishing, and a clock read after the hold would stamp an older commit newer than one that landed during it. The default branch is merged by rebase only, so a commit's committer date is when it landed, and stamps follow the order of the default branch. + +The pending release metadata describes every package it releases, so `n.m.k` SHALL be selected by matching the CLI package's name, and SHALL NOT be taken by position. Position is correct only while the CLI is the sole package under release management, and a version taken from another package publishes successfully while naming a release that was never proposed. + +The timestamp SHALL precede the commit hash, so that lexical comparison of the prerelease identifier orders builds by when their commits landed. The prerelease identifier SHALL contain a non-digit separator between the timestamp and the hash, so that the identifier is never all-digits — an all-digit prerelease identifier is compared numerically and may not begin with a zero, which a hash beginning with `0` would otherwise violate. + +#### Scenario: A nightly version is stamped + +- **WHEN** pending release metadata proposes `0.11.0`, the commit's committer date is `2026-08-18T12:34:56Z`, and the short commit hash is `05b3c88` +- **THEN** the published version SHALL be `0.11.0-20260818123456x05b3c88` + +#### Scenario: A hash beginning with zero produces a valid version + +- **WHEN** the short commit hash consists only of digits and begins with `0` +- **THEN** the published version SHALL still be a valid semantic version + +#### Scenario: The anticipated version is selected by package, not by position + +- **WHEN** the pending release metadata describes more than one package +- **THEN** the nightly version SHALL use the entry naming the CLI package + +#### Scenario: Nightlies sort chronologically + +- **WHEN** two nightlies of the same `n.m.k` are compared +- **THEN** the one whose commit landed later SHALL sort after the one whose commit landed earlier + +#### Scenario: A delayed build keeps its commit's time + +- **WHEN** a run for a commit is held for some time before it builds +- **THEN** its version SHALL carry the commit's date, not the time the build ran + +#### Scenario: An offset commit date is stamped in UTC + +- **WHEN** the commit's committer date is `2026-10-06T11:23:45-07:00` +- **THEN** the version's timestamp SHALL be `20261006182345` + +### Requirement: A nightly build is bounded by pending release metadata and by the commit + +Two gates SHALL decide whether a nightly is built, evaluated in this order. + +First, whether any release metadata is pending. When none is pending, nothing is unreleased, there is no proposed version to name, and no nightly SHALL be built. This gate SHALL be evaluated before any dependency installation, so the common case exits at the cheapest possible point. + +Second, whether this commit already has a nightly. Published versions SHALL be queried and tested for one whose prerelease identifier ends with the short commit hash; if one exists, no nightly SHALL be built. + +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: a gate whose only purpose is suppression SHALL NOT fail open. 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 + +- **WHEN** a commit is pushed to the default branch and no release metadata is pending +- **THEN** no nightly SHALL be published +- **AND** the run SHALL exit before installing dependencies + +#### Scenario: The release merge publishes the release and no nightly + +- **WHEN** the version pull request merges, consuming all pending release metadata and bumping the package version +- **THEN** the real release SHALL be published +- **AND** no nightly SHALL be published, with no rule special-casing that commit + +#### Scenario: A re-run of an already-built commit publishes nothing + +- **WHEN** the nightly flow runs again for a commit that already has a published nightly +- **THEN** no nightly SHALL be published + +#### Scenario: 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 +- **THEN** a nightly SHALL be published for that commit, because the commit — and therefore the artifact it describes — is new + +### Requirement: A published nightly is announced on the pending release pull request + +When a nightly is published, the open pull request that carries the pending release metadata SHALL be annotated with a delimited build-info region naming the published package, the version, the commit it was built from, and the time that commit landed — so the reviewers of that pull request can install and exercise the work it describes. + +Every fact in that region SHALL be derived from the version the publish stamped, not determined independently. The version already encodes the commit's time and the commit, and a second determination could disagree with it. + +Each publish SHALL place the region at the top of the body's description, SHALL replace any region a previous publish left rather than adding to it, and SHALL be restored if a human deletes it. When the body opens with a stack-breadcrumb region, the build-info region SHALL be placed directly below that region rather than above it, which is where the stack-breadcrumb writer's own layout leaves it. It SHALL NOT modify any other managed region on that body. + +Placement is asserted of the write, not of the body for all time: other writers maintain their own regions on the same body and may re-lay it. A publish SHALL return the region to the top of the description rather than leave it where it was found. A publish SHALL NOT overwrite a region naming a build newer than its own. + +The annotation SHALL depend on the publish having succeeded and having published, so a run skipped as superseded announces nothing, and SHALL be performed by a job that holds permission to write pull requests and holds no publishing credential — the ability to publish under the organization's scope and the ability to rewrite pull request text SHALL NOT be held by one job. + +#### Scenario: A publish annotates the open release pull request + +- **WHEN** a nightly is published and a pull request carrying the pending release metadata is open +- **THEN** that pull request's description SHALL begin with a build-info region naming the published package, version, commit, and commit time + +#### Scenario: A stack breadcrumb stays first + +- **WHEN** the body opens with a stack-breadcrumb region +- **THEN** the build-info region SHALL be placed directly below it, and the stack-breadcrumb region SHALL be left unchanged + +#### Scenario: Repeated publishes replace the region + +- **WHEN** a second nightly is published while the same pull request is open +- **THEN** the pull request SHALL carry exactly one build-info region, describing the most recent publish + +#### Scenario: Another writer moves the region + +- **WHEN** another writer re-lays the body and the region no longer sits at the top of the description +- **THEN** the next publish SHALL move it back to the top rather than leave it in place or write a second one + +#### Scenario: An older build does not overwrite a newer one + +- **WHEN** the region on the pull request names a build newer than the one being announced +- **THEN** the body SHALL be left unchanged + +#### Scenario: No open release pull request is not a failure + +- **WHEN** a nightly is published and no pull request carrying pending release metadata is open +- **THEN** the run SHALL succeed and annotate nothing + +#### Scenario: An unanswered query is a failure + +- **WHEN** the query for the pull request fails +- **THEN** the run SHALL fail rather than treat the failure as "no pull request is open" + +#### Scenario: A suppressed nightly annotates nothing + +- **WHEN** a push publishes no nightly, including a run skipped because a newer nightly was already published +- **THEN** no job holding permission to write pull requests SHALL be instantiated for it diff --git a/openspec/changes/archive/2026-10-06-nightly-superseded-skip/tasks.md b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/tasks.md new file mode 100644 index 00000000..187288aa --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-superseded-skip/tasks.md @@ -0,0 +1,24 @@ +## 1. Spec + +- [x] 1.1 Add "A superseded nightly is not published". +- [x] 1.2 Restate the version, gate, and announcement requirements in full as + MODIFIED blocks under their existing titles. +- [x] 1.3 Dry-run `openspec archive` and confirm every prior scenario survives. + +## 2. Implementation + +- [x] 2.1 `nightly-pack.cjs`: a required `--date` on `--print-version`, + rejected in pack mode, and `hasNewerNightly`. +- [x] 2.2 Workflow: stamp from `git log -1 --format=%cI`, check for a + superseded build before publishing, and gate the breadcrumb on + `published`. +- [x] 2.3 Breadcrumb: label the stamp as the commit time. +- [x] 2.4 Update the workflow header (D6) and the comments that assumed a + clock stamp. + +## 3. Tests + +- [x] 3.1 `--date` is required, validated, and rejected in pack mode. +- [x] 3.2 An offset commit date stamps in UTC. +- [x] 3.3 `hasNewerNightly`: newer, older, equal, a lower base version, a bare + string, and an unreadable version. diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index 2d6a778b..d2bb145b 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -53,15 +53,17 @@ When a nightly build is requested without a valid version, the build SHALL fail. ### 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. +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 commit's committer date in UTC, and `sha` is the short commit hash. + +The timestamp SHALL be the commit's date and SHALL NOT be read from the clock of the run that builds it. A run can be held between deciding to publish and publishing, and a clock read after the hold would stamp an older commit newer than one that landed during it. The default branch is merged by rebase only, so a commit's committer date is when it landed, and stamps follow the order of the default branch. The pending release metadata describes every package it releases, so `n.m.k` SHALL be selected by matching the CLI package's name, and SHALL NOT be taken by position. Position is correct only while the CLI is the sole package under release management, and a version taken from another package publishes successfully while naming a release that was never proposed. -The timestamp SHALL precede the commit hash, so that lexical comparison of the prerelease identifier orders builds chronologically. The prerelease identifier SHALL contain a non-digit separator between the timestamp and the hash, so that the identifier is never all-digits — an all-digit prerelease identifier is compared numerically and may not begin with a zero, which a hash beginning with `0` would otherwise violate. +The timestamp SHALL precede the commit hash, so that lexical comparison of the prerelease identifier orders builds by when their commits landed. The prerelease identifier SHALL contain a non-digit separator between the timestamp and the hash, so that the identifier is never all-digits — an all-digit prerelease identifier is compared numerically and may not begin with a zero, which a hash beginning with `0` would otherwise violate. #### Scenario: A nightly version is stamped -- **WHEN** pending release metadata proposes `0.11.0`, the build time is `2026-08-18T12:34:56Z`, and the short commit hash is `05b3c88` +- **WHEN** pending release metadata proposes `0.11.0`, the commit's committer date is `2026-08-18T12:34:56Z`, and the short commit hash is `05b3c88` - **THEN** the published version SHALL be `0.11.0-20260818123456x05b3c88` #### Scenario: A hash beginning with zero produces a valid version @@ -77,7 +79,17 @@ The timestamp SHALL precede the commit hash, so that lexical comparison of the p #### Scenario: Nightlies sort chronologically - **WHEN** two nightlies of the same `n.m.k` are compared -- **THEN** the one built later SHALL sort after the one built earlier +- **THEN** the one whose commit landed later SHALL sort after the one whose commit landed earlier + +#### Scenario: A delayed build keeps its commit's time + +- **WHEN** a run for a commit is held for some time before it builds +- **THEN** its version SHALL carry the commit's date, not the time the build ran + +#### Scenario: An offset commit date is stamped in UTC + +- **WHEN** the commit's committer date is `2026-10-06T11:23:45-07:00` +- **THEN** the version's timestamp SHALL be `20261006182345` ### Requirement: A nightly is installable by default @@ -130,7 +142,7 @@ 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. +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: a gate whose only purpose is suppression SHALL NOT fail open. 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. @@ -193,20 +205,20 @@ Nightly publishing SHALL authenticate with a short-lived credential minted for t ### Requirement: A published nightly is announced on the pending release pull request -When a nightly is published, the open pull request that carries the pending release metadata SHALL be annotated with a delimited build-info region naming the published package, the version, the commit it was built from, and the time it was built — so the reviewers of that pull request can install and exercise the work it describes. +When a nightly is published, the open pull request that carries the pending release metadata SHALL be annotated with a delimited build-info region naming the published package, the version, the commit it was built from, and the time that commit landed — so the reviewers of that pull request can install and exercise the work it describes. -Every fact in that region SHALL be derived from the version the publish stamped, not determined independently. The version already encodes the build time and the commit, and a second determination reads a second clock. +Every fact in that region SHALL be derived from the version the publish stamped, not determined independently. The version already encodes the commit's time and the commit, and a second determination could disagree with it. Each publish SHALL place the region at the top of the body's description, SHALL replace any region a previous publish left rather than adding to it, and SHALL be restored if a human deletes it. When the body opens with a stack-breadcrumb region, the build-info region SHALL be placed directly below that region rather than above it, which is where the stack-breadcrumb writer's own layout leaves it. It SHALL NOT modify any other managed region on that body. Placement is asserted of the write, not of the body for all time: other writers maintain their own regions on the same body and may re-lay it. A publish SHALL return the region to the top of the description rather than leave it where it was found. A publish SHALL NOT overwrite a region naming a build newer than its own. -The annotation SHALL depend on the publish having succeeded, and SHALL be performed by a job that holds permission to write pull requests and holds no publishing credential — the ability to publish under the organization's scope and the ability to rewrite pull request text SHALL NOT be held by one job. +The annotation SHALL depend on the publish having succeeded and having published, so a run skipped as superseded announces nothing, and SHALL be performed by a job that holds permission to write pull requests and holds no publishing credential — the ability to publish under the organization's scope and the ability to rewrite pull request text SHALL NOT be held by one job. #### Scenario: A publish annotates the open release pull request - **WHEN** a nightly is published and a pull request carrying the pending release metadata is open -- **THEN** that pull request's description SHALL begin with a build-info region naming the published package, version, commit, and build time +- **THEN** that pull request's description SHALL begin with a build-info region naming the published package, version, commit, and commit time #### Scenario: A stack breadcrumb stays first @@ -240,7 +252,7 @@ The annotation SHALL depend on the publish having succeeded, and SHALL be perfor #### Scenario: A suppressed nightly annotates nothing -- **WHEN** a push publishes no nightly +- **WHEN** a push publishes no nightly, including a run skipped because a newer nightly was already published - **THEN** no job holding permission to write pull requests SHALL be instantiated for it ### Requirement: A nightly reports the version it is published as @@ -287,3 +299,38 @@ A nightly build SHALL verify, before emitting, that the version it reports and t - **WHEN** a nightly build has run - **THEN** the CLI package manifest in version control SHALL still declare the released version + +### Requirement: A superseded nightly is not published + +Every publish assigns the default install tag, so a publish that lands after a newer commit's nightly would move installers back to older code. Immediately before publishing, the run SHALL query the published versions and SHALL NOT publish when any published nightly's timestamp is later than its own; the newer nightly already contains its commit. Such a run SHALL succeed, and SHALL report that it was superseded. + +The check SHALL be made at the moment of publishing and not only when the run decides to build, because a run can be held between the two, and a newer commit landing during that hold is the case the check exists for. + +Timestamps SHALL be compared alone, not whole versions. The anticipated `n.m.k` can decrease between commits when pending release metadata is removed, and a whole-version comparison would rank the newer commit's nightly below an older one. An equal timestamp SHALL NOT count as later. + +The check SHALL distinguish three outcomes: a later nightly exists, none exists, and the published versions could not be determined. The third SHALL fail the run rather than be treated as the second, since treating it as "none exists" publishes over a newer nightly and reports no error. + +The check SHALL NOT be implemented by cancelling runs: a mechanism that cancels a superseded run can also cancel a publish already in progress. + +#### Scenario: A run held past a newer commit's publish publishes nothing + +- **WHEN** a run for an older commit reaches publishing after a newer commit's nightly was published +- **THEN** no nightly SHALL be published for the older commit +- **AND** the default install tag SHALL still name the newer commit's nightly +- **AND** the run SHALL succeed + +#### Scenario: A newer nightly with a lower anticipated version still supersedes + +- **WHEN** the published nightly with the later timestamp anticipates a lower `n.m.k` than the run's own version +- **THEN** the run SHALL NOT publish + +#### Scenario: Nothing newer publishes normally + +- **WHEN** no published nightly has a later timestamp than the run's own version +- **THEN** the nightly SHALL be published + +#### Scenario: An undeterminable list fails the superseded check + +- **WHEN** the published versions cannot be determined at the moment of publishing +- **THEN** the run SHALL fail +- **AND** no nightly SHALL be published