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. 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());