Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions .github/scripts/nightly-breadcrumb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <!-- /nightly -->
*
* 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 — `<n.m.k>-<yyyymmddhhmmss>x<sha>` — so this
* file parses them back out rather than being handed a second opinion.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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");
}
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions .github/scripts/nightly-breadcrumb.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
"<!-- /nightly -->",
].join("\n")
);
Expand Down
104 changes: 88 additions & 16 deletions .github/scripts/nightly-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@
*
* TWO MODES, AND THE VERSION IS COMPUTED IN EXACTLY ONE OF THEM.
*
* node .github/scripts/nightly-pack.cjs --print-version --status <file> --sha <short-sha>
* node .github/scripts/nightly-pack.cjs --print-version --status <file> --sha <short-sha> --date <iso-date>
* node .github/scripts/nightly-pack.cjs --version <version> [--out <dir>]
*
* --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=<path>`.
* 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 `<cwd>/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).
Expand All @@ -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
Expand Down Expand Up @@ -222,7 +225,7 @@ function buildNightlyReadme(version) {
"",
"## What this is",
"",
`This build is \`${version}\`. The version reads as \`<next release>-<UTC build time>x<commit>\`, 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 \`<next release>-<UTC commit time>x<commit>\`, 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.`,
"",
Expand All @@ -235,14 +238,16 @@ function buildNightlyReadme(version) {

/**
* Turn what `npm view <pkg> 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
Expand Down Expand Up @@ -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<sha>`.
* 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 <n.m.k>-<yyyymmddhhmmss>x<sha>)`
);
}
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.
*
Expand Down Expand Up @@ -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");
Expand All @@ -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;
}

Expand All @@ -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)) {
Expand All @@ -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.
Expand Down Expand Up @@ -495,6 +566,7 @@ module.exports = {
buildNightlyVersion,
formatStampTimestamp,
buildNightlyReadme,
hasNewerNightly,
hasNightlyForSha,
isValidVersion,
parseArguments,
Expand Down
Loading
Loading