Skip to content

Commit d983e4b

Browse files
theCodeDriftclaude
andcommitted
fix(cli): refuse to emit a nightly that disagrees with itself about its version
Post-mortem guard for the bug in the parent commit. #148 shipped not because the two version-bearing defines were derived from different sources by design, but because nothing ever compared them: the invocation read TASKLESS_NIGHTLY_VERSION while the version read package.json, so one artifact announced v0.10.2 and sent every agent to v0.11.0-… . Nothing failed. The disagreement was visible only by comparing two outputs of the same build against each other, which is exactly what nobody does. The fix couples them by construction, so the check is tautological today. That is the point — it exists for the refactor that decouples them again: a second env var, a cached value, a default reintroduced "for local builds". Deriving from one source is not the same as being checked against it, which is precisely what this bug demonstrated. Asserts on the resolved defines rather than scanning the emitted bundle. The build already holds the structured values, and re-deriving them from generated text would be the weaker tool — the styleguide's "verify build output in the build" rule. Verified by simulating the decoupling: reverting the version to `pkg.version` reproduces #148 exactly and the build now refuses it, naming the issue and the env var both values derive from. Had this guard existed, the bug could not have been emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms
1 parent f56accf commit d983e4b

6 files changed

Lines changed: 128 additions & 2 deletions

File tree

‎.changeset/nightly-self-reported-version.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ This also corrects `taskless --version`, the CLI version in recipe headers, and
2222
the `cliVersion` telemetry property on nightly builds. Released builds are
2323
unaffected. A nightly that cannot determine its own version now fails the build
2424
rather than quietly reporting the released one.
25+
26+
The build now also refuses to emit a nightly whose reported version and
27+
embedded invocation disagree. Both derive from the same stamp, so they cannot
28+
diverge today — but that was true of the two values in this bug as well, right
29+
up until one of them started reading `package.json` instead. Deriving from one
30+
source is not the same as being checked against it.

‎openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ A nightly build with no stamp available SHALL fail rather than report the commit
1010

1111
Builds other than nightly SHALL report the committed package version, which for those targets is the version they are.
1212

13+
A nightly build SHALL verify, before emitting, that the version it reports and the version its embedded invocation names are the same. Deriving both from one stamp is not sufficient on its own: the two were derived from a single source and still diverged, because nothing compared them.
14+
1315
#### Scenario: A nightly records its own version when it installs
1416

1517
- **WHEN** a nightly installs into a project
@@ -27,6 +29,12 @@ Builds other than nightly SHALL report the committed package version, which for
2729
- **THEN** the build SHALL fail
2830
- **AND** SHALL NOT fall back to the committed package version
2931

32+
#### Scenario: A nightly that disagrees with itself is not emitted
33+
34+
- **WHEN** a nightly build's reported version and the invocation it embeds name different versions
35+
- **THEN** the build SHALL fail
36+
- **AND** SHALL NOT emit the artifact
37+
3038
#### Scenario: A released build reports the committed version
3139

3240
- **WHEN** a non-nightly build is asked for its version

‎openspec/specs/cli-nightly-builds/spec.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
## Purpose
44

55
TBD - created by archiving change nightly-cli-builds. Update Purpose after archive.
6+
67
## Requirements
8+
79
### Requirement: Unreleased work on the default branch is installable as a nightly package
810

911
The CLI SHALL be published as `@taskless/cli-nightly` from commits on the default branch that carry unreleased work, so that merged-but-unreleased behavior can be installed and exercised. Nightlies SHALL NOT be published from pull requests: only code that has already merged, and therefore already passed review, is eligible.
@@ -246,6 +248,8 @@ A nightly build with no stamp available SHALL fail rather than report the commit
246248

247249
Builds other than nightly SHALL report the committed package version, which for those targets is the version they are.
248250

251+
A nightly build SHALL verify, before emitting, that the version it reports and the version its embedded invocation names are the same. Deriving both from one stamp is not sufficient on its own: the two were derived from a single source and still diverged, because nothing compared them.
252+
249253
#### Scenario: A nightly records its own version when it installs
250254

251255
- **WHEN** a nightly installs into a project
@@ -263,6 +267,12 @@ Builds other than nightly SHALL report the committed package version, which for
263267
- **THEN** the build SHALL fail
264268
- **AND** SHALL NOT fall back to the committed package version
265269

270+
#### Scenario: A nightly that disagrees with itself is not emitted
271+
272+
- **WHEN** a nightly build's reported version and the invocation it embeds name different versions
273+
- **THEN** the build SHALL fail
274+
- **AND** SHALL NOT emit the artifact
275+
266276
#### Scenario: A released build reports the committed version
267277

268278
- **WHEN** a non-nightly build is asked for its version
@@ -272,4 +282,3 @@ Builds other than nightly SHALL report the committed package version, which for
272282

273283
- **WHEN** a nightly build has run
274284
- **THEN** the CLI package manifest in version control SHALL still declare the released version
275-

‎packages/cli/scripts/build-target.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,46 @@ export function resolveCliVersion(
117117
: packageVersion;
118118
}
119119

