From 7948f5e95a1bfed94a7ed371f8fc4b2365280cce Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 21 Aug 2026 16:30:17 -0700 Subject: [PATCH 1/3] docs(cli): add version, build, and nightly badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three badges that need no infrastructure of their own: - `npm` reads the published @taskless/cli version from the registry - `build` reads the Validate workflow's status on main - `nightly` is a STATIC badge linking to @taskless/cli-nightly The nightly badge carries no version on purpose. A nightly version is `0.11.0-20260821175223xf9f05c0` — 31 characters that would dominate the row while telling a reader nothing they can act on, and anyone who wants the exact version is one click from npm, where it cannot be stale. `npm/v/@taskless/cli-nightly/latest` was considered and rejected for that. Being static also keeps it out of the publish loop: a badge derived from each nightly would be committed on every nightly, and that commit re-triggers Validate, which re-triggers the nightly. These live in the CLI README rather than the root one because all three describe the published package, and this is the README npm renders on the package page. `nightly-pack.cjs` swaps in a generated README at pack time for the nightly tarball only, so it is unaffected. Refs OSS-43 --- packages/cli/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index b6c3fc2e..c6f689f1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,5 +1,9 @@ # @taskless/cli +[![npm](https://img.shields.io/npm/v/@taskless/cli)](https://www.npmjs.com/package/@taskless/cli) +[![build](https://img.shields.io/github/actions/workflow/status/taskless/cli/validate.yml?branch=main)](https://github.com/taskless/cli/actions/workflows/validate.yml?query=branch%3Amain) +[![nightly](https://img.shields.io/badge/nightly-npm-blue)](https://www.npmjs.com/package/@taskless/cli-nightly) + CLI companion for [Taskless](https://taskless.io). Designed to work with agent skills to add constraints that improve coding agent output. ## Install From 3c95f7ece3d1f7fd4a97017b41fc4b767f1b76cd Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 22 Aug 2026 01:15:14 -0700 Subject: [PATCH 2/3] ci: add vale and sg toolchain badges with a scheduled refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shields cannot ask "is there a newer upstream we have not pinned", so the comparison runs here and the color rule lives in code: `.shields/*.json` is a shields endpoint payload, committed and served off raw. - `vale-detect.cjs` gains `--json`, which prints and returns { pinned, upstream, ahead } and stops before the checksums download. The badge needs the comparison the script already holds as data; scraping its human "pinned: X upstream latest: vY" line would rebuild that fact with a regex. `--json` is read-only and refuses `--write`. - `sg-detect.cjs` is new, because ast-grep had NO upstream check at all: the eight @ast-grep/cli* pins sat at 0.41.0 with nothing watching them (upstream is 0.45.1). Its upstream is an npm dist-tag rather than a GitHub release, its pinned version comes from the dependency pins rather than a manifest, and disagreeing or non-exact pins abort rather than resolve to a version the badge cannot honestly show. - `update-badges.cjs` calls both in-process and writes a payload only when the rendered badge actually changes. STALENESS IS IN THE MESSAGE, not in a sidecar field. A badge that stopped updating looks exactly like one whose value has not changed, so the message is ` · `. A `checkedAt` key nobody opens is a record, not a signal — and it risks shields rejecting an unrecognized key. The date advances only once it is 7 days old, so the daily schedule still notices an upstream release within a day while a quiet week costs at most one commit per badge. `update-badges.yml` is a SEPARATE workflow, never a job in a publishing one: it needs `contents: write`, and the release workflows hold `id-token: write` plus an npm OIDC identity. Separate files make that separation structural rather than a matter of whoever reviews the next edit. It also cannot live in release-vale.yml, whose detect phase opens the very pull request the yellow badge is warning about. The header records why a per-nightly badge would loop forever (publish → commit → Validate → publish, which the nightly's per-sha dedupe cannot break) and why a badge derived from upstream terminates. Refs OSS-43 --- .github/scripts/sg-detect.cjs | 219 +++++++++++++++++++++++++ .github/scripts/sg-detect.test.cjs | 171 +++++++++++++++++++ .github/scripts/update-badges.cjs | 194 ++++++++++++++++++++++ .github/scripts/update-badges.test.cjs | 199 ++++++++++++++++++++++ .github/scripts/vale-detect.cjs | 51 +++++- .github/scripts/vale-detect.test.cjs | 50 +++++- .github/workflows/update-badges.yml | 114 +++++++++++++ .shields/sg.json | 6 + .shields/vale.json | 6 + packages/cli/README.md | 2 + 10 files changed, 1001 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/sg-detect.cjs create mode 100644 .github/scripts/sg-detect.test.cjs create mode 100644 .github/scripts/update-badges.cjs create mode 100644 .github/scripts/update-badges.test.cjs create mode 100644 .github/workflows/update-badges.yml create mode 100644 .shields/sg.json create mode 100644 .shields/vale.json diff --git a/.github/scripts/sg-detect.cjs b/.github/scripts/sg-detect.cjs new file mode 100644 index 00000000..dc56f699 --- /dev/null +++ b/.github/scripts/sg-detect.cjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * ast-grep — upstream detection. + * + * Answers one question: is the `@ast-grep/cli*` version pinned in + * packages/cli/package.json behind the version npm serves as `latest`? + * + * WHY THIS EXISTS AT ALL. Vale has had `vale-detect.cjs` since the platform + * packages were built, so a Vale release is noticed by a machine. ast-grep had + * nothing: the eight `@ast-grep/cli*` pins sat wherever someone last put them + * and nobody compared them to upstream. The `sg` badge therefore reports a + * number that had no watcher, which is the whole reason it is worth rendering. + * + * WHERE IT DIVERGES FROM vale-detect.cjs, and why the divergence is not + * carelessness: + * + * - Upstream is an npm DIST-TAG, not a GitHub release. ast-grep ships to npm, + * and what a consumer would get from `@ast-grep/cli@latest` is the honest + * definition of "what we are behind by". Reading GitHub releases instead + * would compare our pin against a tag that may not be on npm yet. + * + * - The pinned version is read from packages/cli/package.json, not from a + * manifest. There is no manifest to read: nothing here repackages ast-grep, + * so the dependency pins ARE the record of what we ship. + * + * - The version comparison is local rather than imported from + * vale-release.cjs. Its `parseValeVersion` would do the arithmetic + * correctly and then report `not a plain Vale version: "0.45.x"` when + * ast-grep publishes something unexpected — an error message that lies + * about which dependency is in trouble. Fifteen lines of comparator is a + * better trade than a wrong error and a coupling that makes a Vale-specific + * edit able to break this badge. + * + * Usage: + * node .github/scripts/sg-detect.cjs [--json] + * + * --json print `{ pinned, upstream, ahead }` and nothing else. This script + * never writes anything in either mode; unlike Vale there is no + * manifest to rewrite, and bumping eight dependency pins is a + * lockfile-touching change that belongs to a human. + * + * Outputs (appended to $GITHUB_OUTPUT when set): + * update "true" when upstream is ahead + * sg_version the upstream version + * pinned_version the version currently pinned in packages/cli/package.json + */ + +const { appendFileSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const PACKAGE_JSON_PATH = join( + __dirname, + "..", + "..", + "packages", + "cli", + "package.json" +); + +/** `@ast-grep/cli` itself and its per-platform siblings. */ +const PIN_PATTERN = /^@ast-grep\/cli(-|$)/; + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +const REGISTRY = "https://registry.npmjs.org"; + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +/** + * Parse `major.minor.patch`, rejecting anything else — including a range + * (`^0.41.0`) and a prerelease. Both are real states this can meet and neither + * has a defensible answer: a range means the pin is not a pin, and a + * prerelease on `latest` means upstream is doing something the badge should not + * quietly average over. + */ +function parseVersion(text, what) { + const match = VERSION_PATTERN.exec(String(text ?? "").trim()); + if (!match) { + throw new Error( + `${what} is not an exact major.minor.patch version: ${JSON.stringify(text)}` + ); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** True when `upstream` is a newer version than `pinned`. */ +function isAhead(pinned, upstream) { + const left = parseVersion(pinned, "the pinned ast-grep version"); + const right = parseVersion(upstream, "the upstream ast-grep version"); + for (const [index, value] of right.entries()) { + if (value !== left[index]) { + return value > left[index]; + } + } + return false; +} + +/** + * The one version every `@ast-grep/cli*` dependency is pinned to. + * + * Disagreement among them is an error rather than a "pick the highest", + * because there is no version the badge could honestly show for a repository + * that pins two. That state is also a real bug — the platform packages are + * selected by optional dependency, so a straggler at an older version is a + * different ast-grep on one platform than on the others — and a badge that + * smoothed it over would hide exactly the drift it was added to expose. + */ +function collectPinnedVersion(packageJson) { + const pins = new Map(); + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]) { + for (const [name, range] of Object.entries(packageJson[field] ?? {})) { + if (PIN_PATTERN.test(name)) { + pins.set(name, range); + } + } + } + + if (pins.size === 0) { + throw new Error( + "packages/cli/package.json declares no @ast-grep/cli* dependency" + ); + } + + const versions = new Set(pins.values()); + if (versions.size > 1) { + const detail = [...pins] + .map(([name, range]) => `${name}@${range}`) + .sort() + .join(", "); + throw new Error( + `@ast-grep/cli* pins disagree, so there is no single version to report: ${detail}` + ); + } + + const [version] = versions; + parseVersion(version, "the pinned ast-grep version"); + return version; +} + +/** + * What `npm install @ast-grep/cli` would resolve to today. + * + * The abbreviated packument (`application/vnd.npm.install-v1+json`) is what npm + * itself asks for and is a small fraction of the full document, which carries + * every version's metadata. Both expose `dist-tags`, which is the only field + * read here. + */ +async function fetchLatestVersion(packageName) { + const url = `${REGISTRY}/${packageName}`; + const response = await fetch(url, { + headers: { + accept: "application/vnd.npm.install-v1+json", + "user-agent": "taskless-skills-sg-detect", + }, + }); + if (!response.ok) { + throw new Error(`GET ${url} responded ${response.status}`); + } + const packument = await response.json(); + const latest = packument["dist-tags"]?.latest; + if (typeof latest !== "string") { + throw new TypeError(`${url} returned no dist-tags.latest`); + } + return latest; +} + +async function main({ + argv = process.argv.slice(2), + latestVersion = fetchLatestVersion, + packageJson = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8")), +} = {}) { + const json = argv.includes("--json"); + const log = json ? () => {} : (line) => console.log(line); + + const pinned = collectPinnedVersion(packageJson); + const upstream = await latestVersion("@ast-grep/cli"); + const ahead = isAhead(pinned, upstream); + + log(`pinned: ${pinned} upstream latest: ${upstream}`); + log( + ahead + ? `Upstream ${upstream} is ahead of ${pinned}. Bump every @ast-grep/cli* pin together.` + : "Upstream is not ahead of the pinned version. Nothing to do." + ); + + const comparison = { pinned, upstream, ahead }; + if (json) { + console.log(JSON.stringify(comparison)); + } + + setOutput("update", String(ahead)); + setOutput("sg_version", upstream); + setOutput("pinned_version", pinned); + return comparison; +} + +// main() both prints and RETURNS the comparison, so update-badges.cjs can call +// it in-process and read the answer as data. Nothing should ever parse the +// human line above to recover a version that this return value already holds. +module.exports = { collectPinnedVersion, isAhead, main }; + +if (require.main === module) { + main().catch((error) => { + console.error(`\nsg-detect failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/sg-detect.test.cjs b/.github/scripts/sg-detect.test.cjs new file mode 100644 index 00000000..383d8794 --- /dev/null +++ b/.github/scripts/sg-detect.test.cjs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Tests for sg-detect.cjs. + * + * The registry call is stubbed everywhere, so nothing here touches the network. + * The committed packages/cli/package.json is read once, on purpose: the "the + * repository's real pins are readable and exact" case is the one that fails + * silently in production if someone loosens a pin to a range, and a fixture + * would not notice. + */ + +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 { collectPinnedVersion, isAhead, main } = require("./sg-detect.cjs"); + +const CLI_PACKAGE_JSON = JSON.parse( + readFileSync( + join(__dirname, "..", "..", "packages", "cli", "package.json"), + "utf8" + ) +); + +/** Run main() with the registry stubbed and $GITHUB_OUTPUT captured. */ +async function runDetect({ upstream, packageJson, argv = [] }) { + const directory = mkdtempSync(join(tmpdir(), "sg-detect-test-")); + const outputPath = join(directory, "github-output"); + const previous = process.env.GITHUB_OUTPUT; + process.env.GITHUB_OUTPUT = outputPath; + try { + const comparison = await main({ + argv, + latestVersion: async () => upstream, + packageJson, + }); + 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 { comparison, outputs }; + } finally { + if (previous === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = previous; + } + rmSync(directory, { recursive: true, force: true }); + } +} + +const pinnedAt = (version) => ({ + dependencies: { "@ast-grep/cli": version }, + optionalDependencies: { + "@ast-grep/cli-darwin-arm64": version, + "@ast-grep/cli-linux-x64-gnu": version, + }, +}); + +test("sg-detect: the repository's own pins are exact and agree", () => { + // Not a fixture. If someone changes a pin to `^0.41.0` or bumps one platform + // without the others, the badge has no honest value to show and this is + // where that is caught. + assert.match(collectPinnedVersion(CLI_PACKAGE_JSON), /^\d+\.\d+\.\d+$/); +}); + +test("sg-detect: an upstream release ahead of the pin is reported", async () => { + const { comparison, outputs } = await runDetect({ + upstream: "0.45.1", + packageJson: pinnedAt("0.41.0"), + }); + + assert.deepEqual(comparison, { + pinned: "0.41.0", + upstream: "0.45.1", + ahead: true, + }); + assert.equal(outputs.update, "true"); + assert.equal(outputs.sg_version, "0.45.1"); + assert.equal(outputs.pinned_version, "0.41.0"); +}); + +test("sg-detect: the pin being current is a no-op", async () => { + const { comparison, outputs } = await runDetect({ + upstream: "0.41.0", + packageJson: pinnedAt("0.41.0"), + }); + + assert.equal(comparison.ahead, false); + assert.equal(outputs.update, "false"); +}); + +test("sg-detect: an upstream version behind the pin is not ahead", async () => { + const { comparison } = await runDetect({ + upstream: "0.40.9", + packageJson: pinnedAt("0.41.0"), + }); + + assert.equal(comparison.ahead, false); +}); + +test("sg-detect: --json prints the comparison and nothing else", async () => { + const lines = []; + const original = console.log; + console.log = (line) => lines.push(line); + try { + await runDetect({ + upstream: "0.45.1", + packageJson: pinnedAt("0.41.0"), + argv: ["--json"], + }); + } finally { + console.log = original; + } + + assert.equal(lines.length, 1); + assert.deepEqual(JSON.parse(lines[0]), { + pinned: "0.41.0", + upstream: "0.45.1", + ahead: true, + }); +}); + +test("sg-detect: disagreeing pins abort rather than picking one", () => { + assert.throws( + () => + collectPinnedVersion({ + dependencies: { "@ast-grep/cli": "0.41.0" }, + optionalDependencies: { "@ast-grep/cli-darwin-arm64": "0.40.0" }, + }), + /pins disagree/ + ); +}); + +test("sg-detect: a range instead of an exact pin aborts", () => { + assert.throws( + () => collectPinnedVersion(pinnedAt("^0.41.0")), + /not an exact major\.minor\.patch/ + ); +}); + +test("sg-detect: no @ast-grep dependency at all aborts", () => { + assert.throws( + () => collectPinnedVersion({ dependencies: { typescript: "5.9.2" } }), + /declares no @ast-grep\/cli\* dependency/ + ); +}); + +test("sg-detect: an upstream prerelease on latest aborts loudly", () => { + // Better a failed run than a badge silently comparing 0.41.0 against + // something it cannot order. + assert.throws( + () => isAhead("0.41.0", "0.46.0-alpha.1"), + /upstream ast-grep version is not an exact major\.minor\.patch/ + ); +}); + +test("sg-detect: ordering is numeric, not lexical", () => { + assert.equal(isAhead("0.9.0", "0.10.0"), true); + assert.equal(isAhead("0.10.0", "0.9.0"), false); + assert.equal(isAhead("1.0.0", "0.99.99"), false); +}); diff --git a/.github/scripts/update-badges.cjs b/.github/scripts/update-badges.cjs new file mode 100644 index 00000000..6067e4a1 --- /dev/null +++ b/.github/scripts/update-badges.cjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Write the shields endpoint payloads for the toolchain badges. + * + * `.shields/vale.json` and `.shields/sg.json` are served straight off + * raw.githubusercontent.com and rendered by + * `img.shields.io/endpoint?url=…`. Shields cannot ask "is there a newer + * upstream we have not pinned", so the color rule lives here, in code that is + * reviewed like everything else. + * + * THE MESSAGE CARRIES A DATE, AND THAT IS THE STALENESS SIGNAL. + * + * A badge that has stopped being updated looks exactly like a badge whose + * value has not changed — both render `vale | 3.17.1` forever. That is the + * failure this repository keeps hitting: an absent signal reading as a passing + * one. The fix has to be visible where the badge is read, because a + * `checkedAt` field buried in a JSON file nobody opens is a record, not a + * signal. So the message is ` · `, and a reader who sees + * a date from four months ago knows the job died rather than that upstream has + * been quiet. + * + * The date is deliberately NOT refreshed on every run, or the badge would be + * rewritten daily and every rewrite is a commit to `main` (see the loop note in + * update-badges.yml). It advances only once it is STALE_AFTER_DAYS old, so a + * daily schedule produces at most one commit a week per badge while still + * detecting an upstream release within a day of it happening. The date means + * "checked no earlier than this", which is the guarantee a reader needs, and + * the window of imprecision is bounded by the constant below. + * + * The payload is exactly the shields endpoint schema and carries no extra + * fields: the record and the signal are the same string, so they cannot drift + * apart, and there is no chance of shields rejecting an unrecognized key. + * + * Usage: + * node .github/scripts/update-badges.cjs [--write] + * + * --write rewrite the payloads whose rendered badge changed. Without it the + * script reports what it would do and touches nothing. + * + * Outputs (appended to $GITHUB_OUTPUT when set): + * changed "true" when at least one payload was (or would be) rewritten + */ + +const { + appendFileSync, + mkdirSync, + readFileSync, + writeFileSync, +} = require("node:fs"); +const { join } = require("node:path"); + +const { main: valeDetect } = require("./vale-detect.cjs"); +const { main: sgDetect } = require("./sg-detect.cjs"); + +const SHIELDS_DIRECTORY = join(__dirname, "..", "..", ".shields"); + +/** How old the recorded date may get before a run refreshes it. */ +const STALE_AFTER_DAYS = 7; + +const MESSAGE_SEPARATOR = " · "; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +/** `2026-08-21`, in UTC, so the date does not depend on where a runner is. */ +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +/** Whole days between two `YYYY-MM-DD` strings, both read as UTC midnight. */ +function daysBetween(earlier, later) { + return Math.round( + (Date.parse(`${later}T00:00:00Z`) - Date.parse(`${earlier}T00:00:00Z`)) / + 86_400_000 + ); +} + +/** + * Split `3.17.1 · 2026-08-21` back into its parts. Anything that does not have + * that shape — a hand-edited file, a payload written before this format, a + * truncated write — yields no date, which makes the next run rewrite it. That + * is the right bias: an unreadable badge should be replaced, not preserved. + */ +function parseMessage(message) { + const [version, date] = String(message ?? "").split(MESSAGE_SEPARATOR); + return { + version, + date: DATE_PATTERN.test(date ?? "") ? date : undefined, + }; +} + +/** + * The badge a comparison should render, given what is already committed. + * + * Pure, and the only place the color rule and the date rule live. Keeping the + * committed date when nothing else moved is what stops a daily schedule from + * committing daily; keeping it only while it is fresh is what stops the badge + * from claiming a check that never happened. + */ +function planBadge({ label, comparison, today, previous }) { + const color = comparison.ahead ? "yellow" : "green"; + const before = parseMessage(previous?.message); + + const keepDate = + before.date !== undefined && + before.version === comparison.pinned && + previous?.color === color && + daysBetween(before.date, today) < STALE_AFTER_DAYS; + + return { + schemaVersion: 1, + label, + message: `${comparison.pinned}${MESSAGE_SEPARATOR}${keepDate ? before.date : today}`, + color, + }; +} + +/** The committed payload, or undefined when there is not a readable one. */ +function readBadge(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return undefined; + } +} + +function serialize(badge) { + return `${JSON.stringify(badge, null, 2)}\n`; +} + +async function main({ + argv = process.argv.slice(2), + directory = SHIELDS_DIRECTORY, + now = new Date(), + detect = { + // --json keeps each detect script's human narration off stdout; the answer + // comes back as the return value rather than out of the printed line, so + // nothing here parses anything. + vale: () => valeDetect({ argv: ["--json"] }), + sg: () => sgDetect({ argv: ["--json"] }), + }, +} = {}) { + const write = argv.includes("--write"); + const today = formatDate(now); + + const badges = [ + { label: "vale", file: "vale.json", comparison: await detect.vale() }, + { label: "sg", file: "sg.json", comparison: await detect.sg() }, + ]; + + let changed = false; + for (const { label, file, comparison } of badges) { + const path = join(directory, file); + const previous = readBadge(path); + const badge = planBadge({ label, comparison, today, previous }); + const next = serialize(badge); + + if (previous !== undefined && serialize(previous) === next) { + console.log(`${file}: unchanged (${badge.message}, ${badge.color})`); + continue; + } + + changed = true; + console.log(`${file}: ${badge.message}, ${badge.color}`); + if (write) { + mkdirSync(directory, { recursive: true }); + writeFileSync(path, next); + } + } + + if (!write) { + console.log("\nPass --write to update the payloads."); + } + setOutput("changed", String(changed)); + return { changed }; +} + +module.exports = { main, planBadge }; + +if (require.main === module) { + main().catch((error) => { + console.error(`\nupdate-badges failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/update-badges.test.cjs b/.github/scripts/update-badges.test.cjs new file mode 100644 index 00000000..27f7f7cb --- /dev/null +++ b/.github/scripts/update-badges.test.cjs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Tests for update-badges.cjs. + * + * Both detect scripts are stubbed and the payload directory is a temp one, so + * nothing here touches the network or the committed `.shields/` files. + * + * The rules under test are the two that are easy to get backwards: a run that + * finds nothing new must not rewrite anything (every rewrite becomes a commit + * to main, and a commit to main costs a Validate run and a nightly publish), + * and the date must not be allowed to sit still forever, or a dead job renders + * as a healthy one. + */ + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); + +const { main, planBadge } = require("./update-badges.cjs"); + +const comparisonOf = (pinned, upstream) => ({ + pinned, + upstream, + ahead: pinned !== upstream, +}); + +/** Run main() against a temp directory seeded with `files`. */ +async function run({ files = {}, vale, sg, now, argv = ["--write"] }) { + const directory = mkdtempSync(join(tmpdir(), "update-badges-test-")); + const silenced = console.log; + console.log = () => {}; + try { + for (const [name, contents] of Object.entries(files)) { + writeFileSync(join(directory, name), contents); + } + const result = await main({ + argv, + directory, + now: new Date(now), + detect: { vale: async () => vale, sg: async () => sg }, + }); + const read = (name) => { + const path = join(directory, name); + return existsSync(path) + ? JSON.parse(readFileSync(path, "utf8")) + : undefined; + }; + return { ...result, vale: read("vale.json"), sg: read("sg.json") }; + } finally { + console.log = silenced; + rmSync(directory, { recursive: true, force: true }); + } +} + +const badge = (message, color) => + `${JSON.stringify({ schemaVersion: 1, label: "vale", message, color }, null, 2)}\n`; + +test("badges: an empty .shields writes both payloads", async () => { + const result = await run({ + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, true); + // The live case this shipped against: Vale pinned at 3.17.1 with 3.18.0 + // upstream is yellow, ast-grep level with upstream is green. + assert.deepEqual(result.vale, { + schemaVersion: 1, + label: "vale", + message: "3.17.1 · 2026-08-21", + color: "yellow", + }); + assert.deepEqual(result.sg, { + schemaVersion: 1, + label: "sg", + message: "0.41.0 · 2026-08-21", + color: "green", + }); +}); + +test("badges: nothing new upstream writes nothing", async () => { + const result = await run({ + files: { + "vale.json": badge("3.17.1 · 2026-08-19", "yellow"), + "sg.json": badge("0.41.0 · 2026-08-19", "green").replace( + '"vale"', + '"sg"' + ), + }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, false); + // Two days on, the recorded date is still the truth it was written with. + assert.equal(result.vale.message, "3.17.1 · 2026-08-19"); +}); + +test("badges: a date older than the staleness horizon is refreshed", async () => { + const result = await run({ + files: { "vale.json": badge("3.17.1 · 2026-08-01", "yellow") }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, true); + assert.equal(result.vale.message, "3.17.1 · 2026-08-21"); +}); + +test("badges: bumping the pin turns the badge green and redates it", async () => { + const result = await run({ + files: { "vale.json": badge("3.17.1 · 2026-08-20", "yellow") }, + vale: comparisonOf("3.18.0", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.deepEqual(result.vale, { + schemaVersion: 1, + label: "vale", + message: "3.18.0 · 2026-08-21", + color: "green", + }); +}); + +test("badges: without --write nothing is written but the answer is reported", async () => { + const result = await run({ + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.45.1"), + now: "2026-08-21T06:17:00Z", + argv: [], + }); + + assert.equal(result.changed, true); + assert.equal(result.vale, undefined); + assert.equal(result.sg, undefined); +}); + +test("badges: an unreadable payload is replaced rather than preserved", async () => { + const result = await run({ + files: { "vale.json": "{ not json" }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.vale.message, "3.17.1 · 2026-08-21"); +}); + +test("badges: the payload carries only the shields endpoint schema", () => { + // No `checkedAt` sidecar. Shields validates the payload it fetches, and a + // record nobody reads is not a staleness signal anyway — the date is in the + // message for exactly that reason. + const planned = planBadge({ + label: "vale", + comparison: comparisonOf("3.17.1", "3.18.0"), + today: "2026-08-21", + previous: undefined, + }); + + assert.deepEqual(Object.keys(planned), [ + "schemaVersion", + "label", + "message", + "color", + ]); +}); + +test("badges: a payload written the same day is left alone", () => { + const previous = { + schemaVersion: 1, + label: "sg", + message: "0.41.0 · 2026-08-21", + color: "green", + }; + + assert.deepEqual( + planBadge({ + label: "sg", + comparison: comparisonOf("0.41.0", "0.41.0"), + today: "2026-08-21", + previous, + }), + previous + ); +}); diff --git a/.github/scripts/vale-detect.cjs b/.github/scripts/vale-detect.cjs index 2bfb7048..60a2ba60 100644 --- a/.github/scripts/vale-detect.cjs +++ b/.github/scripts/vale-detect.cjs @@ -22,12 +22,22 @@ * had just discovered would be verifying nothing. * * Usage: - * node .github/scripts/vale-detect.cjs [--write] + * node .github/scripts/vale-detect.cjs [--write] [--json] * * --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. * + * --json print `{ pinned, upstream, ahead }` and nothing else, then stop + * before the checksums download. update-badges.cjs needs the + * comparison and nothing else, and the alternative — scraping the + * "pinned: X upstream latest: vY" line this script prints for a + * human — would rebuild, with a regex, a fact this script already + * holds as data. Implies read-only: --json never writes the + * manifest, because the badge run is not the run that proposes a + * pin, and skipping the checksums fetch is not a shortcut but the + * point (nothing is being verified here). + * * Outputs (appended to $GITHUB_OUTPUT when set): * update "true" when upstream is ahead * vale_version the upstream version @@ -93,15 +103,23 @@ async function main({ latestTag = fetchLatestTag, text = fetchText, } = {}) { + const json = argv.includes("--json"); const write = argv.includes("--write"); + // --json is a reporting mode and --write is a writing one. Refusing the + // combination beats silently dropping whichever flag loses, since the caller + // that passed both is wrong about what it is asking for. + if (json && write) { + throw new Error("--json is read-only; it cannot be combined with --write"); + } + // Everything a human wants to read is noise on stdout when a caller is + // reading structured output from it. + const log = json ? () => {} : (line) => console.log(line); const manifest = assertManifest( JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) ); const upstreamTag = await latestTag(manifest.upstream.repository); - console.log( - `pinned: ${manifest.valeVersion} upstream latest: ${upstreamTag}` - ); + 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 @@ -110,12 +128,30 @@ async function main({ // 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."); + const ahead = isUpstreamAhead(manifest.valeVersion, upstreamVersion); + + // The structured answer, which is all a --json caller wanted. Reported here + // rather than after the checksums fetch below: that download exists to + // propose a manifest, and a badge run proposes nothing. + const comparison = { + pinned: manifest.valeVersion, + upstream: upstreamVersion, + ahead, + }; + if (json) { + console.log(JSON.stringify(comparison)); + setOutput("update", String(ahead)); + setOutput("vale_version", upstreamVersion); + setOutput("pinned_version", manifest.valeVersion); + return comparison; + } + + if (!ahead) { + 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; + return comparison; } // Only now is the checksums file worth downloading: it belongs to a release @@ -144,6 +180,7 @@ async function main({ setOutput("update", "true"); setOutput("vale_version", plan.upstreamVersion); setOutput("pinned_version", plan.pinnedVersion); + return comparison; } // Exported (and only self-invoking as a script) so vale-detect.test.cjs can run diff --git a/.github/scripts/vale-detect.test.cjs b/.github/scripts/vale-detect.test.cjs index ee66140f..d0a6c070 100644 --- a/.github/scripts/vale-detect.test.cjs +++ b/.github/scripts/vale-detect.test.cjs @@ -49,15 +49,15 @@ function checksumsFor(version) { * 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 }) { +async function runDetect({ upstreamTag, checksums, argv = [] }) { 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: [], + const comparison = await main({ + argv, latestTag: async () => upstreamTag, text: async (url) => { fetched.push(url); @@ -73,7 +73,7 @@ async function runDetect({ upstreamTag, checksums }) { return [line.slice(0, at), line.slice(at + 1)]; }) ); - return { outputs, fetched }; + return { comparison, outputs, fetched }; } finally { if (previous === undefined) { delete process.env.GITHUB_OUTPUT; @@ -125,6 +125,48 @@ test("detect: an upstream tag behind the pin is also a no-op", async () => { assert.deepEqual(fetched, []); }); +test("detect: --json reports the comparison without fetching checksums", async () => { + // What update-badges.cjs consumes. It takes the RETURN value rather than the + // printed line, but both are asserted here: the printed line is the contract + // for anything calling the script from a shell. + const lines = []; + const original = console.log; + console.log = (line) => lines.push(line); + let result; + try { + result = await runDetect({ + upstreamTag: "v3.99.0", + checksums: "", + argv: ["--json"], + }); + } finally { + console.log = original; + } + + assert.deepEqual(result.comparison, { + pinned: MANIFEST.valeVersion, + upstream: "3.99.0", + ahead: true, + }); + assert.equal(lines.length, 1); + assert.deepEqual(JSON.parse(lines[0]), result.comparison); + // The badge needs the comparison, not the digests, so the ahead path stops + // before the download the manifest proposal would need. + assert.deepEqual(result.fetched, []); + assert.equal(result.outputs.update, "true"); +}); + +test("detect: --json refuses to be combined with --write", async () => { + await assert.rejects( + runDetect({ + upstreamTag: "v3.99.0", + checksums: "", + argv: ["--json", "--write"], + }), + /--json is read-only/ + ); +}); + test("detect: a checksums file missing a platform aborts", async () => { await assert.rejects( runDetect({ diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml new file mode 100644 index 00000000..225adb0b --- /dev/null +++ b/.github/workflows/update-badges.yml @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: MIT +# Refresh the shields endpoint payloads for the toolchain badges. +# +# `.shields/vale.json` and `.shields/sg.json` are the only badge values this +# repository computes for itself. Shields cannot answer "is there a newer +# upstream we have not pinned", so the comparison runs here and the color rule +# lives in `.github/scripts/update-badges.cjs`, under code review like anything +# else. Everything else in the README's badge row is a shields built-in or a +# static badge and needs nothing from this file. +# +# WHY THIS IS ITS OWN WORKFLOW AND NOT A JOB IN A PUBLISHING ONE. +# +# It needs `contents: write`, because writing a badge means committing to +# `main`. `release-cli.yml`, `release-cli-nightly.yml`, and `release-vale.yml` +# each hold `id-token: write` and an npm OIDC publishing identity. Keeping the +# two apart in SEPARATE FILES makes the separation structural: there is no +# arrangement of jobs here that a later edit could get wrong, because the +# credentialed workflows are not in this file to be edited. A job added to a +# release workflow would put a write token and a publishing identity one +# `permissions:` block away from each other, and the only thing keeping them +# apart would be whoever reviews the next change to that file. Do not merge +# this into a release workflow to save a file. +# +# WHY IT IS NOT FOLDED INTO release-vale.yml SPECIFICALLY. That workflow's +# detect phase opens a pull request when upstream Vale is ahead. The badge has +# to be YELLOW while that pull request is open and green once it merges, so the +# payload cannot live in that pull request's own diff — it would go yellow at +# exactly the moment it should go green. It has to reach `main` independently +# of the change it is warning about. +# +# WHY THE SCHEDULE, AND WHY A BADGE COMMIT DOES NOT LOOP. +# +# `validate.yml` triggers on every push to `main` with NO `paths` filter, and +# since #132 the nightly publishes on `Validate` completing. So a commit here +# costs one Validate run and, if changesets are pending, one nightly publish. +# That chain terminates only because what is committed depends on UPSTREAM +# moving, not on us pushing: +# +# badge commit → Validate → nightly workflow_run → nightly publishes +# → next scheduled badge run finds the same upstream versions +# → writes nothing → no commit → chain over. +# +# A badge derived from each nightly would NOT terminate: publish → commit → +# Validate → publish, forever. The nightly gate cannot stop it, because it +# dedupes per-sha and every badge commit is a new sha. That is why the nightly +# badge in the README is a plain static one with no version in it, and why +# nothing in this workflow ever reads a nightly version. +# +# The same reasoning bounds the cost of this workflow. The schedule is daily so +# an upstream release is noticed within a day, but `update-badges.cjs` rewrites +# a payload only when the rendered badge actually changes — and the date it +# renders advances only once a week — so a quiet week produces at most one +# commit per badge, not seven. +# +# THE DATE IN THE MESSAGE IS THE POINT, NOT DECORATION. A badge that stopped +# being updated looks identical to one whose value has not changed. Rendering +# `vale | 3.17.1 · 2026-08-21` means a reader can tell "upstream has been +# quiet" from "this job died in April" without opening anything. +# +# Action refs are pinned to commit SHAs; the trailing comment records the tag. + +name: Update badges + +on: + # Upstream's release cadence, not ours. This answers "what is upstream + # doing", which has nothing to do with our own pushes or releases — hence a + # schedule rather than a trigger on any repository event. + schedule: + - cron: "17 6 * * *" + + # For "upstream just released, do not wait until tomorrow". + workflow_dispatch: + +# No workflow-wide grants; the one job asks for exactly what it needs. +permissions: {} + +# One at a time, so two runs cannot both decide to commit the same payload. +concurrency: update-badges + +jobs: + update: + name: Refresh toolchain badges + runs-on: ubuntu-latest + permissions: + contents: write # commit the refreshed payloads to main + steps: + # Credentials persist because this job pushes. It holds no npm identity, + # no id-token, and runs nothing it downloaded. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # No install step: the scripts are zero-dependency CommonJS. GITHUB_TOKEN + # is passed only to raise the GitHub API rate limit on the Vale release + # lookup; both endpoints (GitHub releases, the npm registry) are public. + - id: badges + run: node .github/scripts/update-badges.cjs --write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Guarded on the script's own answer rather than on `git diff`, so the + # decision is made in the tested code path rather than restated in shell. + # Nothing from a workflow expression is interpolated into this body. + - name: Commit the refreshed payloads + if: steps.badges.outputs.changed == 'true' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .shields + git commit -m "chore(badges): refresh upstream toolchain badges" + git push origin HEAD:main diff --git a/.shields/sg.json b/.shields/sg.json new file mode 100644 index 00000000..24bd3e05 --- /dev/null +++ b/.shields/sg.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "label": "sg", + "message": "0.41.0 · 2026-08-22", + "color": "yellow" +} diff --git a/.shields/vale.json b/.shields/vale.json new file mode 100644 index 00000000..1a1812bb --- /dev/null +++ b/.shields/vale.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "label": "vale", + "message": "3.17.1 · 2026-08-22", + "color": "yellow" +} diff --git a/packages/cli/README.md b/packages/cli/README.md index c6f689f1..357e95e6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3,6 +3,8 @@ [![npm](https://img.shields.io/npm/v/@taskless/cli)](https://www.npmjs.com/package/@taskless/cli) [![build](https://img.shields.io/github/actions/workflow/status/taskless/cli/validate.yml?branch=main)](https://github.com/taskless/cli/actions/workflows/validate.yml?query=branch%3Amain) [![nightly](https://img.shields.io/badge/nightly-npm-blue)](https://www.npmjs.com/package/@taskless/cli-nightly) +[![vale](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/vale.json)](https://github.com/errata-ai/vale/releases) +[![sg](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/sg.json)](https://github.com/ast-grep/ast-grep/releases) CLI companion for [Taskless](https://taskless.io). Designed to work with agent skills to add constraints that improve coding agent output. From 3d5e3825576760ebba492b473d0bcee06eecff4f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 22 Aug 2026 12:34:09 -0700 Subject: [PATCH 3/3] fix(ci): address review feedback on the badge workflow and scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Percent-encode the scope separator in the npm registry URL, as vale-gate.cjs does. The registry does serve the unescaped path today (verified against the real endpoint), but `/@scope%2Fname` is the documented form and relying on an unpromised redirect is a strange saving. - Treat a committed date in the FUTURE as stale. A negative age passed `age < STALE_AFTER_DAYS`, so a skewed clock or a hand edit would have produced a date no later run could correct — the one way this rule could stick. - Run the two detects with Promise.all. They are independent lookups against different hosts; awaiting them inside the array literal made every scheduled run wait out both round trips end to end. - Retry the badge push (bounded at 3) by rebasing onto the new tip. Any PR merging between checkout and push makes main a non-fast-forward, which would fail the job and leave the badge stale until tomorrow. `.shields/` is touched by nothing else, so the rebase cannot conflict with an ordinary merge. - Compute the test helper's `ahead` with the production comparator. `pinned !== upstream` happened to be right for every current case but mismodels the one that matters — upstream BEHIND the pin is not ahead and must stay green — so a later test could have asserted against a comparison the real scripts cannot produce. Added that case, plus the future-date regression. Refs OSS-43 --- .github/scripts/sg-detect.cjs | 8 ++++- .github/scripts/update-badges.cjs | 17 +++++++++-- .github/scripts/update-badges.test.cjs | 42 +++++++++++++++++++++++++- .github/workflows/update-badges.yml | 25 ++++++++++++++- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.github/scripts/sg-detect.cjs b/.github/scripts/sg-detect.cjs index dc56f699..2047daba 100644 --- a/.github/scripts/sg-detect.cjs +++ b/.github/scripts/sg-detect.cjs @@ -156,9 +156,15 @@ function collectPinnedVersion(packageJson) { * itself asks for and is a small fraction of the full document, which carries * every version's metadata. Both expose `dist-tags`, which is the only field * read here. + * + * The scope separator is percent-encoded, as vale-gate.cjs does. The registry + * happens to serve `/@ast-grep/cli` unescaped today — that was checked against + * the real endpoint — but `/@scope%2Fname` is the documented form, it is what + * the rest of this directory uses, and relying on a redirect nobody promised is + * a strange thing to do to save one call. */ async function fetchLatestVersion(packageName) { - const url = `${REGISTRY}/${packageName}`; + const url = `${REGISTRY}/${packageName.replace("/", "%2F")}`; const response = await fetch(url, { headers: { accept: "application/vnd.npm.install-v1+json", diff --git a/.github/scripts/update-badges.cjs b/.github/scripts/update-badges.cjs index 6067e4a1..cc5d4c20 100644 --- a/.github/scripts/update-badges.cjs +++ b/.github/scripts/update-badges.cjs @@ -110,11 +110,17 @@ function planBadge({ label, comparison, today, previous }) { const color = comparison.ahead ? "yellow" : "green"; const before = parseMessage(previous?.message); + // A committed date in the FUTURE — a skewed runner clock, a hand edit — has + // a negative age, which `age < STALE_AFTER_DAYS` would read as freshly + // written and keep forever. That is the one way this rule could produce a + // badge that never self-corrects, so a negative age counts as stale. + const age = daysBetween(before.date ?? today, today); const keepDate = before.date !== undefined && before.version === comparison.pinned && previous?.color === color && - daysBetween(before.date, today) < STALE_AFTER_DAYS; + age >= 0 && + age < STALE_AFTER_DAYS; return { schemaVersion: 1, @@ -152,9 +158,14 @@ async function main({ const write = argv.includes("--write"); const today = formatDate(now); + // Concurrent, because the two lookups are independent: one hits the GitHub + // releases API and the other the npm registry, and neither informs the other. + // Awaiting them in sequence inside the array literal made a scheduled run + // wait out both round trips end to end for no reason. + const [vale, sg] = await Promise.all([detect.vale(), detect.sg()]); const badges = [ - { label: "vale", file: "vale.json", comparison: await detect.vale() }, - { label: "sg", file: "sg.json", comparison: await detect.sg() }, + { label: "vale", file: "vale.json", comparison: vale }, + { label: "sg", file: "sg.json", comparison: sg }, ]; let changed = false; diff --git a/.github/scripts/update-badges.test.cjs b/.github/scripts/update-badges.test.cjs index 27f7f7cb..3071d669 100644 --- a/.github/scripts/update-badges.test.cjs +++ b/.github/scripts/update-badges.test.cjs @@ -28,10 +28,19 @@ const { join } = require("node:path"); const { main, planBadge } = require("./update-badges.cjs"); +const { isAhead } = require("./sg-detect.cjs"); + +/** + * A stubbed detect result, with `ahead` computed the way production computes + * it. `pinned !== upstream` was close enough for every case here, but it would + * have mismodelled the one that matters — upstream BEHIND the pin, which is + * `ahead: false` and must stay green — so a later test could have asserted + * against a comparison the real scripts can never produce. + */ const comparisonOf = (pinned, upstream) => ({ pinned, upstream, - ahead: pinned !== upstream, + ahead: isAhead(pinned, upstream), }); /** Run main() against a temp directory seeded with `files`. */ @@ -179,6 +188,37 @@ test("badges: the payload carries only the shields endpoint schema", () => { ]); }); +test("badges: an upstream version behind the pin stays green", () => { + // The case the old test helper could not express: `pinned !== upstream` is + // true here, but nothing is ahead of us and the badge must not go yellow. + const planned = planBadge({ + label: "sg", + comparison: comparisonOf("0.45.1", "0.41.0"), + today: "2026-08-22", + previous: undefined, + }); + + assert.equal(planned.color, "green"); +}); + +test("badges: a date in the future is treated as stale, not as fresh", () => { + // A skewed clock or a hand edit must not produce a date that outlives every + // later run. A negative age is stale. + const planned = planBadge({ + label: "vale", + comparison: comparisonOf("3.17.1", "3.18.0"), + today: "2026-08-22", + previous: { + schemaVersion: 1, + label: "vale", + message: "3.17.1 · 2027-01-01", + color: "yellow", + }, + }); + + assert.equal(planned.message, "3.17.1 · 2026-08-22"); +}); + test("badges: a payload written the same day is left alone", () => { const previous = { schemaVersion: 1, diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml index 225adb0b..05d72146 100644 --- a/.github/workflows/update-badges.yml +++ b/.github/workflows/update-badges.yml @@ -103,6 +103,17 @@ jobs: # Guarded on the script's own answer rather than on `git diff`, so the # decision is made in the tested code path rather than restated in shell. # Nothing from a workflow expression is interpolated into this body. + # + # RETRIED, because losing a race here is the ordinary case rather than an + # exceptional one: any pull request merging between the checkout and this + # push makes `main` a non-fast-forward, and a bare push would fail the job + # and leave the badge stale until tomorrow's schedule. Rebasing our single + # commit onto the new tip and pushing again is exactly what a human would + # do, and `.shields/` is touched by nothing else, so the rebase cannot + # meet a conflict from an ordinary merge. + # + # Bounded at three attempts. A push that keeps losing is not a race any + # more, and a job that retries forever hides whatever is actually wrong. - name: Commit the refreshed payloads if: steps.badges.outputs.changed == 'true' run: | @@ -111,4 +122,16 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add .shields git commit -m "chore(badges): refresh upstream toolchain badges" - git push origin HEAD:main + + for attempt in 1 2 3; do + if git push origin HEAD:main; then + echo "Pushed on attempt ${attempt}." + exit 0 + fi + echo "Push rejected on attempt ${attempt}; rebasing onto the new main." + git fetch origin main + git rebase origin/main + done + + echo "::error::could not push the badge commit after 3 attempts" + exit 1