From 868d0fbb307466412a4c60d0ef871f78f241ea8b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 29 Aug 2026 21:10:56 -0700 Subject: [PATCH 1/2] feat(build): stamp a self build with the release it anticipates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build:self` reported the committed package version, so on a `main` carrying unreleased work it wrote `install.cliVersion: "0.10.2"` into the committed `.taskless/taskless.json` — a release that predates the tree, and a value indistinguishable from what a real install of that release would write, so the file silently lost whatever it held. That happened twice in one afternoon, each time caught only because someone read the diff before staging. A self build now stamps `-self`, where `` is what the pending changesets propose. `scripts/next-version.ts` reads it from `changeset status` and falls back to the committed version when nothing is pending, which is the state of `main` for the whole window after a release. Selection is by package name rather than `releases[0]`: the six `@taskless/vale-*` packages share the changesets workspace, so the index is the CLI only until something ships alongside it. Unlike the nightly resolver this falls back rather than throwing, and the asymmetry is deliberate. A nightly that guesses sends agents to the wrong published package, so a wrong string is worse than a failed build. A self build is local, its invocation is a path rather than a package specifier, and the suffix is what carries the meaning, so an unset env degrades instead of blocking someone's local build. The suffix is safe for the ledger: `reconcile-marker` compares the numeric core, so `0.11.0-self` and `0.11.0` are the same version to a reconciliation walk, exactly as a nightly and its release are. A test pins that. Commits the resulting `0.11.0-self`, and verified the prod build still reports `0.10.2`, so the suffix does not leak. --- .taskless/taskless.json | 2 +- package.json | 3 +- packages/cli/scripts/build-target.ts | 51 ++++++++++++++++-- packages/cli/test/build-target.test.ts | 58 ++++++++++++++++++--- scripts/next-version.ts | 71 ++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 scripts/next-version.ts diff --git a/.taskless/taskless.json b/.taskless/taskless.json index d0d82140..31dcba8f 100644 --- a/.taskless/taskless.json +++ b/.taskless/taskless.json @@ -21,7 +21,7 @@ "mode": "reference" } }, - "cliVersion": "0.11.0-20260824213902xf26a7b0", + "cliVersion": "0.11.0-self", "onboarded": true } } diff --git a/package.json b/package.json index f836d81c..ddb71c56 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build:compile": "turbo run build", "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:compile": "TASKLESS_SELF_BASE_VERSION=$(tsx scripts/next-version.ts) pnpm --filter @taskless/cli build:self", "build:self:install": "node packages/cli/dist-self/index.js init --no-interactive", "bump": "run-s bump:version bump:changelog bump:sync", "bump:changelog": "tsx scripts/changelog-compare-links.ts", @@ -21,6 +21,7 @@ "husky": "husky", "lint": "eslint", "lint-staged": "lint-staged", + "next-version": "tsx scripts/next-version.ts", "openspec": "openspec", "package": "run-s bump build", "postinstall": "[ -n \"$CI\" ] || dotagents install || echo 'dotagents install failed — run: pnpm dotagents doctor'", diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index 7ca52dcb..133ff35b 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -23,6 +23,13 @@ const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; */ export const NIGHTLY_VERSION_ENV = "TASKLESS_NIGHTLY_VERSION"; +/** + * The release a `self` build anticipates, supplied by `scripts/next-version.ts` + * and suffixed `-self` for the version this build reports. See + * {@link resolveCliVersion}. + */ +export const SELF_BASE_VERSION_ENV = "TASKLESS_SELF_BASE_VERSION"; + /** * The build target this repository used to have, removed and not coming back. * @@ -143,9 +150,47 @@ export function resolveCliVersion( environment: BuildEnvironment, packageVersion: string ): string { - return resolveBuildTarget(environment) === "nightly" - ? resolveNightlyVersion(environment) - : packageVersion; + const target = resolveBuildTarget(environment); + if (target === "nightly") return resolveNightlyVersion(environment); + if (target === "self") return resolveSelfVersion(environment, packageVersion); + return packageVersion; +} + +/** + * The version a `self` build reports: the release it anticipates, plus `-self`. + * + * A self build is dogfooded INSIDE this repository and writes + * `install.cliVersion` into the committed `.taskless/taskless.json`. Reporting + * the committed package version there is wrong twice over: on a `main` carrying + * unreleased work it names a release that predates the tree, and it is + * indistinguishable from the value a real install of that release would write, + * so the file silently loses whatever it held. That happened twice in one + * afternoon, each time noticed only because someone read the diff. + * + * `-self` fixes both. It names what the build anticipates rather than what it + * follows, and it is unmistakable in a diff. The ledger already tolerates it: + * `reconcile-marker` compares the numeric core, so `0.11.0-self` and `0.11.0` + * are the same version to a reconciliation walk, exactly as a nightly and its + * release are. + * + * UNLIKE {@link resolveNightlyVersion}, THIS FALLS BACK, and the asymmetry is + * deliberate. A nightly that guesses sends agents to the wrong published + * package, so a wrong string is worse than a failed build. A self build is + * local, its invocation is a path rather than a package specifier, and nothing + * it emits is fetched by anyone. The suffix is what carries the meaning; the + * base is a convenience, so an unset env degrades to the committed version + * rather than blocking a local build. + */ +export function resolveSelfVersion( + environment: BuildEnvironment, + packageVersion: string +): string { + const base = environment[SELF_BASE_VERSION_ENV]; + const resolved = + base !== undefined && base.length > 0 && SEMVER_PATTERN.test(base) + ? base + : packageVersion; + return `${resolved}-self`; } /** diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index 36f13a8d..8a1c3919 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { assertVersionConsistency, NIGHTLY_VERSION_ENV, + SELF_BASE_VERSION_ENV, OUT_DIRS, resolveBuildTarget, resolveCliInvocation, @@ -66,18 +67,59 @@ describe("build target resolution", () => { describe("the version a build reports as its own", () => { // Only nightly diverges. self/prod both run from a checkout whose // package.json IS the version they are, so reading it is correct there. - it.each(["prod", "self"])( - "uses the committed package version for the %s target", - (target) => { - expect( - resolveCliVersion({ TASKLESS_BUILD_TARGET: target }, "0.10.2") - ).toBe("0.10.2"); - } - ); + it("uses the committed package version for the prod target", () => { + expect(resolveCliVersion({ TASKLESS_BUILD_TARGET: "prod" }, "0.10.2")).toBe( + "0.10.2" + ); + }); it("uses the committed package version when no target is set", () => { expect(resolveCliVersion({}, "0.10.2")).toBe("0.10.2"); }); + + it("stamps a self build with the release it anticipates, suffixed", () => { + // Not the committed version: a self build on a `main` carrying unreleased + // work writes `install.cliVersion` into the committed taskless.json, and + // naming the last release there is both wrong and indistinguishable from + // what a real install of that release would write. + expect( + resolveCliVersion( + { TASKLESS_BUILD_TARGET: "self", [SELF_BASE_VERSION_ENV]: "0.11.0" }, + "0.10.2" + ) + ).toBe("0.11.0-self"); + }); + + it("falls back to the committed version for a self build with no base", () => { + // Deliberately unlike `nightly`, which throws. A self build is local, its + // invocation is a path rather than a package specifier, and the `-self` + // suffix carries the meaning either way, so an unset env degrades rather + // than blocking someone's local build. + expect(resolveCliVersion({ TASKLESS_BUILD_TARGET: "self" }, "0.10.2")).toBe( + "0.10.2-self" + ); + }); + + it("ignores a malformed self base version rather than emitting it", () => { + for (const base of ["", "not-a-version", "0.11", "v0.11.0"]) { + expect( + resolveCliVersion( + { TASKLESS_BUILD_TARGET: "self", [SELF_BASE_VERSION_ENV]: base }, + "0.10.2" + ) + ).toBe("0.10.2-self"); + } + }); + + it("keeps a self version equal to its release for the reconciliation walk", () => { + // `reconcile-marker` compares the numeric core, so the suffix must not + // make a self build look like a different version to a ledger walk. + const self = resolveCliVersion( + { TASKLESS_BUILD_TARGET: "self", [SELF_BASE_VERSION_ENV]: "0.11.0" }, + "0.10.2" + ); + expect(self.split("-")[0]).toBe("0.11.0"); + }); }); // #148 post-mortem. The two defines were DERIVED from one stamp but never diff --git a/scripts/next-version.ts b/scripts/next-version.ts new file mode 100644 index 00000000..4d84cd11 --- /dev/null +++ b/scripts/next-version.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Print the version the CLI would be released as if the pending changesets + * were applied right now, or its committed version when none are pending. + * + * `build:self` uses this to stamp `-self`. Without it a self build + * reports the last RELEASE, which describes neither the tree it was built from + * nor the artifact it produced: on a `main` carrying 35 changesets it wrote + * `0.10.2` into `.taskless/taskless.json`, silently replacing the committed + * value, twice in one afternoon. + */ + +const ROOT = resolve(import.meta.dirname, ".."); +const CLI_PACKAGE_JSON = join(ROOT, "packages", "cli", "package.json"); +const PACKAGE_NAME = "@taskless/cli"; + +/** + * `changeset status --output=` resolves its path against the CWD, so an + * absolute-looking `/tmp/x.json` silently becomes `/tmp/x.json` and the + * read that follows fails on a file nothing wrote. Measured; the same trap is + * documented in `.github/scripts/nightly-pack.cjs`. Keep this relative. + */ +const STATUS_FILE = "changeset-status.tmp.json"; + +/** The version in version control, which is the last released one. */ +function committedVersion(): string { + const manifest = JSON.parse(readFileSync(CLI_PACKAGE_JSON, "utf8")) as { + version: string; + }; + return manifest.version; +} + +/** + * What the pending changesets propose for the CLI, or `undefined`. + * + * Selected BY NAME rather than by `releases[0]`. The six `@taskless/vale-*` + * packages already sit in the same changesets workspace, so index 0 is the CLI + * only for as long as nothing else is released alongside it, and being wrong + * would stamp a self build with a Vale binary's version. + */ +function proposedVersion(): string | undefined { + try { + execFileSync("pnpm", ["changeset", "status", `--output=${STATUS_FILE}`], { + cwd: ROOT, + stdio: "ignore", + }); + } catch { + // Non-zero exit still writes the file in the cases that matter; if it did + // not, the read below throws and the caller falls back. + } + + try { + const status = JSON.parse( + readFileSync(join(ROOT, STATUS_FILE), "utf8") + ) as { releases?: Array<{ name?: string; newVersion?: string }> }; + return status.releases?.find((r) => r.name === PACKAGE_NAME)?.newVersion; + } catch { + return undefined; + } finally { + rmSync(join(ROOT, STATUS_FILE), { force: true }); + } +} + +// Falls back rather than throwing: with no changesets pending — which is the +// state of `main` for the whole window after a release — there is no proposal +// and the committed version IS the next one. A self build must keep working +// there. +process.stdout.write(proposedVersion() ?? committedVersion()); From 19290d73c691f7f99cde5512eec474963caca74e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 29 Aug 2026 21:11:09 -0700 Subject: [PATCH 2/2] chore: add a changeset for the self build version --- .changeset/self-build-anticipated-version.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/self-build-anticipated-version.md diff --git a/.changeset/self-build-anticipated-version.md b/.changeset/self-build-anticipated-version.md new file mode 100644 index 00000000..5405aefe --- /dev/null +++ b/.changeset/self-build-anticipated-version.md @@ -0,0 +1,20 @@ +--- +"@taskless/cli": patch +--- + +Stamp a `self` build with the release it anticipates rather than the one it follows. + +`build:self` reported the committed package version, so on a `main` carrying +unreleased work it wrote `install.cliVersion: "0.10.2"` into the committed +`.taskless/taskless.json`. That names a release predating the tree, and it is +indistinguishable from the value a real install of that release would write, so +the file silently lost whatever it held. + +A self build now stamps `-self`, where `` is what the pending +changesets propose. The suffix names what the build anticipates rather than what +it follows, and it is unmistakable in a diff. It is safe for the reconciliation +ledger, which compares the numeric core, so `0.11.0-self` and `0.11.0` are the +same version to a walk, exactly as a nightly and its release are. + +Only the `self` target changes. A prod build still reports the committed +version.