120+
/**
121+
* Refuse to emit a nightly whose two version-bearing defines disagree.
122+
*
123+
* A post-mortem guard, not a hypothetical one. #148 shipped because
124+
* `__VERSION__` and `__TASKLESS_CLI__` are *derived* from the same stamp but
125+
* were not *checked* against each other: the invocation read
126+
* `TASKLESS_NIGHTLY_VERSION` and the version read `package.json`, so one
127+
* artifact simultaneously announced v0.10.2 and sent every agent to
128+
* v0.11.0-…. Nothing failed. The disagreement was only visible by comparing
129+
* two outputs of the same build against each other, which is exactly what
130+
* nobody does.
131+
*
132+
* They are coupled by construction today. This exists so that if a later
133+
* refactor decouples them — a second env var, a cached value, a default
134+
* reintroduced "for local builds" — the build stops instead of shipping the
135+
* same silent mismatch again. The check is cheap and it fires at the only
136+
* moment the two values are both in hand.
137+
*
138+
* Deliberately asks the resolved defines rather than scanning the emitted
139+
* bundle: the build already holds the structured values, and re-deriving them
140+
* from generated text would be the weaker tool (see the styleguide's
141+
* "verify build output in the build").
142+
*/
143+
export function assertVersionConsistency(
144+
environment: BuildEnvironment,
145+
version: string,
146+
invocation: string
147+
): void {
148+
if (resolveBuildTarget(environment) !== "nightly") return;
149+
if (invocation.endsWith(`@${version}`)) return;
150+
throw new Error(
151+
`nightly build is inconsistent with itself: it reports version ` +
152+
`${JSON.stringify(version)} but its invocation is ` +
153+
`${JSON.stringify(invocation)}. These must name the same version — a ` +
154+
`build that announces one version and sends agents to another is ` +
155+
`taskless/cli#148. Both derive from ${NIGHTLY_VERSION_ENV}; if they no ` +
156+
`longer do, that is the bug.`
157+
);
158+
}
159+
120160
/**
121161
* The CLI invocation baked into emitted skill/command/recipe content, chosen by
122162
* the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self/

‎packages/cli/test/build-target.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
assertVersionConsistency,
45
NIGHTLY_VERSION_ENV,
56
OUT_DIRS,
67
resolveBuildTarget,
@@ -64,6 +65,61 @@ describe("the version a build reports as its own", () => {
6465
});
6566
});
6667

68+
// #148 post-mortem. The two defines were DERIVED from one stamp but never
69+
// CHECKED against each other, so a single artifact announced one version and
70+
// sent every agent to another, silently. These pin the guard that makes that
71+
// state unbuildable rather than merely unlikely.
72+
describe("a build must agree with itself about its version", () => {
73+
it("refuses a nightly whose invocation names a different version", () => {
74+
expect(() =>
75+
assertVersionConsistency(
76+
nightlyEnvironment,
77+
"0.10.2",
78+
`npx @taskless/cli-nightly@${NIGHTLY_VERSION}`
79+
)
80+
).toThrow(/inconsistent with itself/);
81+
});
82+
83+
// The exact shape of the shipped bug, named in the message so whoever hits
84+
// this next has the history rather than just the assertion.
85+
it("names the issue and the env var both values derive from", () => {
86+
expect(() =>
87+
assertVersionConsistency(
88+
nightlyEnvironment,
89+
"0.10.2",
90+
`npx @taskless/cli-nightly@${NIGHTLY_VERSION}`
91+
)
92+
).toThrow(new RegExp(String.raw`148[\s\S]*` + NIGHTLY_VERSION_ENV));
93+
});
94+
95+
it("accepts a nightly whose two values agree", () => {
96+
expect(() =>
97+
assertVersionConsistency(
98+
nightlyEnvironment,
99+
NIGHTLY_VERSION,
100+
`npx @taskless/cli-nightly@${NIGHTLY_VERSION}`
101+
)
102+
).not.toThrow();
103+
});
104+
105+
// A version pin is meaningless for the other targets: prod names a floating
106+
// package deliberately, and dev/self name a filesystem path. Asserting there
107+
// would fail every ordinary build.
108+
it.each([
109+
["prod", "npx @taskless/cli"],
110+
["dev", "node /repo/packages/cli/dist-dev/index.js"],
111+
["self", "node packages/cli/dist-self/index.js"],
112+
])("does not constrain the %s target", (target, invocation) => {
113+
expect(() =>
114+
assertVersionConsistency(
115+
{ TASKLESS_BUILD_TARGET: target },
116+
"0.10.2",
117+
invocation
118+
)
119+
).not.toThrow();
120+
});
121+
});
122+
67123
describe("the nightly target", () => {
68124
// The whole point of the target: a nightly's skills, commands, and recipes
69125
// must send an agent to the package the reader installed. Without the

‎packages/cli/vite.config.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
88

99
import { SKILL_CATALOG } from "./src/install/catalog";
1010
import {
11+
assertVersionConsistency,
1112
OUT_DIRS,
1213
resolveBuildTarget,
1314
resolveCliInvocation,
@@ -220,9 +221,15 @@ function assertPromptsGraph(): Plugin {
220221
};
221222
}
222223

224+
const cliVersion = resolveCliVersion(process.env, pkg.version);
225+
226+
// Fails the build rather than emitting an artifact whose version and
227+
// invocation disagree — the shape of taskless/cli#148.
228+
assertVersionConsistency(process.env, cliVersion, cliInvocation);
229+
223230
export default defineConfig({
224231
define: {
225-
__VERSION__: JSON.stringify(resolveCliVersion(process.env, pkg.version)),
232+
__VERSION__: JSON.stringify(cliVersion),
226233
__TASKLESS_CLI__: JSON.stringify(cliInvocation),
227234
__TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice),
228235
},

0 commit comments

Comments
 (0)