diff --git a/.github/scripts/vale-detect.cjs b/.github/scripts/vale-detect.cjs
new file mode 100644
index 00000000..2bfb7048
--- /dev/null
+++ b/.github/scripts/vale-detect.cjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// SPDX-License-Identifier: MIT
+"use strict";
+
+/**
+ * Vale platform packages — upstream detection.
+ *
+ * The I/O half of the detect phase. It reads the latest upstream Vale release,
+ * hands it plus the committed manifest to `planManifestUpdate` (pure, in
+ * vale-release.cjs), and writes the rewritten manifest back when upstream is
+ * ahead. It publishes nothing and needs no npm credential.
+ *
+ * What bounds a run is that comparison, and only that comparison (design D5). A
+ * "is this version already on npm?" check could not do the job: every publish
+ * stamps a timestamp npm has never seen, so such a check would answer "not
+ * published" every single time and could never suppress anything.
+ *
+ * The two phases are separate because the trust boundary is code review. Detect
+ * proposes new digests; a human reviews them; merging the manifest change is
+ * what authorizes the publish run to fetch bytes matching those digests. A
+ * single job that discovered a digest and then verified against the digest it
+ * had just discovered would be verifying nothing.
+ *
+ * Usage:
+ * node .github/scripts/vale-detect.cjs [--write]
+ *
+ * --write rewrite vale-manifest.json in place when upstream is ahead.
+ * Without it the script only reports, which is what a local
+ * "what would this do?" run wants.
+ *
+ * Outputs (appended to $GITHUB_OUTPUT when set):
+ * update "true" when upstream is ahead
+ * vale_version the upstream version
+ * pinned_version the version currently in the manifest
+ */
+
+const { appendFileSync, readFileSync, writeFileSync } = require("node:fs");
+const { join } = require("node:path");
+
+const {
+ applyTemplate,
+ assertManifest,
+ isUpstreamAhead,
+ parseReleaseTag,
+ planManifestUpdate,
+ resolveChecksumsUrl,
+} = require("./vale-release.cjs");
+
+const MANIFEST_PATH = join(__dirname, "vale-manifest.json");
+
+function setOutput(key, value) {
+ const file = process.env.GITHUB_OUTPUT;
+ if (file) {
+ appendFileSync(file, `${key}=${value}\n`);
+ }
+}
+
+/**
+ * GitHub's `releases/latest` deliberately excludes prereleases and drafts, so a
+ * Vale release candidate never trips detection. `GITHUB_TOKEN`, when present,
+ * is only for the API rate limit; the endpoint is public.
+ */
+async function fetchLatestTag(repository) {
+ const headers = {
+ accept: "application/vnd.github+json",
+ "user-agent": "taskless-skills-vale-detect",
+ };
+ if (process.env.GITHUB_TOKEN) {
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
+ }
+ const url = `https://api.github.com/repos/${repository}/releases/latest`;
+ const response = await fetch(url, { headers });
+ if (!response.ok) {
+ throw new Error(`GET ${url} responded ${response.status}`);
+ }
+ const release = await response.json();
+ if (typeof release.tag_name !== "string") {
+ throw new TypeError(`${url} returned no tag_name`);
+ }
+ return release.tag_name;
+}
+
+async function fetchText(url) {
+ const response = await fetch(url, { redirect: "follow" });
+ if (!response.ok) {
+ throw new Error(`GET ${url} responded ${response.status}`);
+ }
+ return response.text();
+}
+
+async function main({
+ argv = process.argv.slice(2),
+ latestTag = fetchLatestTag,
+ text = fetchText,
+} = {}) {
+ const write = argv.includes("--write");
+ const manifest = assertManifest(
+ JSON.parse(readFileSync(MANIFEST_PATH, "utf8"))
+ );
+
+ const upstreamTag = await latestTag(manifest.upstream.repository);
+ console.log(
+ `pinned: ${manifest.valeVersion} upstream latest: ${upstreamTag}`
+ );
+
+ // Decide whether to go on with the two pure predicates directly, rather than
+ // by calling planManifestUpdate with a placeholder checksums payload. That
+ // shortcut looks equivalent but inverts the script: planManifestUpdate only
+ // ignores `checksumsText` on the NOT-ahead path, so a stand-in empty string
+ // makes it throw ("parsed to no entries") on exactly the runs that have
+ // something to propose. The cheap check has to be the cheap check.
+ const upstreamVersion = parseReleaseTag(upstreamTag);
+ if (!isUpstreamAhead(manifest.valeVersion, upstreamVersion)) {
+ console.log("Upstream is not ahead of the pinned version. Nothing to do.");
+ setOutput("update", "false");
+ setOutput("vale_version", upstreamVersion);
+ setOutput("pinned_version", manifest.valeVersion);
+ return;
+ }
+
+ // Only now is the checksums file worth downloading: it belongs to a release
+ // we are actually going to propose.
+ const checksumsUrl = resolveChecksumsUrl(manifest, upstreamVersion);
+ console.log(`fetching ${checksumsUrl}`);
+ const checksumsText = await text(checksumsUrl);
+
+ const plan = planManifestUpdate({ manifest, upstreamTag, checksumsText });
+ console.log(
+ `Upstream ${plan.upstreamVersion} is ahead of ${plan.pinnedVersion}.`
+ );
+ for (const platform of plan.manifest.platforms) {
+ console.log(
+ ` ${applyTemplate(platform.asset, { version: plan.upstreamVersion })} ${platform.sha256}`
+ );
+ }
+
+ if (write) {
+ writeFileSync(MANIFEST_PATH, `${JSON.stringify(plan.manifest, null, 2)}\n`);
+ console.log(`\nRewrote ${MANIFEST_PATH}.`);
+ } else {
+ console.log("\nPass --write to update the manifest.");
+ }
+
+ setOutput("update", "true");
+ setOutput("vale_version", plan.upstreamVersion);
+ setOutput("pinned_version", plan.pinnedVersion);
+}
+
+// Exported (and only self-invoking as a script) so vale-detect.test.cjs can run
+// main() with the two fetches stubbed. The bug that motivated this was in the
+// composition — how main() sequences pure functions that were each already
+// tested — which is reachable no other way.
+module.exports = { main };
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(`\nvale-detect failed: ${error.message}`);
+ process.exitCode = 1;
+ });
+}
diff --git a/.github/scripts/vale-detect.test.cjs b/.github/scripts/vale-detect.test.cjs
new file mode 100644
index 00000000..ee66140f
--- /dev/null
+++ b/.github/scripts/vale-detect.test.cjs
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier: MIT
+"use strict";
+
+/**
+ * Composition tests for vale-detect.cjs.
+ *
+ * vale-release.test.cjs covers the pure functions individually. This file
+ * covers the one thing that cannot: how main() sequences them, with both
+ * network calls stubbed. That gap is not hypothetical — the detect job once
+ * routed its cheap "is upstream ahead?" check through planManifestUpdate with
+ * an empty checksums payload, which throws on precisely the ahead path, so the
+ * job failed on every run that had a release to propose while the no-op path
+ * kept passing. Every function involved was green in isolation.
+ *
+ * Nothing here writes: main() is called without `--write`, so the committed
+ * manifest is only read.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { mkdtempSync, readFileSync, rmSync } = require("node:fs");
+const { tmpdir } = require("node:os");
+const { join } = require("node:path");
+
+const { main } = require("./vale-detect.cjs");
+const { applyTemplate } = require("./vale-release.cjs");
+
+const MANIFEST = JSON.parse(
+ readFileSync(join(__dirname, "vale-manifest.json"), "utf8")
+);
+
+/** A digest that is syntactically valid and obviously synthetic. */
+const digestFor = (index) =>
+ String(index + 1)
+ .repeat(64)
+ .slice(0, 64);
+
+/** Upstream's sha256sum-format checksums file for a given Vale version. */
+function checksumsFor(version) {
+ return `${MANIFEST.platforms
+ .map(
+ (platform, index) =>
+ `${digestFor(index)} ${applyTemplate(platform.asset, { version })}`
+ )
+ .join("\n")}\n`;
+}
+
+/**
+ * Run main() with both fetches stubbed and $GITHUB_OUTPUT pointed at a temp
+ * file, then return the parsed step outputs plus which URLs were fetched.
+ */
+async function runDetect({ upstreamTag, checksums }) {
+ const directory = mkdtempSync(join(tmpdir(), "vale-detect-test-"));
+ const outputPath = join(directory, "github-output");
+ const previous = process.env.GITHUB_OUTPUT;
+ const fetched = [];
+ process.env.GITHUB_OUTPUT = outputPath;
+ try {
+ await main({
+ argv: [],
+ latestTag: async () => upstreamTag,
+ text: async (url) => {
+ fetched.push(url);
+ return checksums;
+ },
+ });
+ const outputs = Object.fromEntries(
+ readFileSync(outputPath, "utf8")
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const at = line.indexOf("=");
+ return [line.slice(0, at), line.slice(at + 1)];
+ })
+ );
+ return { outputs, fetched };
+ } finally {
+ if (previous === undefined) {
+ delete process.env.GITHUB_OUTPUT;
+ } else {
+ process.env.GITHUB_OUTPUT = previous;
+ }
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+test("detect: an upstream release ahead of the pin plans an update", async () => {
+ // The regression case. Before the fix this rejected with "upstream checksums
+ // file for 3.99.0 parsed to no entries" — the detect job's failure mode on
+ // every real upstream bump.
+ const { outputs, fetched } = await runDetect({
+ upstreamTag: "v3.99.0",
+ checksums: checksumsFor("3.99.0"),
+ });
+
+ assert.equal(outputs.update, "true");
+ assert.equal(outputs.vale_version, "3.99.0");
+ assert.equal(outputs.pinned_version, MANIFEST.valeVersion);
+ assert.deepEqual(fetched, [
+ `https://github.com/${MANIFEST.upstream.repository}/releases/download/v3.99.0/vale_3.99.0_checksums.txt`,
+ ]);
+});
+
+test("detect: the pinned version being current is a no-op", async () => {
+ const { outputs, fetched } = await runDetect({
+ upstreamTag: `v${MANIFEST.valeVersion}`,
+ checksums: "",
+ });
+
+ assert.equal(outputs.update, "false");
+ assert.equal(outputs.vale_version, MANIFEST.valeVersion);
+ assert.equal(outputs.pinned_version, MANIFEST.valeVersion);
+ // The whole reason the check is cheap: no checksums file is downloaded for a
+ // release we are not going to propose.
+ assert.deepEqual(fetched, []);
+});
+
+test("detect: an upstream tag behind the pin is also a no-op", async () => {
+ const { outputs, fetched } = await runDetect({
+ upstreamTag: "v0.1.0",
+ checksums: "",
+ });
+
+ assert.equal(outputs.update, "false");
+ assert.deepEqual(fetched, []);
+});
+
+test("detect: a checksums file missing a platform aborts", async () => {
+ await assert.rejects(
+ runDetect({
+ upstreamTag: "v3.99.0",
+ checksums: checksumsFor("3.99.0").split("\n").slice(1).join("\n"),
+ }),
+ /publishes no asset named/
+ );
+});
diff --git a/.github/scripts/vale-prepare.cjs b/.github/scripts/vale-prepare.cjs
new file mode 100644
index 00000000..b841c998
--- /dev/null
+++ b/.github/scripts/vale-prepare.cjs
@@ -0,0 +1,293 @@
+#!/usr/bin/env node
+// SPDX-License-Identifier: MIT
+"use strict";
+
+/**
+ * Vale platform packages — fetch, verify, unpack, stamp, pack.
+ *
+ * This is the I/O half of the publish phase. Every decision it makes is
+ * delegated to vale-release.cjs, which is pure and unit-tested; what lives here
+ * is only the network and filesystem work that cannot be.
+ *
+ * The order of operations is the point, and it is the order the requirement
+ * "verification occurs before any step holding publish credentials handles the
+ * binary" (design D6) demands:
+ *
+ * 1. download the upstream release ARCHIVE into a temporary directory;
+ * 2. sha256 it and compare against the digest committed in
+ * vale-manifest.json — abort the whole run on the first mismatch, before
+ * anything is unpacked, so unverified bytes are never even expanded onto
+ * disk;
+ * 3. unpack the single executable named by the manifest into its package
+ * directory at mode 0755;
+ * 4. stamp every package.json with one shared version;
+ * 5. `npm pack` each package into --out.
+ *
+ * Nothing here publishes, and nothing here needs a credential. The workflow
+ * runs it in a job that holds neither an npm identity nor an OIDC token, and
+ * hands the resulting tarballs to a separate credentialed job. That job then
+ * only ever sees bytes that already matched a reviewed digest and are already
+ * sealed into a tarball.
+ *
+ * Usage:
+ * node .github/scripts/vale-prepare.cjs [--out
] [--only ]... [--skip-pack]
+ *
+ * --out where to write the .tgz files (default: .vale-dist at the repo root)
+ * --only restrict to one platform package; repeatable. Accepts the full
+ * name (@taskless/vale-linux-x64) or the suffix (linux-x64).
+ * --skip-pack fetch, verify, unpack, and stamp, but do not run npm pack.
+ *
+ * Running this locally leaves the stamped version in each package.json and the
+ * unpacked executable in each package directory. Both are throwaway: the
+ * executable is gitignored, and checking the platform package.json files back
+ * out of git puts the placeholder version back.
+ */
+
+const { createHash } = require("node:crypto");
+const {
+ chmodSync,
+ copyFileSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+ appendFileSync,
+} = require("node:fs");
+const { tmpdir } = require("node:os");
+const { basename, join, resolve, sep } = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const {
+ applyStamp,
+ assertChecksum,
+ assertManifest,
+ planStamp,
+ resolveAssetName,
+ resolveDownloadUrl,
+} = require("./vale-release.cjs");
+
+const REPO_ROOT = resolve(__dirname, "..", "..");
+const MANIFEST_PATH = join(__dirname, "vale-manifest.json");
+
+/**
+ * Read the value that follows a flag, or throw. A missing value is a typo, and
+ * defaulting it would be worse than stopping: an empty `--out` resolves to the
+ * current working directory, so `--out` with its argument dropped would scatter
+ * tarballs wherever the script happened to be invoked from.
+ */
+function requireValue(argv, index, flag) {
+ const value = argv[index];
+ if (value === undefined || value.length === 0 || value.startsWith("--")) {
+ throw new Error(`${flag} requires a value`);
+ }
+ return value;
+}
+
+function parseArguments(argv) {
+ const options = { out: join(REPO_ROOT, ".vale-dist"), only: [], pack: true };
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === "--out") {
+ index += 1;
+ options.out = resolve(requireValue(argv, index, "--out"));
+ } else if (argument === "--only") {
+ index += 1;
+ options.only.push(requireValue(argv, index, "--only"));
+ } else if (argument === "--skip-pack") {
+ options.pack = false;
+ } else {
+ throw new Error(`unknown argument: ${argument}`);
+ }
+ }
+ return options;
+}
+
+/** Match `--only` against either the full package name or its suffix. */
+function selects(only, platform) {
+ if (only.length === 0) {
+ return true;
+ }
+ const suffix = platform.package.replace("@taskless/vale-", "");
+ return only.some(
+ (entry) =>
+ entry === platform.package ||
+ entry === suffix ||
+ entry === `vale-${suffix}`
+ );
+}
+
+function run(command, arguments_, options = {}) {
+ const result = spawnSync(command, arguments_, {
+ stdio: "inherit",
+ ...options,
+ });
+ if (result.error) {
+ throw result.error;
+ }
+ if (result.status !== 0) {
+ throw new Error(
+ `${command} ${arguments_.join(" ")} exited with status ${result.status}`
+ );
+ }
+ return result;
+}
+
+/** Download to a file and return its sha256, without unpacking anything. */
+async function download(url, destinationPath) {
+ const response = await fetch(url, { redirect: "follow" });
+ if (!response.ok) {
+ throw new Error(`GET ${url} responded ${response.status}`);
+ }
+ const bytes = Buffer.from(await response.arrayBuffer());
+ writeFileSync(destinationPath, bytes);
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+/**
+ * Unpack exactly one member from an archive and place it at `destinationPath`
+ * with mode 0755.
+ *
+ * `tar` and `unzip` are used rather than a bundled extraction library because
+ * this file must stay dependency-free and both are present on the runner. The
+ * archive path and member name come from the reviewed manifest and are passed
+ * as argv (never through a shell), so neither can inject a command.
+ *
+ * The mode is set explicitly rather than inherited. Upstream's tarballs already
+ * carry `-rwxr-xr-x`, but a zip has no reliable Unix mode, and the executable
+ * bit surviving into the published tarball is a requirement rather than
+ * something to leave to the archive format.
+ *
+ * What lands in the temp directory is third-party bytes, so two things are
+ * checked before anything is copied into a package: the extracted path resolves
+ * inside the temp directory (a `../` member name cannot reach out of it), and it
+ * is a regular file rather than a symlink or directory. Without those, an
+ * archive carrying a symlink at the member's name would put a dangling link, or
+ * a link to a host path, into the published tarball.
+ *
+ * Both checks run after extraction, so they cover the member's LEAF entry and
+ * nothing above it. For a nested member the escape to worry about is an
+ * intermediate directory component that is itself a symlink out of the temp
+ * directory, which `tar` would follow while writing — before this function sees
+ * a path to inspect. What rules that out is upstream of here: `assertManifest`
+ * requires every `archiveMember` to be a flat single-segment filename, so there
+ * is no intermediate component to subvert. (GNU tar also refuses by default to
+ * follow a symlink when creating an implied directory, but that is the
+ * extractor's behavior, not this code's guarantee.) A future manifest entry
+ * needing a nested member would have to relax that assertion, and the checks
+ * below will not substitute for it.
+ */
+function unpackMember(archivePath, member, destinationPath) {
+ const workDirectory = mkdtempSync(join(tmpdir(), "vale-unpack-"));
+ try {
+ if (archivePath.endsWith(".zip")) {
+ run("unzip", ["-o", "-q", archivePath, member, "-d", workDirectory]);
+ } else {
+ run("tar", ["-xzf", archivePath, "-C", workDirectory, member]);
+ }
+ const root = resolve(workDirectory);
+ const extracted = resolve(root, member);
+ if (extracted === root || !extracted.startsWith(`${root}${sep}`)) {
+ throw new Error(`member ${member} resolves outside the unpack directory`);
+ }
+ const stats = lstatSync(extracted, { throwIfNoEntry: false });
+ if (!stats) {
+ throw new Error(
+ `${basename(archivePath)} contains no member named ${member}`
+ );
+ }
+ if (!stats.isFile()) {
+ throw new Error(
+ `member ${member} of ${basename(archivePath)} is not a regular file`
+ );
+ }
+ copyFileSync(extracted, destinationPath);
+ chmodSync(destinationPath, 0o755);
+ } finally {
+ rmSync(workDirectory, { recursive: true, force: true });
+ }
+}
+
+function setOutput(key, value) {
+ const file = process.env.GITHUB_OUTPUT;
+ if (file) {
+ appendFileSync(file, `${key}=${value}\n`);
+ }
+}
+
+async function main() {
+ const options = parseArguments(process.argv.slice(2));
+ const manifest = assertManifest(
+ JSON.parse(readFileSync(MANIFEST_PATH, "utf8"))
+ );
+ const selected = manifest.platforms.filter((platform) =>
+ selects(options.only, platform)
+ );
+ if (selected.length === 0) {
+ throw new Error(`--only matched no platform in ${MANIFEST_PATH}`);
+ }
+
+ // One version for the whole set, computed once from the run's start time.
+ const plan = planStamp({ manifest, date: new Date() });
+ console.log(`Vale ${manifest.valeVersion} → version ${plan.version}`);
+
+ mkdirSync(options.out, { recursive: true });
+ const downloadDirectory = mkdtempSync(join(tmpdir(), "vale-download-"));
+ const packed = [];
+
+ try {
+ for (const platform of selected) {
+ const asset = resolveAssetName(manifest, platform);
+ const url = resolveDownloadUrl(manifest, platform);
+ const archivePath = join(downloadDirectory, asset);
+
+ console.log(`\n${platform.package}`);
+ console.log(` fetch ${url}`);
+ const actual = await download(url, archivePath);
+
+ // Fatal on mismatch: the loop stops here, nothing is unpacked, and no
+ // tarball reaches the credentialed job.
+ assertChecksum({ asset, expected: platform.sha256, actual });
+ console.log(` verify sha256 ${actual}`);
+
+ const packageDirectory = join(REPO_ROOT, platform.directory);
+ const binaryPath = join(packageDirectory, platform.archiveMember);
+ unpackMember(archivePath, platform.archiveMember, binaryPath);
+ console.log(
+ ` unpack ${platform.directory}/${platform.archiveMember} (0755)`
+ );
+
+ const packageJsonPath = join(packageDirectory, "package.json");
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
+ writeFileSync(
+ packageJsonPath,
+ `${JSON.stringify(applyStamp(packageJson, plan.version), null, 2)}\n`
+ );
+ console.log(` stamp ${plan.version}`);
+
+ if (options.pack) {
+ run(
+ "npm",
+ ["pack", "--ignore-scripts", "--pack-destination", options.out],
+ { cwd: packageDirectory }
+ );
+ packed.push(platform.package);
+ }
+ }
+ } finally {
+ rmSync(downloadDirectory, { recursive: true, force: true });
+ }
+
+ console.log(
+ `\nPrepared ${selected.length} package(s) at ${plan.version}` +
+ (options.pack ? `; ${packed.length} tarball(s) in ${options.out}` : "")
+ );
+ setOutput("version", plan.version);
+ setOutput("vale_version", manifest.valeVersion);
+}
+
+main().catch((error) => {
+ console.error(`\nvale-prepare failed: ${error.message}`);
+ process.exitCode = 1;
+});
diff --git a/.github/scripts/vale-release.cjs b/.github/scripts/vale-release.cjs
new file mode 100644
index 00000000..af79e34c
--- /dev/null
+++ b/.github/scripts/vale-release.cjs
@@ -0,0 +1,540 @@
+// SPDX-License-Identifier: MIT
+"use strict";
+
+/**
+ * Vale platform packages — pure, zero-dependency release logic.
+ *
+ * Everything here is a total function over plain data: no network, no
+ * filesystem, no `process`. The I/O lives in the two sibling entry points that
+ * consume this module —
+ *
+ * vale-detect.cjs compares the latest upstream Vale release against the
+ * pinned one and rewrites the manifest for review.
+ * vale-prepare.cjs downloads, verifies, unpacks, stamps, and packs.
+ *
+ * — which keeps the parts worth testing (version stamping, semver assertions,
+ * digest comparison, manifest rewriting) directly unit-testable with
+ * `node --test` and no build step, the same arrangement as stack-breadcrumb.cjs.
+ *
+ * The design decisions these functions encode live in
+ * openspec/changes/add-vale-binary-packages/design.md; D4 (all-prerelease
+ * timestamp versioning) and D6 (committed checksums are the trust boundary) are
+ * the two that most of this file exists to enforce.
+ */
+
+// ---------------------------------------------------------------------------
+// Version shapes
+//
+// Two distinct kinds of version appear here and conflating them is the mistake
+// this section exists to prevent:
+//
+// a VALE version plain `major.minor.patch`, e.g. `3.17.1` — what upstream
+// released. Never itself published to npm by us.
+// a STAMPED version `-`, e.g.
+// `3.17.1-20260806120000` — what we publish, always.
+//
+// D4's four properties all follow from that second shape: provenance stays
+// readable in the leading component, a packaging fix against the same upstream
+// release gets a fresh timestamp rather than needing a version it does not own,
+// the prerelease makes a caret range unable to resolve anything (see
+// `rangeMatches`), and a 14-digit timestamp is a valid numeric prerelease
+// identifier so ordering is numeric and monotonic.
+// ---------------------------------------------------------------------------
+
+/** A plain upstream Vale version: `major.minor.patch`, no prerelease, no build. */
+const VALE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
+
+/** A stamped package version: a Vale version plus a 14-digit UTC timestamp. */
+const STAMPED_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)-(\d{14})$/;
+
+/**
+ * The version committed for every platform package. It is a placeholder and is
+ * overwritten by `planStamp` before packing; it is never published as-is. Kept
+ * here so the assertion in `planStamp` and the scaffolding agree on one value.
+ */
+const PLACEHOLDER_VERSION = "0.0.0";
+
+/**
+ * Parse a plain Vale version into its numeric components. Throws on anything
+ * that is not exactly `major.minor.patch` — including a version that already
+ * carries a prerelease, which would otherwise let a stamped version be stamped
+ * a second time and produce `3.17.1-2026…-2026…`.
+ */
+function parseValeVersion(text) {
+ const match = VALE_VERSION_PATTERN.exec(String(text ?? ""));
+ if (!match) {
+ throw new Error(
+ `not a plain Vale version (expected major.minor.patch): ${JSON.stringify(text)}`
+ );
+ }
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ };
+}
+
+/**
+ * Strip the leading `v` from an upstream git tag (`v3.17.1` → `3.17.1`) and
+ * validate the remainder. Upstream tags its releases with the prefix; the
+ * manifest stores the bare version.
+ */
+function parseReleaseTag(tag) {
+ const text = String(tag ?? "").trim();
+ const bare = text.startsWith("v") ? text.slice(1) : text;
+ parseValeVersion(bare);
+ return bare;
+}
+
+/**
+ * Format a Date as the `yyyymmddhhmmss` UTC stamp. Derived from the ISO string
+ * so the UTC conversion is the platform's, not ours: `2026-08-06T12:00:00.000Z`
+ * → `20260806120000`.
+ */
+function formatStampTimestamp(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
+ throw new TypeError("formatStampTimestamp requires a valid Date");
+ }
+ return date.toISOString().replaceAll(/\D/g, "").slice(0, 14);
+}
+
+/**
+ * Build the one version the whole set is published under:
+ * `-`. The result is run through
+ * `assertStampedVersion`, so a caller cannot get an unvalidated version out of
+ * here even by passing something strange in.
+ */
+function stampVersion(valeVersion, date) {
+ parseValeVersion(valeVersion);
+ const version = `${valeVersion}-${formatStampTimestamp(date)}`;
+ assertStampedVersion(version);
+ return version;
+}
+
+/**
+ * Assert a version is a legitimate stamped version, and throw with a specific
+ * reason when it is not. Four things are checked, one per property in D4:
+ *
+ * 1. it matches `major.minor.patch-<14 digits>` at all, so a plain Vale
+ * version — the thing that must never be published — cannot pass;
+ * 2. the prerelease is a NUMERIC identifier under semver, meaning no leading
+ * zero. A leading zero would make semver treat it as alphanumeric and
+ * compare it lexically, breaking monotonic ordering;
+ * 3. that number is inside the safe-integer range, so the numeric comparison
+ * is exact;
+ * 4. the timestamp is a real calendar date, which catches a mangled stamp
+ * (`20261340…`) that would otherwise pass every syntactic check.
+ */
+function assertStampedVersion(version) {
+ const text = String(version ?? "");
+ const match = STAMPED_VERSION_PATTERN.exec(text);
+ if (!match) {
+ throw new Error(
+ `not a stamped version (expected -): ${JSON.stringify(version)}`
+ );
+ }
+ const timestamp = match[4];
+ if (timestamp.startsWith("0")) {
+ throw new Error(
+ `timestamp ${timestamp} has a leading zero, so semver would compare it as a string rather than a number`
+ );
+ }
+ const numeric = Number(timestamp);
+ if (!Number.isSafeInteger(numeric)) {
+ throw new Error(`timestamp ${timestamp} is outside the safe-integer range`);
+ }
+ const year = Number(timestamp.slice(0, 4));
+ const month = Number(timestamp.slice(4, 6));
+ const day = Number(timestamp.slice(6, 8));
+ const hour = Number(timestamp.slice(8, 10));
+ const minute = Number(timestamp.slice(10, 12));
+ const second = Number(timestamp.slice(12, 14));
+ const asDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
+ if (formatStampTimestamp(asDate) !== timestamp) {
+ throw new Error(`timestamp ${timestamp} is not a real UTC date and time`);
+ }
+ return text;
+}
+
+/**
+ * Order two stamped versions by semver precedence. Both are prereleases of the
+ * form used here, so precedence is: compare `major.minor.patch` numerically,
+ * then compare the single numeric prerelease identifier numerically. Returns
+ * -1, 0, or 1.
+ */
+function compareStampedVersions(a, b) {
+ const left = STAMPED_VERSION_PATTERN.exec(assertStampedVersion(a));
+ const right = STAMPED_VERSION_PATTERN.exec(assertStampedVersion(b));
+ for (let index = 1; index <= 4; index += 1) {
+ const difference = Number(left[index]) - Number(right[index]);
+ if (difference !== 0) {
+ return difference < 0 ? -1 : 1;
+ }
+ }
+ return 0;
+}
+
+/** Order two plain Vale versions numerically. Returns -1, 0, or 1. */
+function compareValeVersions(a, b) {
+ const left = parseValeVersion(a);
+ const right = parseValeVersion(b);
+ for (const part of ["major", "minor", "patch"]) {
+ if (left[part] !== right[part]) {
+ return left[part] < right[part] ? -1 : 1;
+ }
+ }
+ return 0;
+}
+
+/** True when upstream has released a Vale version newer than the pinned one. */
+function isUpstreamAhead(pinnedVersion, upstreamVersion) {
+ return compareValeVersions(upstreamVersion, pinnedVersion) > 0;
+}
+
+/**
+ * Does a dependency range match a version, for the narrow subset of ranges D4
+ * cares about: an exact version, `^x.y.z`, or `~x.y.z`.
+ *
+ * This is not a general semver resolver and is not used to resolve anything. It
+ * exists so D4's third property — "exact pinning is enforced by semver, not
+ * convention" — is asserted by a test rather than asserted in prose. It encodes
+ * the one semver rule that produces that property:
+ *
+ * a version carrying a prerelease satisfies a range only if some comparator
+ * in that range names the same major.minor.patch AND itself carries a
+ * prerelease.
+ *
+ * `^3.17.1` desugars to `>=3.17.1 <4.0.0`; neither comparator carries a
+ * prerelease, so `3.17.1-20260806120000` cannot satisfy it. Only the literal
+ * `3.17.1-20260806120000` does. That is what makes a consumer physically unable
+ * to float across published platform packages.
+ */
+function rangeMatches(range, version) {
+ const text = String(range ?? "").trim();
+ const operator = text.startsWith("^") ? "^" : text.startsWith("~") ? "~" : "";
+ const target = operator === "" ? text : text.slice(1);
+
+ const stamped = STAMPED_VERSION_PATTERN.exec(String(version ?? ""));
+ const plain = VALE_VERSION_PATTERN.exec(String(version ?? ""));
+ if (!stamped && !plain) {
+ throw new Error(`unsupported version: ${JSON.stringify(version)}`);
+ }
+
+ if (stamped) {
+ // The prerelease-exclusion rule. A caret or tilde range written over a
+ // plain Vale version has no prerelease anywhere in it, so it is out
+ // immediately; an exact range matches only when it is character-identical.
+ if (operator !== "") {
+ return false;
+ }
+ return target === String(version);
+ }
+
+ // A plain version against a plain range: ordinary caret/tilde semantics,
+ // included only so the tests can contrast the two cases.
+ const rangeParts = parseValeVersion(target);
+ const versionParts = parseValeVersion(String(version));
+ if (operator === "") {
+ return compareValeVersions(target, String(version)) === 0;
+ }
+ if (compareValeVersions(String(version), target) < 0) {
+ return false;
+ }
+ if (versionParts.major !== rangeParts.major) {
+ return false;
+ }
+ if (operator === "~" && versionParts.minor !== rangeParts.minor) {
+ return false;
+ }
+ if (operator === "^" && rangeParts.major === 0) {
+ // Caret below 1.0.0 narrows twice: `^0.y.z` allows the patch to float but
+ // pins the minor, and `^0.0.z` desugars to `>=0.0.z <0.0.(z+1)`, which is
+ // the single version itself.
+ if (rangeParts.minor === 0) {
+ return (
+ versionParts.minor === 0 && versionParts.patch === rangeParts.patch
+ );
+ }
+ return versionParts.minor === rangeParts.minor;
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Manifest
+//
+// .github/scripts/vale-manifest.json pins the upstream Vale version and, per
+// platform, the release asset, the member to unpack from it, and the SHA256 of
+// the ARCHIVE. The digest covers the archive rather than the executable because
+// that is what upstream publishes in vale__checksums.txt — so the
+// committed value is checkable against upstream, and the archive is verified
+// before anything is unpacked from it (D6).
+// ---------------------------------------------------------------------------
+
+const MANIFEST_PLATFORM_FIELDS = [
+ "package",
+ "directory",
+ "os",
+ "cpu",
+ "asset",
+ "archiveMember",
+ "sha256",
+];
+
+const SHA256_PATTERN = /^[\da-f]{64}$/;
+
+/**
+ * Validate a parsed manifest's shape and throw on the first problem. Called by
+ * both entry points before they do anything, so a hand-edit that drops a field
+ * fails immediately with a readable message rather than partway through a
+ * download loop.
+ */
+function assertManifest(manifest) {
+ if (typeof manifest !== "object" || manifest === null) {
+ throw new TypeError("manifest must be an object");
+ }
+ parseValeVersion(manifest.valeVersion);
+ const upstream = manifest.upstream;
+ if (typeof upstream !== "object" || upstream === null) {
+ throw new Error("manifest.upstream is missing");
+ }
+ for (const field of ["repository", "tag", "downloadUrl", "checksumsAsset"]) {
+ if (typeof upstream[field] !== "string" || upstream[field].length === 0) {
+ throw new Error(`manifest.upstream.${field} is missing`);
+ }
+ }
+ if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0) {
+ throw new Error("manifest.platforms must be a non-empty array");
+ }
+ const seen = new Set();
+ for (const platform of manifest.platforms) {
+ for (const field of MANIFEST_PLATFORM_FIELDS) {
+ if (typeof platform[field] !== "string" || platform[field].length === 0) {
+ throw new Error(
+ `manifest platform ${JSON.stringify(platform.package ?? "?")} is missing ${field}`
+ );
+ }
+ }
+ // The containment checks in vale-prepare.cjs's unpackMember run after
+ // extraction, so they cover the member's LEAF entry only: an intermediate
+ // directory component that is itself a symlink pointing out of the unpack
+ // directory would be followed by tar while writing, before there is any
+ // path to inspect. Requiring a flat filename deletes that case rather than
+ // leaving it to the extractor's own symlink refusal — and keeps the
+ // requirement here, where a hand-edited manifest is rejected up front,
+ // instead of as a comment someone has to notice.
+ if (
+ /[/\\]/.test(platform.archiveMember) ||
+ platform.archiveMember === ".."
+ ) {
+ throw new Error(
+ `manifest platform ${platform.package} has an archiveMember that is not a flat filename: ${platform.archiveMember}`
+ );
+ }
+ if (!SHA256_PATTERN.test(platform.sha256)) {
+ throw new Error(
+ `manifest platform ${platform.package} has a sha256 that is not 64 lowercase hex characters`
+ );
+ }
+ if (seen.has(platform.package)) {
+ throw new Error(`manifest lists ${platform.package} more than once`);
+ }
+ seen.add(platform.package);
+ }
+ return manifest;
+}
+
+/** Substitute `{version}` (and optionally `{asset}`) into a manifest template. */
+function applyTemplate(template, values) {
+ return String(template).replaceAll(/{(\w+)}/g, (whole, key) =>
+ Object.hasOwn(values, key) ? String(values[key]) : whole
+ );
+}
+
+/** The resolved asset filename for a platform at the manifest's Vale version. */
+function resolveAssetName(manifest, platform) {
+ return applyTemplate(platform.asset, { version: manifest.valeVersion });
+}
+
+/** The full download URL for a platform's archive. */
+function resolveDownloadUrl(manifest, platform) {
+ return applyTemplate(manifest.upstream.downloadUrl, {
+ version: manifest.valeVersion,
+ asset: resolveAssetName(manifest, platform),
+ });
+}
+
+/** The full download URL for the upstream checksums file. */
+function resolveChecksumsUrl(manifest, version = manifest.valeVersion) {
+ return applyTemplate(manifest.upstream.downloadUrl, {
+ version,
+ asset: applyTemplate(manifest.upstream.checksumsAsset, { version }),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Digest verification (D6)
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse an upstream `vale__checksums.txt` into a Map of asset name to
+ * digest. The format is the sha256sum one: `<64 hex> `, with the
+ * filename possibly prefixed by `*` for binary mode.
+ */
+function parseChecksumsFile(text) {
+ const digests = new Map();
+ for (const line of String(text ?? "").split("\n")) {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) {
+ continue;
+ }
+ const match = /^([\da-f]{64})\s+\*?(.+)$/i.exec(trimmed);
+ if (!match) {
+ continue;
+ }
+ digests.set(match[2].trim(), match[1].toLowerCase());
+ }
+ return digests;
+}
+
+/**
+ * Compare a computed digest against the committed one and throw on a mismatch.
+ * The thrown message names the asset and both digests, because the operator
+ * reading a failed run needs to tell "upstream re-cut the release" apart from
+ * "the manifest was updated without re-running the fetch".
+ *
+ * Callers treat this as fatal: nothing downstream of a mismatch runs, which is
+ * what the requirement "checksum mismatch aborts the release" means in practice.
+ */
+function assertChecksum({ asset, expected, actual }) {
+ const want = String(expected ?? "").toLowerCase();
+ const got = String(actual ?? "").toLowerCase();
+ if (!SHA256_PATTERN.test(want)) {
+ throw new Error(`no committed sha256 for ${asset}`);
+ }
+ if (want !== got) {
+ throw new Error(
+ `sha256 mismatch for ${asset}\n committed: ${want}\n downloaded: ${got}\nRefusing to package or publish unverified bytes.`
+ );
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Detect phase
+// ---------------------------------------------------------------------------
+
+/**
+ * Decide what the detect phase should do, given the pinned manifest, the latest
+ * upstream tag, and (when upstream is ahead) that release's checksums file.
+ *
+ * Returns `{ update: false, … }` when upstream is not ahead — the ONLY thing
+ * that bounds these runs. A published-version check could not: every publish
+ * stamps a timestamp npm has never seen, so it would report "not published" on
+ * every run and never suppress anything (D5).
+ *
+ * When upstream is ahead it returns the rewritten manifest, with `valeVersion`
+ * and every `sha256` taken from upstream's own checksums file and every other
+ * field left alone. A platform whose asset is absent from that file throws
+ * rather than silently carrying a stale digest forward, which would otherwise
+ * produce a manifest that fails verification only later, inside the publish run.
+ */
+function planManifestUpdate({ manifest, upstreamTag, checksumsText }) {
+ assertManifest(manifest);
+ const upstreamVersion = parseReleaseTag(upstreamTag);
+ if (!isUpstreamAhead(manifest.valeVersion, upstreamVersion)) {
+ return {
+ update: false,
+ pinnedVersion: manifest.valeVersion,
+ upstreamVersion,
+ manifest,
+ };
+ }
+
+ const digests = parseChecksumsFile(checksumsText);
+ if (digests.size === 0) {
+ throw new Error(
+ `upstream checksums file for ${upstreamVersion} parsed to no entries`
+ );
+ }
+
+ const platforms = manifest.platforms.map((platform) => {
+ const asset = applyTemplate(platform.asset, { version: upstreamVersion });
+ const sha256 = digests.get(asset);
+ if (sha256 === undefined) {
+ throw new Error(
+ `upstream ${upstreamVersion} publishes no asset named ${asset} (needed by ${platform.package})`
+ );
+ }
+ return { ...platform, sha256 };
+ });
+
+ return {
+ update: true,
+ pinnedVersion: manifest.valeVersion,
+ upstreamVersion,
+ manifest: { ...manifest, valeVersion: upstreamVersion, platforms },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Stamp phase
+// ---------------------------------------------------------------------------
+
+/**
+ * Plan the version write for every platform package: one version, computed
+ * once, applied identically across the set. Nothing here is per-platform, which
+ * is the point — six packages carrying the same upstream Vale build that
+ * disagreed about their version would make the CLI's exact pins unresolvable on
+ * some hosts and resolvable on others.
+ */
+function planStamp({ manifest, date }) {
+ assertManifest(manifest);
+ const version = stampVersion(manifest.valeVersion, date);
+ return {
+ version,
+ packages: manifest.platforms.map((platform) => ({
+ package: platform.package,
+ directory: platform.directory,
+ version,
+ })),
+ };
+}
+
+/**
+ * Apply a stamped version to a parsed `package.json`, returning a new object.
+ * Refuses to stamp a package.json whose version is neither the committed
+ * placeholder nor an already-stamped version, so a hand-edit that put a real
+ * version into a platform package is caught rather than overwritten silently.
+ */
+function applyStamp(packageJson, version) {
+ assertStampedVersion(version);
+ const current = String(packageJson.version ?? "");
+ if (current !== PLACEHOLDER_VERSION) {
+ assertStampedVersion(current);
+ }
+ return { ...packageJson, version };
+}
+
+module.exports = {
+ PLACEHOLDER_VERSION,
+ applyStamp,
+ applyTemplate,
+ assertChecksum,
+ assertManifest,
+ assertStampedVersion,
+ compareStampedVersions,
+ compareValeVersions,
+ formatStampTimestamp,
+ isUpstreamAhead,
+ parseChecksumsFile,
+ parseReleaseTag,
+ parseValeVersion,
+ planManifestUpdate,
+ planStamp,
+ rangeMatches,
+ resolveAssetName,
+ resolveChecksumsUrl,
+ resolveDownloadUrl,
+ stampVersion,
+};
diff --git a/.github/scripts/vale-release.test.cjs b/.github/scripts/vale-release.test.cjs
new file mode 100644
index 00000000..4c242fc8
--- /dev/null
+++ b/.github/scripts/vale-release.test.cjs
@@ -0,0 +1,481 @@
+// SPDX-License-Identifier: MIT
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { readFileSync } = require("node:fs");
+const { join } = require("node:path");
+
+const {
+ PLACEHOLDER_VERSION,
+ applyStamp,
+ assertChecksum,
+ assertManifest,
+ assertStampedVersion,
+ compareStampedVersions,
+ compareValeVersions,
+ formatStampTimestamp,
+ isUpstreamAhead,
+ parseChecksumsFile,
+ parseReleaseTag,
+ parseValeVersion,
+ planManifestUpdate,
+ planStamp,
+ rangeMatches,
+ resolveAssetName,
+ resolveChecksumsUrl,
+ resolveDownloadUrl,
+ stampVersion,
+} = require("./vale-release.cjs");
+
+/** The manifest as committed, so these tests fail if it drifts out of shape. */
+const COMMITTED_MANIFEST = JSON.parse(
+ readFileSync(join(__dirname, "vale-manifest.json"), "utf8")
+);
+
+const DIGEST_A = "a".repeat(64);
+const DIGEST_B = "b".repeat(64);
+
+function fixtureManifest() {
+ return {
+ valeVersion: "3.17.1",
+ upstream: {
+ repository: "errata-ai/vale",
+ tag: "v{version}",
+ downloadUrl:
+ "https://github.com/errata-ai/vale/releases/download/v{version}/{asset}",
+ checksumsAsset: "vale_{version}_checksums.txt",
+ },
+ platforms: [
+ {
+ package: "@taskless/vale-linux-x64",
+ directory: "packages/vale-linux-x64",
+ os: "linux",
+ cpu: "x64",
+ asset: "vale_{version}_Linux_64-bit.tar.gz",
+ archiveMember: "vale",
+ sha256: DIGEST_A,
+ },
+ {
+ package: "@taskless/vale-win32-x64",
+ directory: "packages/vale-win32-x64",
+ os: "win32",
+ cpu: "x64",
+ asset: "vale_{version}_Windows_64-bit.zip",
+ archiveMember: "vale.exe",
+ sha256: DIGEST_B,
+ },
+ ],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Version parsing and stamping (tasks 4.1, 4.2)
+// ---------------------------------------------------------------------------
+
+test("parseValeVersion: accepts a plain version, rejects a stamped one", () => {
+ assert.deepEqual(parseValeVersion("3.17.1"), {
+ major: 3,
+ minor: 17,
+ patch: 1,
+ });
+ assert.throws(() => parseValeVersion("3.17.1-20260806120000"), /plain Vale/);
+ assert.throws(() => parseValeVersion("v3.17.1"), /plain Vale/);
+ assert.throws(() => parseValeVersion("3.17"), /plain Vale/);
+ assert.throws(() => parseValeVersion(undefined), /plain Vale/);
+});
+
+test("parseReleaseTag: strips the upstream v prefix", () => {
+ assert.equal(parseReleaseTag("v3.17.1"), "3.17.1");
+ assert.equal(parseReleaseTag("3.17.1"), "3.17.1");
+ assert.throws(() => parseReleaseTag("v3.17"), /plain Vale/);
+});
+
+test("formatStampTimestamp: 14 UTC digits, no separators", () => {
+ assert.equal(
+ formatStampTimestamp(new Date("2026-08-06T12:00:00.000Z")),
+ "20260806120000"
+ );
+ // A non-UTC input is still stamped in UTC.
+ assert.equal(
+ formatStampTimestamp(new Date("2026-08-06T12:00:00.000+02:00")),
+ "20260806100000"
+ );
+ assert.throws(() => formatStampTimestamp("2026-08-06"), TypeError);
+ assert.throws(() => formatStampTimestamp(new Date("nope")), TypeError);
+});
+
+test("stampVersion: produces -", () => {
+ assert.equal(
+ stampVersion("3.17.1", new Date("2026-08-06T12:00:00Z")),
+ "3.17.1-20260806120000"
+ );
+});
+
+test("stampVersion: a bare Vale version can never be produced", () => {
+ // Every stamp carries a timestamp, so the output always contains a `-`.
+ for (const iso of [
+ "2026-01-01T00:00:00Z",
+ "2026-08-06T12:34:56Z",
+ "2099-12-31T23:59:59Z",
+ ]) {
+ const version = stampVersion("3.17.1", new Date(iso));
+ assert.notEqual(version, "3.17.1");
+ assert.match(version, /^3\.17\.1-\d{14}$/);
+ }
+ // And the assertion itself refuses a bare version outright.
+ assert.throws(() => assertStampedVersion("3.17.1"), /not a stamped version/);
+});
+
+test("assertStampedVersion: rejects a leading-zero timestamp", () => {
+ // A leading zero makes semver treat the identifier as alphanumeric, which
+ // would compare lexically and break monotonic ordering.
+ assert.throws(
+ () => assertStampedVersion("3.17.1-02608061200000"),
+ /leading zero/
+ );
+});
+
+test("assertStampedVersion: rejects a timestamp that is not a real date", () => {
+ assert.throws(
+ () => assertStampedVersion("3.17.1-20261340120000"),
+ /not a real UTC date/
+ );
+});
+
+test("assertStampedVersion: rejects other prerelease shapes", () => {
+ assert.throws(
+ () => assertStampedVersion("3.17.1-taskless.1"),
+ /not a stamped version/
+ );
+ assert.throws(
+ () => assertStampedVersion("3.17.1-2026080612000"),
+ /not a stamped version/
+ );
+ assert.throws(
+ () => assertStampedVersion("3.17.1+20260806120000"),
+ /not a stamped version/
+ );
+});
+
+test("assertStampedVersion: the timestamp stays a safe integer", () => {
+ const version = assertStampedVersion("3.17.1-20260806120000");
+ const timestamp = Number(version.split("-")[1]);
+ assert.ok(Number.isSafeInteger(timestamp));
+});
+
+// ---------------------------------------------------------------------------
+// Ordering (task 4.3)
+// ---------------------------------------------------------------------------
+
+test("compareStampedVersions: two runs produce ordered versions", () => {
+ const earlier = stampVersion("3.17.1", new Date("2026-08-06T12:00:00Z"));
+ const later = stampVersion("3.17.1", new Date("2026-08-06T12:00:01Z"));
+ assert.equal(compareStampedVersions(earlier, later), -1);
+ assert.equal(compareStampedVersions(later, earlier), 1);
+ assert.equal(compareStampedVersions(later, later), 0);
+});
+
+test("compareStampedVersions: a newer Vale version outranks any timestamp", () => {
+ assert.equal(
+ compareStampedVersions("3.17.1-29991231235959", "3.17.2-20260101000000"),
+ -1
+ );
+});
+
+test("compareValeVersions and isUpstreamAhead", () => {
+ assert.equal(compareValeVersions("3.17.1", "3.17.2"), -1);
+ assert.equal(compareValeVersions("3.18.0", "3.17.9"), 1);
+ assert.equal(compareValeVersions("3.17.1", "3.17.1"), 0);
+ assert.equal(isUpstreamAhead("3.17.1", "3.17.2"), true);
+ assert.equal(isUpstreamAhead("3.17.1", "3.17.1"), false);
+ assert.equal(isUpstreamAhead("3.17.1", "3.17.0"), false);
+});
+
+// ---------------------------------------------------------------------------
+// Range resolution (task 4.3 — D4 property 3)
+// ---------------------------------------------------------------------------
+
+test("rangeMatches: a caret or tilde range over the Vale version matches nothing", () => {
+ const version = "3.17.1-20260806120000";
+ assert.equal(rangeMatches("^3.17.1", version), false);
+ assert.equal(rangeMatches("~3.17.1", version), false);
+ assert.equal(rangeMatches("^3.0.0", version), false);
+ assert.equal(rangeMatches("~3.17.0", version), false);
+});
+
+test("rangeMatches: only the literal exact version resolves", () => {
+ const version = "3.17.1-20260806120000";
+ assert.equal(rangeMatches(version, version), true);
+ assert.equal(rangeMatches("3.17.1", version), false);
+ assert.equal(rangeMatches("3.17.1-20260806120001", version), false);
+});
+
+test("rangeMatches: plain versions keep ordinary caret/tilde semantics", () => {
+ // The contrast that makes the prerelease exclusion above meaningful: the same
+ // ranges do match when the version carries no prerelease.
+ assert.equal(rangeMatches("^3.17.1", "3.18.0"), true);
+ assert.equal(rangeMatches("~3.17.1", "3.17.9"), true);
+ assert.equal(rangeMatches("~3.17.1", "3.18.0"), false);
+ assert.equal(rangeMatches("^3.17.1", "4.0.0"), false);
+ assert.equal(rangeMatches("^3.17.1", "3.17.0"), false);
+});
+
+test("rangeMatches: caret below 1.0.0 narrows the way semver narrows it", () => {
+ // `^0.y.z` pins the minor; `^0.0.z` pins everything.
+ assert.equal(rangeMatches("^0.2.1", "0.2.9"), true);
+ assert.equal(rangeMatches("^0.2.1", "0.3.0"), false);
+ assert.equal(rangeMatches("^0.0.1", "0.0.1"), true);
+ assert.equal(rangeMatches("^0.0.1", "0.0.2"), false);
+ assert.equal(rangeMatches("^0.0.1", "0.1.0"), false);
+});
+
+// ---------------------------------------------------------------------------
+// Stamping the set (task 4.1, 4.3)
+// ---------------------------------------------------------------------------
+
+test("planStamp: the whole set shares one version", () => {
+ const plan = planStamp({
+ manifest: COMMITTED_MANIFEST,
+ date: new Date("2026-08-06T12:00:00Z"),
+ });
+ assert.equal(
+ plan.version,
+ `${COMMITTED_MANIFEST.valeVersion}-20260806120000`
+ );
+ assert.equal(plan.packages.length, 6);
+ assert.equal(new Set(plan.packages.map((p) => p.version)).size, 1);
+ for (const entry of plan.packages) {
+ assert.equal(entry.version, plan.version);
+ }
+});
+
+test("planStamp: covers every committed platform package", () => {
+ const plan = planStamp({
+ manifest: COMMITTED_MANIFEST,
+ date: new Date("2026-08-06T12:00:00Z"),
+ });
+ assert.deepEqual(plan.packages.map((p) => p.package).sort(), [
+ "@taskless/vale-darwin-arm64",
+ "@taskless/vale-darwin-x64",
+ "@taskless/vale-linux-arm64",
+ "@taskless/vale-linux-x64",
+ "@taskless/vale-win32-arm64",
+ "@taskless/vale-win32-x64",
+ ]);
+});
+
+test("applyStamp: replaces the placeholder and re-stamps a stamped package", () => {
+ const stamped = applyStamp(
+ { name: "@taskless/vale-linux-x64", version: PLACEHOLDER_VERSION },
+ "3.17.1-20260806120000"
+ );
+ assert.equal(stamped.version, "3.17.1-20260806120000");
+ assert.equal(
+ applyStamp(stamped, "3.17.1-20260806120001").version,
+ "3.17.1-20260806120001"
+ );
+});
+
+test("applyStamp: refuses a package.json carrying an unexpected version", () => {
+ assert.throws(
+ () => applyStamp({ version: "1.2.3" }, "3.17.1-20260806120000"),
+ /not a stamped version/
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Manifest validation and templating
+// ---------------------------------------------------------------------------
+
+test("assertManifest: the committed manifest is well formed", () => {
+ assert.doesNotThrow(() => assertManifest(COMMITTED_MANIFEST));
+ assert.equal(COMMITTED_MANIFEST.platforms.length, 6);
+});
+
+test("assertManifest: every committed platform has a package directory entry", () => {
+ for (const platform of COMMITTED_MANIFEST.platforms) {
+ assert.equal(
+ platform.directory,
+ `packages/${platform.package.replace("@taskless/", "")}`
+ );
+ }
+});
+
+test("assertManifest: rejects a missing field, a bad digest, and a duplicate", () => {
+ const missing = fixtureManifest();
+ delete missing.platforms[0].archiveMember;
+ assert.throws(() => assertManifest(missing), /missing archiveMember/);
+
+ const badDigest = fixtureManifest();
+ badDigest.platforms[0].sha256 = "not-a-digest";
+ assert.throws(() => assertManifest(badDigest), /64 lowercase hex/);
+
+ const duplicate = fixtureManifest();
+ duplicate.platforms[1].package = duplicate.platforms[0].package;
+ assert.throws(() => assertManifest(duplicate), /more than once/);
+
+ const noUpstream = fixtureManifest();
+ delete noUpstream.upstream.downloadUrl;
+ assert.throws(() => assertManifest(noUpstream), /upstream\.downloadUrl/);
+});
+
+test("assertManifest: an archiveMember must be a flat filename", () => {
+ // unpackMember's containment checks run after extraction and only cover the
+ // leaf entry, so a nested member would let a symlinked intermediate
+ // directory be followed by tar before there is a path to inspect. Keeping
+ // members flat is what removes that case, so it is asserted, not assumed.
+ for (const member of ["bin/vale", "../vale", "..", "a\\vale.exe"]) {
+ const nested = fixtureManifest();
+ nested.platforms[0].archiveMember = member;
+ assert.throws(() => assertManifest(nested), /not a flat filename/);
+ }
+
+ const flat = fixtureManifest();
+ flat.platforms[0].archiveMember = "vale.exe";
+ assert.equal(assertManifest(flat), flat);
+});
+
+test("resolve*: templates expand against the pinned version", () => {
+ const manifest = fixtureManifest();
+ const [linux] = manifest.platforms;
+ assert.equal(
+ resolveAssetName(manifest, linux),
+ "vale_3.17.1_Linux_64-bit.tar.gz"
+ );
+ assert.equal(
+ resolveDownloadUrl(manifest, linux),
+ "https://github.com/errata-ai/vale/releases/download/v3.17.1/vale_3.17.1_Linux_64-bit.tar.gz"
+ );
+ assert.equal(
+ resolveChecksumsUrl(manifest),
+ "https://github.com/errata-ai/vale/releases/download/v3.17.1/vale_3.17.1_checksums.txt"
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Digest verification (task 3.4)
+// ---------------------------------------------------------------------------
+
+test("parseChecksumsFile: parses the upstream sha256sum format", () => {
+ const digests = parseChecksumsFile(
+ [
+ `${DIGEST_A} vale_3.17.1_Linux_64-bit.tar.gz`,
+ `${DIGEST_B} *vale_3.17.1_Windows_64-bit.zip`,
+ "",
+ "garbage line that is not a digest",
+ ].join("\n")
+ );
+ assert.equal(digests.size, 2);
+ assert.equal(digests.get("vale_3.17.1_Linux_64-bit.tar.gz"), DIGEST_A);
+ assert.equal(digests.get("vale_3.17.1_Windows_64-bit.zip"), DIGEST_B);
+});
+
+test("assertChecksum: a matching digest succeeds", () => {
+ assert.equal(
+ assertChecksum({ asset: "a.tar.gz", expected: DIGEST_A, actual: DIGEST_A }),
+ true
+ );
+ // Case is normalized on both sides.
+ assert.equal(
+ assertChecksum({
+ asset: "a.tar.gz",
+ expected: DIGEST_A.toUpperCase(),
+ actual: DIGEST_A,
+ }),
+ true
+ );
+});
+
+test("assertChecksum: a mismatched digest aborts", () => {
+ assert.throws(
+ () =>
+ assertChecksum({
+ asset: "vale_3.17.1_Linux_64-bit.tar.gz",
+ expected: DIGEST_A,
+ actual: DIGEST_B,
+ }),
+ /sha256 mismatch for vale_3\.17\.1_Linux_64-bit\.tar\.gz[\S\s]*Refusing to package or publish/
+ );
+});
+
+test("assertChecksum: an absent committed digest aborts", () => {
+ assert.throws(
+ () => assertChecksum({ asset: "a.tar.gz", actual: DIGEST_A }),
+ /no committed sha256/
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Detect phase (tasks 5.2, 5.4)
+// ---------------------------------------------------------------------------
+
+test("planManifestUpdate: upstream unchanged plans nothing", () => {
+ const result = planManifestUpdate({
+ manifest: fixtureManifest(),
+ upstreamTag: "v3.17.1",
+ checksumsText: "",
+ });
+ assert.equal(result.update, false);
+ assert.equal(result.upstreamVersion, "3.17.1");
+});
+
+test("planManifestUpdate: an older upstream tag plans nothing", () => {
+ const result = planManifestUpdate({
+ manifest: fixtureManifest(),
+ upstreamTag: "v3.17.0",
+ checksumsText: "",
+ });
+ assert.equal(result.update, false);
+});
+
+test("planManifestUpdate: a newer upstream rewrites version and digests only", () => {
+ const manifest = fixtureManifest();
+ const result = planManifestUpdate({
+ manifest,
+ upstreamTag: "v3.18.0",
+ checksumsText: [
+ `${DIGEST_B} vale_3.18.0_Linux_64-bit.tar.gz`,
+ `${DIGEST_A} vale_3.18.0_Windows_64-bit.zip`,
+ ].join("\n"),
+ });
+ assert.equal(result.update, true);
+ assert.equal(result.pinnedVersion, "3.17.1");
+ assert.equal(result.manifest.valeVersion, "3.18.0");
+ assert.equal(result.manifest.platforms[0].sha256, DIGEST_B);
+ assert.equal(result.manifest.platforms[1].sha256, DIGEST_A);
+ // Asset templates are untouched, so they still carry {version}.
+ assert.equal(
+ result.manifest.platforms[0].asset,
+ "vale_{version}_Linux_64-bit.tar.gz"
+ );
+ assert.equal(result.manifest.platforms[0].archiveMember, "vale");
+ // And the input manifest is not mutated.
+ assert.equal(manifest.valeVersion, "3.17.1");
+ assert.equal(manifest.platforms[0].sha256, DIGEST_A);
+ assert.doesNotThrow(() => assertManifest(result.manifest));
+});
+
+test("planManifestUpdate: a platform missing from upstream's checksums aborts", () => {
+ assert.throws(
+ () =>
+ planManifestUpdate({
+ manifest: fixtureManifest(),
+ upstreamTag: "v3.18.0",
+ checksumsText: `${DIGEST_B} vale_3.18.0_Linux_64-bit.tar.gz`,
+ }),
+ /publishes no asset named vale_3\.18\.0_Windows_64-bit\.zip/
+ );
+});
+
+test("planManifestUpdate: an unparseable checksums file aborts", () => {
+ assert.throws(
+ () =>
+ planManifestUpdate({
+ manifest: fixtureManifest(),
+ upstreamTag: "v3.18.0",
+ checksumsText: "404: Not Found",
+ }),
+ /parsed to no entries/
+ );
+});
diff --git a/.github/workflows/vale-binaries.yml b/.github/workflows/vale-binaries.yml
new file mode 100644
index 00000000..63fb14ed
--- /dev/null
+++ b/.github/workflows/vale-binaries.yml
@@ -0,0 +1,298 @@
+# SPDX-License-Identifier: MIT
+# Vale platform packages — detect upstream releases, then publish @taskless/vale-*.
+#
+# Standalone by design. This workflow does not touch release.yml and release.yml
+# does not touch these packages: they are in the changesets `ignore` list, their
+# versions are stamped here rather than bumped by a changeset, and release.yml's
+# "is main's version on npm yet?" check reads packages/cli/package.json only.
+#
+# TWO PHASES, because the trust boundary is code review (design D6):
+#
+# detect Runs on a schedule with NO npm credential and NO OIDC identity. It
+# compares the latest upstream Vale release against the version
+# pinned in .github/scripts/vale-manifest.json. When upstream is
+# ahead it opens a pull request that updates the pinned version and
+# all six SHA256 digests, taken from upstream's own checksums file.
+# It publishes nothing.
+#
+# publish Runs on the push to main that merges that pull request — i.e. once
+# a human has reviewed the digests. Split further into `prepare` and
+# `publish` below.
+#
+# A single job that discovered a digest and then verified downloads against the
+# digest it had just discovered would verify nothing. Splitting the phases is
+# what makes the automation trustworthy: nothing is published on bytes nobody
+# signed off on, and nobody has to notice a Vale release for the process to run.
+#
+# WHAT BOUNDS A RUN is the upstream-version comparison, and only that. A "is
+# this version already on npm?" check — the thing release.yml uses — cannot work
+# here: every publish stamps -, a version npm has
+# never seen, so such a check would answer "not published" every time and could
+# never suppress anything (design D5).
+#
+# WHY prepare AND publish ARE SEPARATE JOBS: `prepare` downloads third-party
+# bytes off the internet. It holds `contents: read`, no environment, and no
+# id-token, so it cannot publish or mint a token no matter what it downloads. It
+# verifies every archive against the committed digest, aborts the run on a
+# mismatch before anything is unpacked, and hands over `npm pack` tarballs. The
+# credentialed `publish` job therefore only ever handles bytes that already
+# matched a reviewed digest and are already sealed into a tarball. It does not
+# even check out the repository.
+#
+# WHY PACK BEFORE UPLOADING: actions/upload-artifact does not preserve file
+# modes, and the Vale executable has to reach npm with its executable bit set.
+# `npm pack` records modes inside the .tgz, so packing first and shipping the
+# tarball through the artifact keeps 0755 intact end to end.
+#
+# PUBLISHING IDENTITY is inherited, not reinvented: npm trusted publishing, a
+# short-lived OIDC-minted token bound to the `npm-production` environment, with
+# no stored NPM_TOKEN anywhere. That binding is registered PER PACKAGE on
+# npmjs.com and there is nothing to bind until the package name exists, so the
+# FIRST publish of each of the six names is a deliberate one-time manual step by
+# a maintainer, who then registers the trusted publisher. This workflow assumes
+# that has already happened for every name in the manifest, and there is no
+# fallback token path here on purpose.
+#
+# BOOTSTRAPPING A NEW PACKAGE NAME, once:
+#
+# node .github/scripts/vale-prepare.cjs --out .vale-dist
+# npm publish --access public --tag latest .vale-dist/.tgz
+# git checkout -- packages/vale-*/package.json # drop the local stamp
+#
+# Publish the packed tarball rather than the directory: the committed
+# package.json carries the placeholder version 0.0.0 and no binary, so a bare
+# `npm publish` in a package directory would burn the name on an empty 0.0.0.
+# Provenance is omitted from the manual step (it needs a CI OIDC identity);
+# register the trusted publisher afterwards and every later publish gets it.
+#
+# Action refs are pinned to commit SHAs; the trailing comment records the tag.
+
+name: Vale Binaries
+
+on:
+ # Detect only. Weekly is a deliberate cadence choice: a Vale security release
+ # should be mirrored faster than that, which is what the manual `detect`
+ # dispatch below is for.
+ schedule:
+ - cron: "23 7 * * 1"
+
+ # Publish. This path fires exactly when a reviewed manifest change lands on
+ # main, which is the merge of a detect pull request. An ordinary push to main
+ # does not touch the manifest, so it starts no job here at all.
+ push:
+ branches: [main]
+ paths:
+ - ".github/scripts/vale-manifest.json"
+
+ workflow_dispatch:
+ inputs:
+ phase:
+ description: "detect: check upstream and open a PR. publish: stamp and publish the pinned version."
+ type: choice
+ options:
+ - detect
+ - publish
+ default: detect
+
+# No workflow-wide grants; each job asks for exactly what it needs.
+permissions: {}
+
+# One at a time, so a scheduled detect cannot race a publish.
+concurrency: vale-binaries
+
+jobs:
+ detect:
+ name: Detect upstream Vale
+ if: >-
+ github.event_name == 'schedule' ||
+ (github.event_name == 'workflow_dispatch' && inputs.phase == 'detect')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # push the vale/update- branch
+ pull-requests: write # open the manifest update PR
+ steps:
+ # Credentials persist here because this job pushes a branch. It holds no
+ # npm identity and no id-token, and it never runs downloaded code.
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 24
+
+ # No install step: the script is zero-dependency CommonJS. GITHUB_TOKEN is
+ # passed only to raise the GitHub API rate limit; the endpoints are public.
+ - id: detect
+ run: node .github/scripts/vale-detect.cjs --write
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Every value reaching the shell below goes through `env:` rather than
+ # `${{ }}` interpolation into the script body. The version has already been
+ # validated as major.minor.patch by the script, but the pattern is the
+ # rule regardless of the value.
+ - name: Open the manifest update PR
+ if: steps.detect.outputs.update == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VALE_VERSION: ${{ steps.detect.outputs.vale_version }}
+ PINNED_VERSION: ${{ steps.detect.outputs.pinned_version }}
+ run: |
+ branch="vale/update-${VALE_VERSION}"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ if git diff --quiet -- .github/scripts/vale-manifest.json; then
+ echo "Manifest already pins ${VALE_VERSION}; nothing to propose."
+ exit 0
+ fi
+
+ git checkout -b "$branch"
+ git add .github/scripts/vale-manifest.json
+ git commit -m "chore(vale): pin Vale ${VALE_VERSION}"
+ git push --force origin "$branch"
+
+ if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
+ echo "A pull request for $branch is already open; it now carries the current digests."
+ exit 0
+ fi
+
+ gh pr create \
+ --base main \
+ --head "$branch" \
+ --title "chore(vale): pin Vale ${VALE_VERSION}" \
+ --body "$(cat <<'BODY'
+ Upstream Vale is ahead of the version this repository packages.
+
+ This updates `.github/scripts/vale-manifest.json` to the new upstream
+ version and replaces every platform's SHA256 with the digest from
+ upstream's `vale__checksums.txt`.
+
+ **Reviewing this is the trust boundary.** Merging it authorizes the
+ publish phase to download those exact archives and package them; a
+ download that does not match a digest here aborts the run and
+ publishes nothing. Check the digests against upstream's checksums file
+ for the release before approving.
+
+ Merging this triggers the publish phase, which stamps every platform
+ package `-` and publishes the set. That
+ publish is inert on its own: `@taskless/cli` pins exact versions, so
+ nothing reaches a consumer until that pin is deliberately bumped.
+ BODY
+ )" \
+ --label skip-changeset
+
+ # Credential-free. Downloads third-party bytes, verifies them against the
+ # reviewed digests, and produces tarballs. Cannot publish anything.
+ prepare:
+ name: Fetch, verify, stamp, pack
+ if: >-
+ github.event_name == 'push' ||
+ (github.event_name == 'workflow_dispatch' && inputs.phase == 'publish')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read # checkout only
+ outputs:
+ version: ${{ steps.prepare.outputs.version }}
+ vale_version: ${{ steps.prepare.outputs.vale_version }}
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false # nothing here writes to git
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 24
+
+ # No dependency install at all: the script is zero-dependency CommonJS and
+ # unpacks with `tar` and `unzip`, both present on ubuntu-latest. Nothing
+ # from npm runs in this job beyond `npm pack` on our own packages.
+ - id: prepare
+ run: node .github/scripts/vale-prepare.cjs --out .vale-dist
+
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: vale-tarballs
+ path: .vale-dist/*.tgz
+ if-no-files-found: error
+ retention-days: 1
+
+ publish:
+ name: Publish to npm
+ needs: prepare
+ runs-on: ubuntu-latest
+ # The scoping and audit boundary for the release, and where the npm trusted
+ # publisher for each @taskless/vale-* package is bound.
+ environment: npm-production
+ permissions:
+ contents: read
+ id-token: write # OIDC → short-lived npm auth + build provenance
+ steps:
+ # Deliberately no checkout. This job publishes tarballs the previous job
+ # already verified and sealed; it has no reason to hold repository source
+ # while an OIDC identity exists.
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: vale-tarballs
+ path: tarballs
+
+ # OIDC trusted publishing and provenance need npm >= 11.5.1. Pinned, not
+ # @latest, so publish behavior cannot change unreviewed. --ignore-scripts:
+ # no lifecycle code runs while the OIDC identity is available.
+ - run: npm install -g npm@12.0.1 --ignore-scripts
+
+ # `--tag latest` is required, not cosmetic: every version here is a semver
+ # prerelease by design (D4), and npm refuses to publish a prerelease onto
+ # the default tag without being told. Tagging them `latest` is right —
+ # these are not preview builds, they are the only builds, and the
+ # prerelease component exists to defeat range matching, not to signal
+ # instability.
+ - name: Publish every platform tarball
+ working-directory: tarballs
+ env:
+ STAMPED_VERSION: ${{ needs.prepare.outputs.version }}
+ run: |
+ shopt -s nullglob
+ tarballs=(*.tgz)
+ if [ "${#tarballs[@]}" -eq 0 ]; then
+ echo "No tarballs in the artifact — refusing to report success." >&2
+ exit 1
+ fi
+ echo "Publishing ${#tarballs[@]} package(s) at ${STAMPED_VERSION}."
+
+ # The loop deliberately does not stop at the first failure, and the
+ # step deliberately does not lean on `set -e` here. Six sequential
+ # publishes are six chances for a transient registry error, and
+ # aborting midway leaves the set partially released — some platforms
+ # resolvable at this version, others not, which is the one state the
+ # CLI's exact cross-package pins cannot tolerate. Attempting all six
+ # and failing at the end means one re-run has at most the stragglers
+ # left to do.
+ #
+ # That re-run is what the `npm view` guard is for: a tarball is
+ # immutable and its version is stamped once, in prepare, so a package
+ # already at this version was published by an earlier attempt of this
+ # same release. Skipping it is idempotent, where re-publishing would
+ # fail with "cannot publish over the previously published version"
+ # and strand every package after it.
+ failed=()
+ for tarball in "${tarballs[@]}"; do
+ echo "::group::$tarball"
+ name="$(tar -xzOf "$tarball" package/package.json | node -e 'let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; }).on("end", () => { console.log(JSON.parse(raw).name); });')"
+ if npm view "${name}@${STAMPED_VERSION}" version >/dev/null 2>&1; then
+ echo "${name}@${STAMPED_VERSION} is already published — skipping."
+ elif ! npm publish --provenance --access public --tag latest "./$tarball"; then
+ echo "::error::failed to publish ${name}@${STAMPED_VERSION}"
+ failed+=("$name")
+ fi
+ echo "::endgroup::"
+ done
+
+ if [ "${#failed[@]}" -ne 0 ]; then
+ echo "Failed to publish ${#failed[@]} of ${#tarballs[@]} package(s) at ${STAMPED_VERSION}:" >&2
+ printf ' %s\n' "${failed[@]}" >&2
+ echo "Re-run this job to retry them; already-published packages are skipped." >&2
+ exit 1
+ fi
+ echo "All ${#tarballs[@]} package(s) are published at ${STAMPED_VERSION}."
diff --git a/.gitignore b/.gitignore
index 0b521c7c..d502dd69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ node_modules/
/.claude/skills/taskless/
/.claude/commands/tskl/
+# Vale platform-package tarballs (npm pack output from vale-prepare.cjs)
+.vale-dist/
+
# Misc
tmp
# Auto-generated by dotagents — do not commit these files.
diff --git a/openspec/changes/add-vale-binary-packages/tasks.md b/openspec/changes/add-vale-binary-packages/tasks.md
index ddf2f9f0..3efa61f5 100644
--- a/openspec/changes/add-vale-binary-packages/tasks.md
+++ b/openspec/changes/add-vale-binary-packages/tasks.md
@@ -15,22 +15,22 @@
## 3. Checksums and fetch
- [x] 3.1 Commit a checksum manifest recording the SHA256 of each platform's upstream release asset for the pinned Vale version
-- [ ] 3.2 Write the fetch step: download each platform asset, verify against the committed checksum, fail loudly on mismatch, and unpack the executable into its package directory preserving the executable bit
-- [ ] 3.3 Ensure verification runs in a credential-free step, so no credentialed step handles unverified bytes
-- [ ] 3.4 Tests: a mismatched checksum aborts and publishes nothing; a matching one yields an executable file in the expected location
+- [x] 3.2 Write the fetch step: download each platform asset, verify against the committed checksum, fail loudly on mismatch, and unpack the executable into its package directory preserving the executable bit
+- [x] 3.3 Ensure verification runs in a credential-free step, so no credentialed step handles unverified bytes
+- [x] 3.4 Tests: a mismatched checksum aborts and publishes nothing; a matching one yields an executable file in the expected location
## 4. Version stamping
-- [ ] 4.1 Write the stamping step: set every platform package to `-` (UTC), identically across the set
-- [ ] 4.2 Assert the stamped version parses as a valid semver prerelease, that the timestamp is a numeric identifier with no leading zero, and that a plain `` is never produced
-- [ ] 4.3 Tests: two runs produce ordered versions; the whole set shares one version; a caret range over the Vale version matches nothing
+- [x] 4.1 Write the stamping step: set every platform package to `-` (UTC), identically across the set
+- [x] 4.2 Assert the stamped version parses as a valid semver prerelease, that the timestamp is a numeric identifier with no leading zero, and that a plain `` is never produced
+- [x] 4.3 Tests: two runs produce ordered versions; the whole set shares one version; a caret range over the Vale version matches nothing
## 5. Release workflow
-- [ ] 5.1 Add a standalone workflow in two phases, no coupling to `release.yml`: **detect** — on a schedule, compare upstream Vale against what is published and open a PR updating the pinned version + checksums, publishing nothing; **publish** — on merge of that PR, run fetch → verify → stamp → pack → publish against the reviewed checksums
-- [ ] 5.2 Bound runs by the upstream comparison, not a published-version check — a fresh timestamp is never already on npm, so that check can never suppress a run
-- [ ] 5.3 Follow the existing hardening conventions: SHA-pinned action refs, no workflow-wide permission grants, no `${{ }}` interpolation of untrusted text into `run:`, OIDC trusted publishing bound to the `npm-production` environment, `--ignore-scripts` on install
-- [ ] 5.4 Verify an ordinary push to `main` publishes no platform package, and that a run with upstream unchanged publishes nothing
+- [x] 5.1 Add a standalone workflow in two phases, no coupling to `release.yml`: **detect** — on a schedule, compare upstream Vale against what is published and open a PR updating the pinned version + checksums, publishing nothing; **publish** — on merge of that PR, run fetch → verify → stamp → pack → publish against the reviewed checksums
+- [x] 5.2 Bound runs by the upstream comparison, not a published-version check — a fresh timestamp is never already on npm, so that check can never suppress a run
+- [x] 5.3 Follow the existing hardening conventions: SHA-pinned action refs, no workflow-wide permission grants, no `${{ }}` interpolation of untrusted text into `run:`, OIDC trusted publishing bound to the `npm-production` environment, `--ignore-scripts` on install
+- [x] 5.4 Verify an ordinary push to `main` publishes no platform package, and that a run with upstream unchanged publishes nothing
## 6. CLI wiring
@@ -40,6 +40,6 @@
## 7. Quality gates
-- [ ] 7.1 `pnpm typecheck && pnpm lint && pnpm test` clean at the repo root
-- [ ] 7.2 Dry-run the release workflow end to end without publishing, and confirm the packed tarball contains the executable with its permission bit
+- [x] 7.1 `pnpm typecheck && pnpm lint && pnpm test` clean at the repo root
+- [x] 7.2 Dry-run the release workflow end to end without publishing, and confirm the packed tarball contains the executable with its permission bit
- [ ] 7.3 Once published, remove tasks 5.1b–5.1e from `add-vale-rule-engine`, which reduces to the runtime resolution (its task 5.1) alone