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
20 changes: 20 additions & 0 deletions .changeset/self-build-anticipated-version.md
Original file line number Diff line number Diff line change
@@ -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 `<next>-self`, where `<next>` 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.
2 changes: 1 addition & 1 deletion .taskless/taskless.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"mode": "reference"
}
},
"cliVersion": "0.11.0-20260824213902xf26a7b0",
"cliVersion": "0.11.0-self",
"onboarded": true
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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'",
Expand Down
51 changes: 48 additions & 3 deletions packages/cli/scripts/build-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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`;
}

/**
Expand Down
58 changes: 50 additions & 8 deletions packages/cli/test/build-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
assertVersionConsistency,
NIGHTLY_VERSION_ENV,
SELF_BASE_VERSION_ENV,
OUT_DIRS,
resolveBuildTarget,
resolveCliInvocation,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions scripts/next-version.ts
Original file line number Diff line number Diff line change
@@ -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 `<next>-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 `<cwd>/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());
Loading