chore(deps): remove dependabot.yml, fold action bumps into the sweep

Closes #2235.

Switches the old dependency flow off now that #2232 landed the
replacement. Removes the five npm ecosystem entries (root plus each
client under clients/*) and, resolving the question #2229 left open, the
github-actions entry as well — so .github/dependabot.yml goes away
outright rather than being emptied, which its schema does not allow.

The github-actions entry had exactly the property #2229 exists to
remove: it opened a grouped monthly PR carrying no `Closes #N` and no
board card, the one standing exception to "every PR references an
issue". Deleting it unreplaced would have left 9 actions unwatched, and
`npm outdated` says nothing about actions, so the monthly sweep now also
checks every `uses:` ref under .github/workflows and renders the stale
ones as one more section of the same tracking issue.

Ranking comes from the release LIST, not `releases/latest`. That
endpoint returns the release GitHub designates most recent, not the
greatest version, so an action publishing a maintenance release for an
older major (a v6.9.1 cut after v8.0.0) would make a workflow pinned to
v7 compare against v6 and read as current — silently missing a whole
major upgrade, the one thing this check exists to catch.

Staleness is compared only to the precision the ref specifies. `v7` is a
moving major tag that GitHub repoints at every v7.x release, so `v7`
against a highest of `v7.0.1` is current and only `v8` makes it stale;
an exactly-pinned `v7.0.0` is behind `v7.0.1`; a SHA pin is deliberately
immovable and is never reported.

A release lookup suppresses only a 404 — the legitimate "this action has
never cut a release" answer — and throws on anything else. Treating a
rate limit or an expired token as "no release" is indistinguishable from
"not stale", and since every action here already sits on its latest
major, the resulting empty section is byte-identical to a healthy run.

`buildClearedBody` now speaks for both halves: once actions are in scope,
its npm-only wording would assert a clean bill of health the sweep never
checked.

Dependabot security updates are unaffected — they are configured in repo
settings, not in this file, and kept working while it was missing
entirely (#1833, #1840). That note moves into the workflow header rather
than dying with the file; #2233 is where they are turned off
deliberately. The script header, the workflow header and the generated
issue body all say version-update PRs rather than claiming Dependabot is
replaced wholesale.

Also renames the workflow's npm-outdated job to dependency-sweep now
that the sweep is no longer npm-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-03 21:41:31 -04:00 • committed by GitHub
commit 6af74424d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,8 +12,14 @@ import assert from "node:assert/strict";
import {
buildClearedBody,
buildIssueBody,
highestVersionTag,
isActionStale,
isMissingRelease,
main,
parseActionRefs,
parseOutdated,
parseVersionRef,
staleActions,
INSTALLS,
ISSUE_MARKER,
} from "./dependency-refresh.mjs";
@ -26,12 +32,18 @@ import {
* @param {number} [opts.outdatedStatus] exit status for every `npm outdated`
* @param {Array<{number:number,body:string}>} [opts.existing] what `gh issue list` returns
* @param {string|null} [opts.milestone] what the milestone lookup returns
* @param {string[]} [opts.releaseTags] tags every action's release list returns
* @param {number} [opts.releasesStatus] exit status for every release lookup
* @param {string} [opts.releasesStderr] stderr for a failing release lookup
*/
function fakeSpawn({
outdated = {},
outdatedStatus,
existing = [],
milestone = "v2.6.0",
releaseTags = [],
releasesStatus,
releasesStderr = "",
} = {}) {
const calls = [];
const fn = (cmd, args, opts) => {
@ -48,6 +60,15 @@ function fakeSpawn({
}
if (args[0] === "issue" && args[1] === "list")
return { status: 0, stdout: JSON.stringify(existing), stderr: "" };
// MUST be tested before the milestone branch below: both are `gh api`,
// so matching on args[0] alone hands the release lookup the milestone
// string and the assertion silently checks nothing.
if (args[0] === "api" && args[1].includes("/releases"))
return {
status: releasesStatus ?? 0,
stdout: releasesStatus ? "" : releaseTags.join("\n"),
stderr: releasesStderr,
};
if (args[0] === "api")
return {
status: 0,
@ -107,6 +128,112 @@ test("parseOutdated falls back when a field is missing", () => {
]);
});
test("parseActionRefs pulls owner/repo@ref out of a workflow, in file order", () => {
const yaml = [
"jobs:",
" build:",
" steps:",
" - uses: actions/checkout@v7",
" - name: Setup",
" uses: actions/setup-node@v7",
' - uses: "docker/login-action@v4"',
" - uses: docker/build-push-action@v7 # trailing comment",
].join("\n");
assert.deepEqual(parseActionRefs(yaml), [
{ action: "actions/checkout", ref: "v7" },
{ action: "actions/setup-node", ref: "v7" },
{ action: "docker/login-action", ref: "v4" },
{ action: "docker/build-push-action", ref: "v7" },
]);
});
test("parseActionRefs skips local, container and unpinned steps", () => {
const yaml = [
" - uses: ./.github/actions/local",
" - uses: docker://alpine:3.20",
" - uses: actions/checkout",
" - uses: github/codeql-action/init@v3",
].join("\n");
assert.deepEqual(parseActionRefs(yaml), [
{ action: "github/codeql-action/init", ref: "v3" },
]);
});
test("parseVersionRef reads a numeric ref and rejects anything else", () => {
assert.deepEqual(parseVersionRef("v7"), [7]);
assert.deepEqual(parseVersionRef("7.0.1"), [7, 0, 1]);
assert.equal(parseVersionRef("main"), null);
assert.equal(
parseVersionRef("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
null,
);
});
test("isActionStale compares only to the precision the ref specifies", () => {
// `v7` is a moving major tag, so a v7.x release does not make it stale.
assert.equal(isActionStale("v7", "v7.0.1"), false);
assert.equal(isActionStale("v7", "v8.0.0"), true);
// An exactly-pinned ref is behind its own patch release.
assert.equal(isActionStale("v7.0.0", "v7.0.1"), true);
assert.equal(isActionStale("v7.1", "v7.0.9"), false);
});
test("isActionStale reports nothing for a ref it cannot rank", () => {
// A SHA pin is deliberately immovable; a tag comparison says nothing about it.
assert.equal(
isActionStale("8f4b7f84864484a7bf31766abe9204da3cbe65b3", "v5"),
false,
);
assert.equal(isActionStale("main", "v5"), false);
assert.equal(isActionStale("v5", "not-a-tag"), false);
});
test("highestVersionTag picks the greatest version, not the newest entry", () => {
// GitHub's `releases/latest` is the DESIGNATED latest, not the greatest
// version: a maintenance release cut for an older major after a newer one
// would make a workflow on v7 read as current (Copilot).
assert.equal(highestVersionTag(["v6.9.1", "v8.0.0", "v7.2.0"]), "v8.0.0");
assert.equal(highestVersionTag(["v7", "v7.0.1"]), "v7.0.1");
assert.equal(highestVersionTag(["v10.0.0", "v9.9.9"]), "v10.0.0");
});
test("highestVersionTag ignores tags it cannot parse", () => {
assert.equal(highestVersionTag(["nightly", "v2.0.0", "latest"]), "v2.0.0");
assert.equal(highestVersionTag(["nightly", "latest"]), null);
assert.equal(highestVersionTag([]), null);
});
test("staleActions dedupes, drops actions with no known release and sorts", () => {
const refs = [
{ action: "actions/checkout", ref: "v7" },
{ action: "actions/cache", ref: "v6" },
{ action: "actions/cache", ref: "v6" },
{ action: "some/unreleased", ref: "v1" },
];
assert.deepEqual(
staleActions(refs, {
"actions/cache": "v7.0.0",
"actions/checkout": "v7.0.1",
"some/unreleased": null,
}),
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
);
});
test("isMissingRelease suppresses only a 404, never a real API failure", () => {
// A 404 is the legitimate "this action cuts no GitHub releases" answer.
assert.equal(isMissingRelease("gh: Not Found (HTTP 404)"), true);
// Everything else must fail the sweep rather than read as "not stale" — an
// empty actions section is indistinguishable from a healthy run (Copilot).
assert.equal(
isMissingRelease("gh: API rate limit exceeded (HTTP 403)"),
false,
);
assert.equal(isMissingRelease("gh: Bad credentials (HTTP 401)"), false);
assert.equal(isMissingRelease("gh: Server Error (HTTP 502)"), false);
assert.equal(isMissingRelease(""), false);
});
test("buildIssueBody returns null when every install is up to date", () => {
assert.equal(
buildIssueBody([
@ -266,3 +393,73 @@ test("main falls back to GITHUB_REPOSITORY when no repo is passed", () => {
else process.env.GITHUB_REPOSITORY = saved;
}
});
test("buildIssueBody renders a GitHub Actions section after the npm ones", () => {
const body = buildIssueBody(
[{ label: "root", packages: [] }],
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
);
assert.ok(body.startsWith(ISSUE_MARKER));
assert.ok(body.includes("### GitHub Actions"));
assert.ok(body.includes("| `actions/cache` | v6 | v7.0.0 |"));
assert.ok(!body.includes("### `root`"));
});
test("buildIssueBody returns null only when npm and actions are both clean", () => {
assert.equal(buildIssueBody([{ label: "root", packages: [] }], []), null);
assert.notEqual(
buildIssueBody(
[],
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
),
null,
);
});
test("buildClearedBody speaks for both halves of the sweep", () => {
const body = buildClearedBody("2026-09-04");
assert.ok(body.startsWith(ISSUE_MARKER));
assert.ok(body.includes("2026-09-04"));
// npm-only wording here would assert an all-clear the sweep never checked.
assert.match(body, /npm package/);
assert.match(body, /uses:/);
});
test("main fails the sweep when a release lookup errors, rather than reporting no stale actions", () => {
// The silent-success shape one level down: a 403 read as "no release" is
// indistinguishable from "not stale", and every action here is already on
// its latest major, so the empty section would look like a healthy run.
const spawn = fakeSpawn({
releasesStatus: 1,
releasesStderr: "gh: API rate limit exceeded (HTTP 403)",
});
assert.throws(
() => captureLog(() => main("o/r", spawn)),
/release lookup for .* failed/,
);
});
test("main treats a 404 release lookup as 'this action cuts no releases'", () => {
const spawn = fakeSpawn({
releasesStatus: 1,
releasesStderr: "gh: Not Found (HTTP 404)",
});
const log = captureLog(() => main("o/r", spawn));
assert.match(log.join("\n"), /no-op/);
});
test("main asks for release lists, not the designated latest release", () => {
const spawn = fakeSpawn();
captureLog(() => main("o/r", spawn));
const lookups = spawn.calls.filter(
(c) =>
c.cmd === "gh" && c.args[0] === "api" && c.args[1].includes("/releases"),
);
assert.ok(lookups.length > 0, "expected at least one release lookup");
for (const call of lookups) {
// `releases/latest` is GitHub's designated latest, not the greatest
// version — ranking must come from the list.
assert.doesNotMatch(call.args[1], /releases\/latest/);
assert.match(call.args[1], /\/releases\?/);
}
});