diff --git a/.github/actions/automerge-dependabot/gate.cjs b/.github/actions/automerge-dependabot/gate.cjs index c0d220c3..0a9265a3 100644 --- a/.github/actions/automerge-dependabot/gate.cjs +++ b/.github/actions/automerge-dependabot/gate.cjs @@ -79,11 +79,95 @@ function parseJson(text) { } } +// Where the standards sync places the org dependabot-policy in a repository. +const POLICY_PATH = ".github/standards/dependabot-policy/policy.json"; +// pr-automerge-dependabot.yml's publisher-allowlist default. A caller that +// passes any other list keeps it whatever the policy says. +const DEFAULT_PUBLISHER_ALLOWLIST = ["actions/*", "github/*", "anthropics/*"]; + +const isObject = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); +const isGlobList = (value) => + Array.isArray(value) && + value.every((entry) => typeof entry === "string" && entry.length > 0); + +// fetch-metadata's id for each Dependabot package-ecosystem name the policy uses. +const METADATA_ECOSYSTEM = { + "github-actions": ACTIONS_ECOSYSTEM, + npm: "npm_and_yarn", + pip: "pip", + uv: "uv", + nuget: "nuget", +}; + +// The gate's inputs once the synced policy (its text, undefined when the +// repository has no copy) is applied to the caller's. With no copy the inputs +// stand. With one, its autoMerge.reviewOnly entries for `repository` join the +// caller's review-only names and its autoMerge.ecosystems bound the +// ecosystems armed; both only narrow. When the caller leaves the +// publisher-allowlist at its default, the list becomes the policy's +// `tier: auto` publishers plus its GitHub Actions and own fast track, whose +// patch and minor bumps arm like any other allowlisted publisher's. +// Returns { inputs } or { failure }. +function resolvePolicyInputs(inputs, policyText, repository) { + if (policyText === undefined) return { inputs }; + const policy = parseJson(policyText); + const autoMerge = policy?.autoMerge; + const publishers = autoMerge?.publishers; + const ecosystems = autoMerge?.ecosystems; + const policyReviewOnly = autoMerge?.reviewOnly ?? []; + const fastTrack = policy?.fastTrack ?? {}; + const tracks = [fastTrack["github-actions"] ?? [], fastTrack.own ?? []]; + if ( + !isObject(publishers) || + !isGlobList(ecosystems) || + !Array.isArray(policyReviewOnly) || + !policyReviewOnly.every( + (entry) => + isObject(entry) && + typeof entry.repository === "string" && + typeof entry.dependency === "string", + ) || + !isObject(fastTrack) || + !tracks.every(isGlobList) + ) { + return { + failure: `${POLICY_PATH} is not a readable dependabot policy (autoMerge.publishers, ecosystems, reviewOnly, fastTrack)`, + }; + } + const resolved = { + ...inputs, + ecosystems: ecosystems.map((name) => METADATA_ECOSYSTEM[name] ?? name), + }; + const callerReviewOnly = parseJson(inputs.reviewOnly); + if (Array.isArray(callerReviewOnly)) { + const scoped = policyReviewOnly + .filter( + (entry) => entry.repository.toLowerCase() === repository.toLowerCase(), + ) + .map((entry) => entry.dependency); + resolved.reviewOnly = JSON.stringify([ + ...new Set([...callerReviewOnly, ...scoped]), + ]); + } + const input = parseJson(inputs.publisherAllowlist); + if (JSON.stringify(input) === JSON.stringify(DEFAULT_PUBLISHER_ALLOWLIST)) { + const auto = Object.keys(publishers).filter( + (publisher) => publishers[publisher]?.tier === "auto", + ); + resolved.publisherAllowlist = JSON.stringify([ + ...new Set([...auto, ...tracks.flat()]), + ]); + } + return { inputs: resolved }; +} + // `pr` is the REST pull request (user.id, title, body, head.sha). `updates` // is fetch-metadata's updated-dependencies shape. `listCommits` returns the // REST commit list, read only once every cheaper gate holds. `inputs` holds // the caller's raw strings: publisherAllowlist and reviewOnly (JSON arrays) -// and packageAutoMerge ("true" or anything else). +// and packageAutoMerge ("true" or anything else), plus, from a synced policy, +// ecosystems: the fetch-metadata ecosystem ids that may arm at all. async function gateFailure({ pr, updates, listCommits, inputs }) { if (pr?.user?.id !== DEPENDABOT_ID) return "the PR author is not Dependabot"; if (!Array.isArray(updates) || updates.length === 0) @@ -107,6 +191,9 @@ async function gateFailure({ pr, updates, listCommits, inputs }) { for (const update of updates) { const name = String(update?.dependencyName ?? ""); const ecosystem = update?.packageEcosystem; + if (inputs.ecosystems && !inputs.ecosystems.includes(ecosystem)) + return `${name} is a ${ecosystem} update, which the policy's autoMerge.ecosystems leaves out`; + if (reviewOnly.includes(name)) return `${name} is a review-only dependency`; if (ecosystem === ACTIONS_ECOSYSTEM) { if (!allowlist.some((pattern) => pattern.test(name))) return `${name} is not an allowlisted publisher`; @@ -114,8 +201,6 @@ async function gateFailure({ pr, updates, listCommits, inputs }) { return `${name} is not a GitHub Actions, npm, pip, uv or NuGet update`; } else if (!packageAutoMerge) { return `${name} is a package update and this repository turned package auto-merge off`; - } else if (reviewOnly.includes(name)) { - return `${name} is a review-only dependency`; } if (!UPDATE_TYPES.has(update?.updateType)) return `${name} is not a patch or minor update`; @@ -247,6 +332,18 @@ async function runEvent({ github, context, core, env }) { return "fetching the Dependabot metadata failed"; const updates = parseJson(env.UPDATED_DEPENDENCIES_JSON || ""); if (updates === undefined) return "the Dependabot metadata is not valid JSON"; + const policy = await readBasePolicy(); + if (policy.failure) return policy.failure; + const resolved = resolvePolicyInputs( + { + publisherAllowlist: env.PUBLISHER_ALLOWLIST, + packageAutoMerge: env.PACKAGE_AUTO_MERGE, + reviewOnly: env.REVIEW_ONLY_DEPENDENCIES, + }, + policy.text, + `${context.repo.owner}/${context.repo.repo}`, + ); + if (resolved.failure) return resolved.failure; return gateFailure({ pr, updates, @@ -256,14 +353,28 @@ async function runEvent({ github, context, core, env }) { pull_number: pr.number, per_page: 100, }), - inputs: { - publisherAllowlist: env.PUBLISHER_ALLOWLIST, - packageAutoMerge: env.PACKAGE_AUTO_MERGE, - reviewOnly: env.REVIEW_ONLY_DEPENDENCIES, - }, + inputs: resolved.inputs, }); } + // Read at the base commit, so the pull request cannot change its own tier. + async function readBasePolicy() { + try { + const { data } = await github.rest.repos.getContent({ + ...context.repo, + path: POLICY_PATH, + ref: pr.base.sha, + mediaType: { format: "raw" }, + }); + return { text: String(data) }; + } catch (error) { + if (error.status === 404) return { text: undefined }; + return { + failure: `${POLICY_PATH} could not be read at the base: ${error.message}`, + }; + } + } + const reason = await eventFailure(); if (reason) { core.notice(`Auto-merge not armed: ${reason}.`); @@ -286,9 +397,12 @@ async function runEvent({ github, context, core, env }) { } module.exports = { + DEFAULT_PUBLISHER_ALLOWLIST, DEPENDABOT_ID, + POLICY_PATH, WEB_FLOW_ID, gateFailure, parseCommitMetadata, + resolvePolicyInputs, runEvent, }; diff --git a/.github/actions/automerge-dependabot/queue.cjs b/.github/actions/automerge-dependabot/queue.cjs index 1aae3e45..00109ada 100644 --- a/.github/actions/automerge-dependabot/queue.cjs +++ b/.github/actions/automerge-dependabot/queue.cjs @@ -9,9 +9,42 @@ // Nothing here throws: one pull request's failure is a warning and the next // one is still tried, so this job never blocks anything. -const { DEPENDABOT_ID, gateFailure, parseCommitMetadata } = require( - "./gate.cjs", -); +const fs = require("node:fs"); +const path = require("node:path"); +const { + DEPENDABOT_ID, + POLICY_PATH, + gateFailure, + parseCommitMetadata, + resolvePolicyInputs, +} = require("./gate.cjs"); + +// The synced policy in the checked-out default branch: { text }, with text +// undefined when the repository has no copy, or { failure }. +function readWorkspacePolicy(workspace) { + try { + return { text: fs.readFileSync(path.join(workspace, POLICY_PATH), "utf8") }; + } catch (error) { + if (error.code === "ENOENT") return { text: undefined }; + return { failure: `${POLICY_PATH} could not be read: ${error.message}` }; + } +} + +// Every non-dev package an npm lockfile installs, nested and scoped ones +// included: { names }, {} when no lockfile is named, or { failure } when the +// named one cannot be read, so a missing runtime list never arms anything. +function readLockedRuntime(workspace, lockfile) { + if (!lockfile) return {}; + try { + const { packages } = JSON.parse(fs.readFileSync(path.join(workspace, lockfile), "utf8")); + const names = Object.entries(packages) + .filter(([key, entry]) => key && !entry.dev) + .map(([key]) => key.split("node_modules/").pop()); + return { names }; + } catch (error) { + return { failure: `${lockfile} is not a readable npm lockfile: ${error.message}` }; + } +} const CI_STATUS = "ci-status"; const MISSING_WORKFLOWS = /without `?workflows`? permission/iu; @@ -63,7 +96,7 @@ function warnArmFailure(core, pr, error) { } // Returns what happened to one pull request: queued, skipped or failed. -async function queueOne({ github, app, repo, core, pr, inputs, appSlug }) { +async function queueOne({ github, app, repo, core, pr, inputs, policyFailure, appSlug }) { const disarm = () => app.graphql(DISARM, { id: pr.node_id }); const commits = await github.paginate(github.rest.pulls.listCommits, { ...repo, @@ -71,10 +104,13 @@ async function queueOne({ github, app, repo, core, pr, inputs, appSlug }) { per_page: 100, }); const updates = parseCommitMetadata(commits.at(-1)?.commit?.message, pr.head.ref); - const reason = - updates === null - ? "its head commit carries no readable Dependabot metadata" - : await gateFailure({ pr, updates, listCommits: async () => commits, inputs }); + let reason = policyFailure; + if (!reason) { + reason = + updates === null + ? "its head commit carries no readable Dependabot metadata" + : await gateFailure({ pr, updates, listCommits: async () => commits, inputs }); + } if (reason) { core.notice(`#${pr.number} left alone: ${reason}.`); // GitHub keeps auto-merge on when a user with write access pushes to @@ -128,11 +164,21 @@ async function queueOne({ github, app, repo, core, pr, inputs, appSlug }) { async function runQueue({ github, app, context, core, env }) { const repo = context.repo; - const inputs = { + const policy = readWorkspacePolicy(env.GITHUB_WORKSPACE); + // REVIEW_ONLY_LOCKFILE, when set, holds that lockfile's whole runtime tree + // for review in place of REVIEW_ONLY_DEPENDENCIES. + const locked = readLockedRuntime(env.GITHUB_WORKSPACE, env.REVIEW_ONLY_LOCKFILE); + const callerInputs = { publisherAllowlist: env.PUBLISHER_ALLOWLIST, packageAutoMerge: env.PACKAGE_AUTO_MERGE, - reviewOnly: env.REVIEW_ONLY_DEPENDENCIES, + reviewOnly: locked.names ? JSON.stringify(locked.names) : env.REVIEW_ONLY_DEPENDENCIES, }; + const readFailure = policy.failure ?? locked.failure; + const resolved = readFailure + ? { failure: readFailure } + : resolvePolicyInputs(callerInputs, policy.text, `${repo.owner}/${repo.repo}`); + const policyFailure = resolved.failure ?? ""; + const inputs = resolved.inputs ?? callerInputs; const counts = { queued: 0, skipped: 0, failed: 0 }; let pulls; try { @@ -147,7 +193,9 @@ async function runQueue({ github, app, context, core, env }) { } for (const pr of pulls.filter((pull) => pull.user?.id === DEPENDABOT_ID)) { try { - counts[await queueOne({ github, app, repo, core, pr, inputs, appSlug: env.APP_SLUG })] += 1; + counts[ + await queueOne({ github, app, repo, core, pr, inputs, policyFailure, appSlug: env.APP_SLUG }) + ] += 1; } catch (error) { core.warning(`Could not check #${pr.number}: ${error.message}`); counts.failed += 1; diff --git a/.github/actions/bump-versions/action.yml b/.github/actions/bump-versions/action.yml index 30156bd8..84f81b58 100644 --- a/.github/actions/bump-versions/action.yml +++ b/.github/actions/bump-versions/action.yml @@ -12,6 +12,13 @@ inputs: description: Policy file, relative to the repository root (command prepare). required: false default: .github/version-bump/policy.yaml + dependabot-policy: + description: >- + The standards dependabot-policy policy.json the standards sync places in the repository, + relative to its root. Its cooldownDays apply when the policy file sets none (command + prepare); a policy file that sets cooldownDays keeps its own. + required: false + default: .github/standards/dependabot-policy/policy.json out: description: Directory for results.json and the prepared bumps, relative to the repository root. required: false @@ -69,6 +76,7 @@ runs: GH_TOKEN: ${{ inputs.token }} BUMP_COMMAND: ${{ inputs.command }} BUMP_POLICY: ${{ inputs.policy }} + BUMP_DEPENDABOT_POLICY: ${{ inputs.dependabot-policy }} BUMP_OUT: ${{ inputs.out }} BUMP_ONLY: ${{ inputs.only }} BUMP_DRY_RUN: ${{ inputs.dry-run }} diff --git a/.github/actions/bump-versions/bump.py b/.github/actions/bump-versions/bump.py index 8a1bb6cd..ab713865 100755 --- a/.github/actions/bump-versions/bump.py +++ b/.github/actions/bump-versions/bump.py @@ -38,6 +38,8 @@ from xml.etree import ElementTree BRANCH_PREFIX = "version-bump/" +DEPENDABOT_POLICY = ".github/standards/dependabot-policy/policy.json" +COOLDOWN_CLASSES = ("own", "patch", "minor", "major") SIGNER_EMAIL = "noreply@github.com" VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$") HEX = {"sha256": 64, "sha512": 128} @@ -94,7 +96,8 @@ def change_class(current: str, candidate: str) -> str: cur, new = parse_version(current), parse_version(candidate) if cur is None or new is None: raise BumpError(f"cannot classify {current} -> {candidate}") - if cur[0] != new[0]: + # Below 1.0 a minor move is the breaking one, as Dependabot's gate treats it. + if cur[0] != new[0] or (cur[0] == 0 and cur[1:2] != new[1:2]): return "major" if cur[1:2] != new[1:2]: return "minor" @@ -115,8 +118,13 @@ def cooldown_days( def in_line( current: tuple[int, ...], candidate: tuple[int, ...], line: str | None ) -> bool: - """line major keeps the first number, minor the first two; None allows any release.""" + """line major keeps the first number, minor the first two; None allows any release. + + Below 1.0 the minor number is the breaking one, so line major keeps it too. + """ width = {"major": 1, "minor": 2}.get(line or "", 0) + if line == "major" and current[:1] == (0,): + width = 2 return candidate[:width] == current[:width] @@ -1212,9 +1220,34 @@ def changed_paths() -> list[str]: # ---------------------------------------------------------------- commands -def load_policy(path: Path) -> dict[str, Any]: +def synced_cooldown_days(path: Path, policy_path: Path) -> dict[str, int]: + """cooldownDays from the standards dependabot-policy copy the standards sync places here.""" + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit( + f"{policy_path} sets no cooldownDays and {path} is absent: the repository must" + " receive the standards dependabot-policy sync component or set cooldownDays" + ) from None + except json.JSONDecodeError as exc: + raise SystemExit(f"{path} is not valid JSON: {exc}") from None + days = doc.get("cooldownDays") if isinstance(doc, dict) else None + if not isinstance(days, dict) or not all( + type(days.get(c)) is int and days[c] >= 0 for c in COOLDOWN_CLASSES + ): + raise SystemExit( + f"{path} cooldownDays must give a non-negative integer for each of" + f" {', '.join(COOLDOWN_CLASSES)}" + ) + return {c: days[c] for c in COOLDOWN_CLASSES} + + +def load_policy(path: Path, dependabot_policy: Path) -> dict[str, Any]: + """The version-bump policy; its own cooldownDays wins over the synced org policy's.""" policy: dict[str, Any] = yq_json(".", path) policy["_dir"] = path.resolve().parent + if "cooldownDays" not in policy: + policy["cooldownDays"] = synced_cooldown_days(dependabot_policy, path) return policy @@ -1385,7 +1418,7 @@ def cmd_prepare(args: argparse.Namespace) -> int: # clean tree: uncommitted work would be swept into the bump and then lost. if not args.dry_run and changed_paths(): raise SystemExit("prepare needs a clean tree; commit or stash first") - policy = load_policy(Path(args.policy)) + policy = load_policy(Path(args.policy), Path(args.dependabot_policy)) out = Path(args.out).resolve() out.mkdir(parents=True, exist_ok=True) now = parse_date(args.now) if args.now else dt.datetime.now(dt.UTC) @@ -1498,22 +1531,52 @@ def tag_cooldown(rows: list[dict[str, Any]]) -> int: ) -def arm_auto_merge(bump: dict[str, Any], repo: str, tip: dict[str, Any]) -> None: +def has_major(rows: list[dict[str, Any]]) -> bool: + """Whether any row moves to a release of class major, a 0.x minor move included.""" + return any(change_class(r["current"], r["target"]) == "major" for r in rows) + + +def disarm_auto_merge(pr: int, repo: str) -> None: + """Drop any auto-merge arm a rebuilt pull request still carries from an earlier target. + + gh fails where no arm exists; that failure is accepted only once the pull request reads + back with no arm, so any other failure leaves the run red instead of the old arm live. + """ + proc = run_raw(["gh", "pr", "merge", str(pr), "-R", repo, "--disable-auto"]) + if proc.returncode == 0: + return + armed = run( + [ + *["gh", "pr", "view", str(pr), "-R", repo], + *["--json", "autoMergeRequest", "--jq", ".autoMergeRequest != null"], + ] + ).strip() + if armed != "false": + raise BumpError( + f"could not disable auto-merge on #{pr}: {proc.stderr.strip()[-400:]}" + ) + + +def arm_auto_merge( + bump: dict[str, Any], rows: list[dict[str, Any]], repo: str, tip: dict[str, Any] +) -> None: """Arm auto-merge, except while a tag source in the bump is still inside its cooldown. A tag's commit date (and the module proxy's .info Time for a git-sourced module) is set by whoever made the commit, so for those sources the cooldown also runs from the bot's - own commit, whose date GitHub sets. + own commit, whose date GitHub sets. A bump holding a major move is never armed: the org + update policy sends majors to review, so its pull request waits for a person. """ + if has_major(rows): + disarm_auto_merge(bump["pr"], repo) + bump["autoMerge"] = False + bump["detail"] = "a major release waits for review" + return wait = int(bump.get("tagCooldown", 0)) if wait: age = dt.datetime.now(dt.UTC) - parse_date(tip["date"]) if age.total_seconds() < wait * 86400: - # A pull request rebuilt for a newer tag may still carry an earlier arm. - # Disabling it where none is armed fails harmlessly, so the exit is ignored. - run_raw( - ["gh", "pr", "merge", str(bump["pr"]), "-R", repo, "--disable-auto"] - ) + disarm_auto_merge(bump["pr"], repo) bump["autoMerge"] = False bump["detail"] = "auto-merge waits for the cooldown from first sighting" return @@ -1547,7 +1610,7 @@ def publish_bump( ): # Re-armed every run, so a failed arm on an earlier run recovers. bump["outcome"], bump["pr"] = "already-open", prs[0]["number"] - arm_auto_merge(bump, repo, tip) + arm_auto_merge(bump, rows, repo, tip) return commit = signed_commit(bump, out, repo, base, title, results["policy"]) update_ref(repo, branch, tip["sha"] if tip else "0" * 40, commit["sha"]) @@ -1574,7 +1637,7 @@ def publish_bump( number = int(url.rsplit("/", 1)[-1]) bump["outcome"] = "pr-opened" bump["pr"] = number - arm_auto_merge(bump, repo, commit) + arm_auto_merge(bump, rows, repo, commit) def branch_tip(repo: str, branch: str, app_login: str) -> dict[str, Any] | None: @@ -1816,6 +1879,11 @@ def main(argv: list[str] | None = None) -> int: sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("prepare") p.add_argument("--policy", required=True, help="policy file, relative to the root") + p.add_argument( + "--dependabot-policy", + default=DEPENDABOT_POLICY, + help="synced org policy giving cooldownDays when the policy file sets none", + ) p.add_argument("--out", required=True) p.add_argument("--only", default="", help="comma-separated tool or group names") p.add_argument( diff --git a/.github/actions/bump-versions/run.sh b/.github/actions/bump-versions/run.sh index b0c868f1..88aeb5d6 100755 --- a/.github/actions/bump-versions/run.sh +++ b/.github/actions/bump-versions/run.sh @@ -12,7 +12,9 @@ engine="$GITHUB_ACTION_PATH/bump.py" case "$BUMP_COMMAND" in prepare) : "${BUMP_POLICY:?}" - args=(--policy "$BUMP_POLICY" --out "$BUMP_OUT" --only "${BUMP_ONLY:-}") + : "${BUMP_DEPENDABOT_POLICY:?}" + args=(--policy "$BUMP_POLICY" --dependabot-policy "$BUMP_DEPENDABOT_POLICY") + args+=(--out "$BUMP_OUT" --only "${BUMP_ONLY:-}") if [[ "${BUMP_DRY_RUN:-false}" == true ]]; then args+=(--dry-run); fi python -B "$engine" prepare "${args[@]}" count=$(jq '[.bumps[] | select(.outcome == "prepared" or .outcome == "stale-pr")] | length' \ diff --git a/.github/actions/bump-versions/test_bump.py b/.github/actions/bump-versions/test_bump.py index 75287c5e..76f52017 100644 --- a/.github/actions/bump-versions/test_bump.py +++ b/.github/actions/bump-versions/test_bump.py @@ -27,6 +27,7 @@ POLICY = {"cooldownDays": {"own": 0, "patch": 3, "minor": 3, "major": 7}} THIRD = {"class": "third-party"} NOW = dt.datetime(2026, 11, 2, 12, tzinfo=dt.UTC) +DISARMED = mock.Mock(returncode=0, stderr="") def release(version: str, days_old: float) -> object: @@ -47,6 +48,19 @@ def test_classes(self) -> None: self.assertEqual(bump.change_class("2026.9.12", "2026.10.24"), "minor") self.assertEqual(bump.change_class("2026.12.3", "2027.1.0"), "major") + def test_below_one_a_minor_move_is_major(self) -> None: + # The Dependabot gate's breaksBelowOne rule: 0.24 -> 0.25 breaks, 0.24.2 -> 0.24.3 not. + self.assertEqual(bump.change_class("0.24.2", "0.25.0"), "major") + self.assertEqual(bump.change_class("0.24.2", "0.24.3"), "patch") + self.assertEqual(bump.change_class("0.38.2", "1.0.0"), "major") + self.assertEqual(bump.cooldown_days(POLICY, THIRD, "0.38.2", "0.39.0"), 7) + + def test_line_major_below_one_keeps_the_minor(self) -> None: + self.assertFalse(bump.in_line((0, 24, 2), (0, 25, 0), "major")) + self.assertTrue(bump.in_line((0, 24, 2), (0, 24, 3), "major")) + self.assertTrue(bump.in_line((1, 4, 0), (1, 5, 0), "major")) + self.assertFalse(bump.in_line((1, 4, 0), (2, 0, 0), "major")) + def test_cooldown_by_class_and_override(self) -> None: self.assertEqual(bump.cooldown_days(POLICY, THIRD, "1.0.0", "1.0.1"), 3) self.assertEqual(bump.cooldown_days(POLICY, THIRD, "1.0.0", "2.0.0"), 7) @@ -192,14 +206,14 @@ def test_tag_source_waits_for_cooldown_from_bot_commit(self) -> None: row = {"pr": 9, "tagCooldown": 3} with ( mock.patch.object(bump, "run") as run, - mock.patch.object(bump, "run_raw") as run_raw, + mock.patch.object(bump, "run_raw", return_value=DISARMED) as run_raw, ): - bump.arm_auto_merge(row, "o/r", {"date": recent}) + bump.arm_auto_merge(row, [], "o/r", {"date": recent}) run.assert_not_called() # An arm left from an earlier target is disabled while the new one waits. self.assertIn("--disable-auto", run_raw.call_args.args[0]) self.assertFalse(row["autoMerge"]) - bump.arm_auto_merge(row, "o/r", {"date": old}) + bump.arm_auto_merge(row, [], "o/r", {"date": old}) run.assert_called_once() self.assertTrue(row["autoMerge"]) @@ -215,11 +229,71 @@ def test_author_dated_sources_wait_from_first_sighting(self) -> None: self.assertEqual(bump.tag_cooldown(rows[:1]), 3) self.assertEqual(bump.tag_cooldown(rows[2:]), 0) + def test_bump_with_a_major_row_is_left_for_review(self) -> None: + rows = [ + {"current": "1.4.0", "target": "1.5.0"}, + {"current": "0.24.2", "target": "0.25.0"}, + ] + self.assertTrue(bump.has_major(rows)) + self.assertFalse(bump.has_major(rows[:1])) + self.assertTrue(bump.has_major([{"current": "2.9.0", "target": "3.0.0"}])) + + def test_publish_holds_a_bump_with_a_major_row_for_review(self) -> None: + cases = { + "major": [{"tool": "x", "current": "2.9.0", "target": "3.0.0"}], + "0.x minor": [ + {"current": "1.4.0", "target": "1.5.0"}, + {"current": "0.24.2", "target": "0.25.0"}, + ], + } + marker = "" + open_pr = {"number": 9, "body": marker, "mergeable": "MERGEABLE"} + tip = {"sha": "a" * 40, "date": dt.datetime.now(dt.UTC).isoformat()} + for name, rows in cases.items(): + with ( + self.subTest(name), + mock.patch.object(bump, "own_prs", return_value=[open_pr]), + mock.patch.object(bump, "branch_tip", return_value=tip), + mock.patch.object(bump, "run") as run, + mock.patch.object(bump, "run_raw", return_value=DISARMED) as run_raw, + ): + pin = {"branch": "b", "name": "n", "targets": "t"} + bump.publish_bump( + pin, rows, Path(), "o/r", "base", "app", "u", {}, "main" + ) + calls = [c.args[0] for c in run_raw.call_args_list] + self.assertIn( + ["gh", "pr", "merge", "9", "-R", "o/r", "--disable-auto"], calls + ) + armed = [c.args[0] for c in run.call_args_list if "--auto" in c.args[0]] + self.assertEqual( + armed, [], "a bump holding a major must never be armed" + ) + self.assertFalse(pin["autoMerge"]) + self.assertEqual(pin["detail"], "a major release waits for review") + + def test_disarm_accepts_a_failure_only_where_no_arm_exists(self) -> None: + failed = mock.Mock(returncode=1, stderr="GraphQL: something went wrong") + with ( + mock.patch.object(bump, "run_raw", return_value=failed), + mock.patch.object(bump, "run", return_value="false\n") as run, + ): + bump.disarm_auto_merge(9, "o/r") + self.assertIn("autoMergeRequest", run.call_args.args[0]) + with ( + mock.patch.object(bump, "run_raw", return_value=failed), + mock.patch.object(bump, "run", return_value="true\n"), + self.assertRaisesRegex( + bump.BumpError, "could not disable auto-merge on #9" + ), + ): + bump.disarm_auto_merge(9, "o/r") + def test_release_source_arms_at_once(self) -> None: row = {"pr": 9, "tagCooldown": 0} now = dt.datetime.now(dt.UTC).isoformat() with mock.patch.object(bump, "run") as run: - bump.arm_auto_merge(row, "o/r", {"date": now}) + bump.arm_auto_merge(row, [], "o/r", {"date": now}) run.assert_called_once() @@ -787,6 +861,147 @@ def test_no_hooks_is_a_no_op(self) -> None: run.assert_not_called() +class SyncedCooldownTest(unittest.TestCase): + """cooldownDays come from the policy file, else the synced standards dependabot-policy.""" + + # standards components/dependabot-policy/policy.json schemaVersion 2 (standards#987). + SYNCED = { + "schemaVersion": 2, + "cooldownMinimumDays": 3, + "cooldownDays": {"own": 0, "patch": 3, "minor": 3, "major": 7}, + } + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.yaml = self.root / "policy.yaml" + self.synced = self.root / "policy.json" + + def load(self, yaml_doc: dict[str, Any]) -> dict[str, Any]: + with mock.patch.object(bump, "yq_json", return_value=yaml_doc): + return bump.load_policy(self.yaml, self.synced) + + def test_policy_without_cooldown_takes_the_synced_classes(self) -> None: + self.synced.write_text(json.dumps(self.SYNCED), encoding="utf-8") + policy = self.load({"tools": {}}) + self.assertEqual( + policy["cooldownDays"], {"own": 0, "patch": 3, "minor": 3, "major": 7} + ) + self.assertEqual(bump.cooldown_days(policy, THIRD, "1.0.0", "2.0.0"), 7) + self.assertEqual(bump.cooldown_days(policy, THIRD, "1.0.0", "1.0.1"), 3) + + def test_policy_cooldown_wins_over_the_synced_copy(self) -> None: + self.synced.write_text(json.dumps(self.SYNCED), encoding="utf-8") + own = {"own": 1, "patch": 2, "minor": 4, "major": 9} + policy = self.load({"cooldownDays": own, "tools": {}}) + self.assertEqual(policy["cooldownDays"], own) + self.assertEqual(bump.cooldown_days(policy, THIRD, "1.0.0", "2.0.0"), 9) + + def test_policy_cooldown_needs_no_synced_copy(self) -> None: + own = {"own": 0, "patch": 3, "minor": 3, "major": 7} + self.assertEqual(self.load({"cooldownDays": own})["cooldownDays"], own) + + def test_neither_source_fails_naming_both_paths(self) -> None: + with self.assertRaises(SystemExit) as raised: + self.load({"tools": {}}) + self.assertIn(str(self.yaml), str(raised.exception)) + self.assertIn(str(self.synced), str(raised.exception)) + + def test_synced_copy_missing_a_class_fails(self) -> None: + self.synced.write_text( + json.dumps({"cooldownDays": {"own": 0, "patch": 3, "minor": 3}}), + encoding="utf-8", + ) + with self.assertRaisesRegex(SystemExit, "own, patch, minor, major"): + self.load({"tools": {}}) + + def test_synced_copy_with_a_non_integer_class_fails(self) -> None: + days = {"own": False, "patch": 3, "minor": 3, "major": 7} + self.synced.write_text(json.dumps({"cooldownDays": days}), encoding="utf-8") + with self.assertRaisesRegex(SystemExit, "non-negative integer"): + self.load({"tools": {}}) + + def test_synced_copy_that_is_not_json_fails(self) -> None: + self.synced.write_text("{", encoding="utf-8") + with self.assertRaisesRegex(SystemExit, "not valid JSON"): + self.load({"tools": {}}) + + def test_cpython_source_waits_the_synced_patch_cooldown(self) -> None: + self.synced.write_text(json.dumps(self.SYNCED), encoding="utf-8") + policy = self.load({"tools": {}}) + uv = { + "draft": False, + "prerelease": False, + "published_at": (NOW - dt.timedelta(days=2)).isoformat(), + "tag_name": "0.9.0", + } + with ( + mock.patch.object(bump, "gh_json", return_value=[uv]), + self.assertRaisesRegex(bump.BumpError, "past the cooldown"), + ): + bump.cpython_releases(policy, NOW) + + +REPO = SCRIPT.parents[3] +OWN_POLICY = REPO / ".github" / "version-bump" / "policy.yaml" + + +@unittest.skipUnless(shutil.which("yq"), "needs yq (bump-versions/install-tools.sh)") +class OwnPolicyPinsTest(unittest.TestCase): + """This repository's own policy: every pin it names is one line a bump can rewrite.""" + + @classmethod + def setUpClass(cls) -> None: + cls.policy = bump.yq_json(".", OWN_POLICY) + + def assert_one_line(self, path: Any, file: Path) -> str: + text = bump.read_exact(file) + if isinstance(path, dict): + return bump.regex_pin(text, path["regex"])[0] + old = bump.yq_json(path, file) + self.assertIsInstance(old, str, f"{path} in {file} is not a string scalar") + bump.locate_line(text, path, old, bump.yq_line(path, file)) + return old + + def test_cooldowns_come_from_the_synced_org_policy(self) -> None: + self.assertNotIn("cooldownDays", self.policy) + + def test_every_tool_sets_a_line(self) -> None: + # A merged bump reaches every @main caller, so no tool may cross a major unattended. + for name, tool in self.policy["tools"].items(): + with self.subTest(tool=name): + self.assertIn(tool.get("line"), {"major", "minor"}) + + def test_every_version_and_field_pin_is_on_one_line(self) -> None: + for name, tool in self.policy["tools"].items(): + with self.subTest(tool=name): + file = REPO / tool["file"] + scalar = self.assert_one_line(tool["version"], file) + self.assertIsNotNone( + bump.parse_version(bump.pinned_version(tool, scalar)) + ) + for item in tool.get("fields", []): + self.assert_one_line( + item["path"], REPO / item.get("file", tool["file"]) + ) + + def test_tools_in_one_group_pin_the_same_release(self) -> None: + tools = self.policy["tools"] + for group, names in bump.group_units(tools).items(): + with self.subTest(group=group): + versions = { + bump.pinned_version( + tools[n], + self.assert_one_line( + tools[n]["version"], REPO / tools[n]["file"] + ), + ) + for n in names + } + self.assertEqual(len(versions), 1, f"{group} pins {sorted(versions)}") + + def subprocess_result(stdout: str, returncode: int) -> Any: return mock.Mock(stdout=stdout, returncode=returncode) diff --git a/.github/actions/check-dependabot-policy/action.yml b/.github/actions/check-dependabot-policy/action.yml new file mode 100644 index 00000000..f78339c0 --- /dev/null +++ b/.github/actions/check-dependabot-policy/action.yml @@ -0,0 +1,50 @@ +name: Check Dependabot Policy +description: >- + Check the caller's Dependabot config against the organization dependency-update + policy. A consumer calls it as a step of its ci-status lane jobs. It fails + closed (exit 2) when the synced policy from the standards dependabot-policy + component is absent. + +inputs: + root: + description: Repository root the other paths resolve against. + default: '.' + policy: + description: Path to the synced policy file, relative to root. + default: .github/standards/dependabot-policy/policy.json + config: + description: Path to the repository's exception file, relative to root. + default: .github/dependabot-policy.json + dependabot: + description: Path to the Dependabot config, relative to root. + default: .github/dependabot.yml + node-version: + description: Node.js version to set up. + default: 24.21.0 + +runs: + using: composite + steps: + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Check Dependabot policy + shell: bash + env: + ROOT: ${{ inputs.root }} + POLICY: ${{ inputs.policy }} + CONFIG: ${{ inputs.config }} + DEPENDABOT: ${{ inputs.dependabot }} + run: | + set -euo pipefail + + dir="$(mktemp -d "${RUNNER_TEMP%/}/check-dependabot-policy.XXXXXXXX")" + cp "$GITHUB_ACTION_PATH/package.json" "$GITHUB_ACTION_PATH/package-lock.json" \ + "$GITHUB_ACTION_PATH/check-dependabot-policy.mjs" \ + "$GITHUB_ACTION_PATH/policy.schema.json" \ + "$GITHUB_ACTION_PATH/dependabot-policy.schema.json" "$dir/" + npm ci --prefix "$dir" --ignore-scripts --no-audit --no-fund + node "$dir/check-dependabot-policy.mjs" --root "$ROOT" --policy "$POLICY" \ + --config "$CONFIG" --dependabot "$DEPENDABOT" diff --git a/.github/actions/check-dependabot-policy/check-dependabot-policy.mjs b/.github/actions/check-dependabot-policy/check-dependabot-policy.mjs new file mode 100755 index 00000000..cd44866d --- /dev/null +++ b/.github/actions/check-dependabot-policy/check-dependabot-policy.mjs @@ -0,0 +1,542 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { parseDocument } from "yaml"; + +export class ConfigurationError extends Error { + constructor(message) { + super(message); + this.name = "ConfigurationError"; + } +} + +export function parseUniqueJson(source, location) { + const document = parseDocument(source, { + maxAliasCount: 0, + merge: false, + prettyErrors: true, + schema: "json", + strict: true, + uniqueKeys: true, + }); + if (document.errors.length > 0) { + throw new ConfigurationError( + `${location} has duplicate object members or ambiguous structure: ${document.errors[0].message}`, + ); + } + try { + return JSON.parse(source); + } catch (error) { + throw new ConfigurationError(`${location} is not valid JSON: ${error.message}`); + } +} + +const MODULE_DIRECTORY = import.meta.dirname; +const DEFAULT_POLICY_PATH = ".github/standards/dependabot-policy/policy.json"; +const DEFAULT_CONFIG_PATH = ".github/dependabot-policy.json"; +const DEFAULT_DEPENDABOT_PATH = ".github/dependabot.yml"; +const POLICY_SCHEMA_PATH = path.join(MODULE_DIRECTORY, "policy.schema.json"); +const CONFIG_SCHEMA_PATH = path.join(MODULE_DIRECTORY, "dependabot-policy.schema.json"); +const POLICY_SCHEMA = parseUniqueJson( + await readFile(POLICY_SCHEMA_PATH, "utf8"), + `dependabot policy schema at ${POLICY_SCHEMA_PATH}`, +); +const CONFIG_SCHEMA = parseUniqueJson( + await readFile(CONFIG_SCHEMA_PATH, "utf8"), + `repository dependabot policy schema at ${CONFIG_SCHEMA_PATH}`, +); +const SCHEMA_VALIDATOR = new Ajv2020({ + allErrors: false, + strict: true, + validateFormats: false, +}); +const validatePolicyStructure = SCHEMA_VALIDATOR.compile(POLICY_SCHEMA); +const validateConfigStructure = SCHEMA_VALIDATOR.compile(CONFIG_SCHEMA); + +// Which policy rule each waive category suppresses. +const WAIVE_BY_RULE = { + "schedule-not-standard": "schedule", + "cooldown-below-minimum": "cooldown", + "cooldown-soak-bypassed": "cooldown", + "cooldown-major-below-major-class": "cooldown", + "cooldown-exclude-outside-fast-track": "cooldown", + "groups-missing": "groups", +}; + +function finding(rule, file, entry, message) { + return entry === undefined ? { rule, file, message } : { rule, file, entry, message }; +} + +function jsonPointerLocation(location, instancePath) { + return `${location}${instancePath + .split("/") + .slice(1) + .map((segment) => `.${segment.replaceAll("~1", "/").replaceAll("~0", "~")}`) + .join("")}`; +} + +function validateStructure(value, validator, location) { + if (validator(value)) { + return value; + } + const [error] = validator.errors; + let errorLocation = jsonPointerLocation(location, error.instancePath); + if (error.keyword === "additionalProperties") { + errorLocation += `.${error.params.additionalProperty}`; + } else if (error.keyword === "propertyNames") { + errorLocation += `.${error.params.propertyName}`; + } + throw new ConfigurationError(`${errorLocation} ${error.message}`); +} + +async function readJson(filePath, location) { + let source; + try { + source = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + return undefined; + } + throw new ConfigurationError(`${location} could not be read at ${filePath}: ${error.message}`); + } + return parseUniqueJson(source, `${location} at ${filePath}`); +} + +async function readDependabot(filePath) { + let source; + try { + source = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + return undefined; + } + throw new ConfigurationError( + `dependabot config could not be read at ${filePath}: ${error.message}`, + ); + } + const document = parseDocument(source, { + maxAliasCount: 0, + merge: false, + prettyErrors: true, + strict: true, + uniqueKeys: true, + }); + if (document.errors.length > 0) { + throw new ConfigurationError( + `dependabot config at ${filePath} is not valid YAML: ${document.errors[0].message}`, + ); + } + const value = document.toJS({ maxAliasCount: 0 }); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ConfigurationError(`dependabot config at ${filePath} must be a mapping`); + } + return value; +} + +// A stable identifier for one `updates` entry. Dependabot keys an entry by its +// ecosystem and its directory; the plural `directories` list is joined so an +// entry that scans several roots still has one deterministic key. +function entryKey(entry) { + const ecosystem = entry["package-ecosystem"]; + const prefix = typeof ecosystem === "string" && ecosystem.length > 0 ? ecosystem : "?"; + if (typeof entry.directory === "string") { + return `${prefix}:${entry.directory}`; + } + if (Array.isArray(entry.directories)) { + return `${prefix}:${entry.directories.join(",")}`; + } + return `${prefix}:?`; +} + +// fastTrack also holds the `reviewed` and `basis` strings; only array-valued +// keys are pattern lists. +function fastTrackList(policy, name) { + const value = Object.hasOwn(policy.fastTrack, name) ? policy.fastTrack[name] : undefined; + return Array.isArray(value) ? value : []; +} + +// The cooldown floor (cooldown-below-minimum) already holds default-days at or +// above the patch class, since the policy requires the two to be equal. These +// add the major class and the fast track, the only names an exclude may hold +// (THREAT-MODEL.md, fast-track row). +function cooldownClassFindings(file, key, entry, policy) { + const findings = []; + const cooldown = entry.cooldown; + const majorDays = cooldown?.["semver-major-days"]; + if (typeof majorDays === "number" && majorDays < policy.cooldownDays.major) { + findings.push( + finding( + "cooldown-major-below-major-class", + file, + key, + `cooldown.semver-major-days must be >= ${policy.cooldownDays.major} (the major class), found ${majorDays}`, + ), + ); + } + const ecosystem = entry["package-ecosystem"]; + const allowed = new Set(fastTrackList(policy, ecosystem)); + if (ecosystem === "github-actions") { + for (const pattern of fastTrackList(policy, "own")) { + allowed.add(pattern); + } + } + const exclude = Array.isArray(cooldown?.exclude) ? cooldown.exclude : []; + const outside = [...new Set(exclude)].filter((pattern) => !allowed.has(pattern)).sort(); + if (outside.length > 0) { + findings.push( + finding( + "cooldown-exclude-outside-fast-track", + file, + key, + `cooldown.exclude may list only the ${ecosystem} fast track ${JSON.stringify([...allowed].sort())}; outside it: ${JSON.stringify(outside)}`, + ), + ); + } + return findings; +} + +function entryFindings(file, key, entry, policy) { + const findings = []; + const interval = entry.schedule?.interval; + if (interval !== policy.scheduleInterval) { + findings.push( + finding( + "schedule-not-standard", + file, + key, + `schedule.interval must be ${JSON.stringify(policy.scheduleInterval)}, found ${JSON.stringify(interval ?? null)}`, + ), + ); + } + // Every configured cooldown day-count must meet the floor. A semver-specific + // override (semver-patch-days: 0, say) takes precedence over default-days for + // that update type, so a short override reintroduces the bot burst the soak + // exists to prevent. + const cooldown = entry.cooldown; + const dayKeys = ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days"]; + const belowFloor = dayKeys.filter((dayKey) => { + const value = cooldown?.[dayKey]; + if (dayKey === "default-days") { + return typeof value !== "number" || value < policy.cooldownMinimumDays; + } + return typeof value === "number" && value < policy.cooldownMinimumDays; + }); + if (belowFloor.length > 0) { + findings.push( + finding( + "cooldown-below-minimum", + file, + key, + `cooldown ${belowFloor.join(", ")} must each be an integer >= ${policy.cooldownMinimumDays}`, + ), + ); + } else { + // A default-days floor is only a soak if it actually applies. A match-all + // `exclude` skips the soak for every dependency, and an `include` list + // inverts the soak to opt-in, so both defeat the control. A narrow exclude + // (a fast-tracked first-party owner, say) still soaks everything else. + const exclude = cooldown?.exclude; + const include = cooldown?.include; + const matchAllExclude = Array.isArray(exclude) && exclude.includes("*"); + const restrictiveInclude = Array.isArray(include) && include.length > 0; + if (matchAllExclude || restrictiveInclude) { + findings.push( + finding( + "cooldown-soak-bypassed", + file, + key, + matchAllExclude + ? 'cooldown.exclude contains the match-all pattern "*", so the soak applies to nothing' + : "cooldown.include restricts the soak to a subset, so most updates skip it", + ), + ); + } + } + findings.push(...cooldownClassFindings(file, key, entry, policy)); + if (policy.requireGroups) { + const groups = entry.groups; + const groupValues = + groups !== null && typeof groups === "object" && !Array.isArray(groups) + ? Object.values(groups) + : []; + // A group only batches version updates when its applies-to is absent (the + // default) or version-updates; a security-updates-only group leaves regular + // bumps at one pull request per dependency. + const coversVersionUpdates = groupValues.some((group) => { + if (group === null || typeof group !== "object" || Array.isArray(group)) { + return false; + } + const appliesTo = group["applies-to"]; + const isVersionGroup = appliesTo === undefined || appliesTo === "version-updates"; + const excludePatterns = group["exclude-patterns"]; + const excludesEverything = Array.isArray(excludePatterns) && excludePatterns.includes("*"); + return isVersionGroup && !excludesEverything; + }); + if (!coversVersionUpdates) { + findings.push( + finding( + "groups-missing", + file, + key, + "a groups block covering version updates is required so related bumps batch into one pull request", + ), + ); + } + } + const limit = entry["open-pull-requests-limit"]; + if (typeof limit === "number" && limit < 1) { + findings.push( + finding( + "pr-limit-disables-updates", + file, + key, + `open-pull-requests-limit of ${limit} disables version updates; set it between 1 and ${policy.maxOpenPullRequests}`, + ), + ); + } else if (typeof limit === "number" && limit > policy.maxOpenPullRequests) { + findings.push( + finding( + "pr-limit-too-high", + file, + key, + `open-pull-requests-limit must be <= ${policy.maxOpenPullRequests}, found ${limit}`, + ), + ); + } + // A match-all ignore with no version or update-type narrowing suppresses every + // update for the ecosystem, disabling the policy instead of shaping volume. A + // narrow ignore (a specific dependency or first-party owner) is unaffected. + const ignore = Array.isArray(entry.ignore) ? entry.ignore : []; + const disablingIgnore = ignore.some( + (rule) => + rule !== null && + typeof rule === "object" && + !Array.isArray(rule) && + rule["dependency-name"] === "*" && + rule.versions === undefined && + rule["update-types"] === undefined, + ); + if (disablingIgnore) { + findings.push( + finding( + "ignore-disables-updates", + file, + key, + 'an ignore rule for dependency-name "*" with no version or update-type narrowing suppresses every update', + ), + ); + } + return findings; +} + +export async function auditRepository({ + root = process.cwd(), + configPath = DEFAULT_CONFIG_PATH, + policyPath = DEFAULT_POLICY_PATH, + dependabotPath = DEFAULT_DEPENDABOT_PATH, +} = {}) { + const resolvedRoot = path.resolve(root); + const resolve = (candidate) => + path.isAbsolute(candidate) ? candidate : path.join(resolvedRoot, candidate); + const resolvedPolicyPath = resolve(policyPath); + const rawPolicy = await readJson(resolvedPolicyPath, "dependabot policy"); + if (rawPolicy === undefined) { + throw new ConfigurationError( + `dependabot policy not found at ${resolvedPolicyPath}; the repository must receive the standards dependabot-policy sync component`, + ); + } + const policy = validateStructure(rawPolicy, validatePolicyStructure, "policy"); + // The floor is the patch class: a policy where they differ would enforce a + // soak no reader of cooldownDays expects. + if (policy.cooldownMinimumDays !== policy.cooldownDays.patch) { + throw new ConfigurationError( + `policy.cooldownMinimumDays (${policy.cooldownMinimumDays}) must equal policy.cooldownDays.patch (${policy.cooldownDays.patch})`, + ); + } + const rawConfig = await readJson(resolve(configPath), "repository dependabot policy"); + const config = + rawConfig === undefined + ? { schemaVersion: 1, exceptions: {} } + : validateStructure(rawConfig, validateConfigStructure, "dependabot policy"); + const exceptions = config.exceptions ?? {}; + + const dependabot = await readDependabot(resolve(dependabotPath)); + const findings = []; + const file = dependabotPath; + if (dependabot === undefined) { + findings.push( + finding( + "dependabot-config-missing", + file, + undefined, + `the repository declares no ${dependabotPath}, so no dependency-update policy is enforced`, + ), + ); + return sortFindings(findings); + } + + if (dependabot.version !== 2) { + findings.push( + finding( + "unsupported-version", + file, + undefined, + `dependabot config version must be 2, found ${JSON.stringify(dependabot.version ?? null)}`, + ), + ); + } + const updates = Array.isArray(dependabot.updates) ? dependabot.updates : []; + if (updates.length === 0) { + findings.push( + finding( + "updates-missing", + file, + undefined, + "dependabot config declares no updates entries, so no dependency-update policy is enforced", + ), + ); + } + + // (entryKey, waivedRule) pairs that suppressed a real finding. + const consumedWaives = new Set(); + const seenKeys = new Set(); + for (const [index, entry] of updates.entries()) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + findings.push( + finding( + "malformed-update-entry", + file, + `updates[${index}]`, + "each updates entry must be a mapping with a package-ecosystem and a directory", + ), + ); + continue; + } + const ecosystem = entry["package-ecosystem"]; + const hasDirectory = typeof entry.directory === "string" || Array.isArray(entry.directories); + if (typeof ecosystem !== "string" || ecosystem.length === 0 || !hasDirectory) { + findings.push( + finding( + "incomplete-update-entry", + file, + `updates[${index}]`, + "each updates entry requires package-ecosystem and directory or directories", + ), + ); + continue; + } + const key = entryKey(entry); + seenKeys.add(key); + const exception = Object.hasOwn(exceptions, key) ? exceptions[key] : undefined; + const waived = new Set(exception?.waives ?? []); + for (const item of entryFindings(file, key, entry, policy)) { + const waiveCategory = WAIVE_BY_RULE[item.rule]; + if (waiveCategory && waived.has(waiveCategory)) { + consumedWaives.add(`${key}\0${waiveCategory}`); + continue; + } + findings.push(item); + } + } + + for (const [key, exception] of Object.entries(exceptions)) { + if (!seenKeys.has(key)) { + findings.push( + finding( + "exception-inventory-drift", + file, + key, + "dependabot-policy exception names an updates entry that does not exist", + ), + ); + continue; + } + for (const waiveCategory of exception.waives) { + if (!consumedWaives.has(`${key}\0${waiveCategory}`)) { + findings.push( + finding( + "exception-inventory-drift", + file, + key, + `waives ${JSON.stringify(waiveCategory)} but that rule is already satisfied; remove the unused waiver`, + ), + ); + } + } + } + + return sortFindings(findings); +} + +function sortFindings(findings) { + const unique = new Map(); + for (const item of findings) { + const key = [item.file, item.entry ?? "", item.rule, item.message].join("\0"); + if (!unique.has(key)) { + unique.set(key, item); + } + } + return [...unique.values()].sort((left, right) => + [left.file, left.entry ?? "", left.rule, left.message] + .join("\0") + .localeCompare([right.file, right.entry ?? "", right.rule, right.message].join("\0")), + ); +} + +export function parseArguments(argv) { + try { + const { values } = parseArgs({ + args: argv, + allowPositionals: false, + options: { + config: { default: DEFAULT_CONFIG_PATH, type: "string" }, + dependabot: { default: DEFAULT_DEPENDABOT_PATH, type: "string" }, + json: { default: false, type: "boolean" }, + policy: { default: DEFAULT_POLICY_PATH, type: "string" }, + root: { default: process.cwd(), type: "string" }, + }, + strict: true, + }); + return { + root: values.root, + configPath: values.config, + policyPath: values.policy, + dependabotPath: values.dependabot, + json: values.json, + }; + } catch (error) { + throw new ConfigurationError(error instanceof Error ? error.message : String(error)); + } +} + +async function main() { + try { + const { json, ...options } = parseArguments(process.argv.slice(2)); + const findings = await auditRepository(options); + if (json) { + process.stdout.write(`${JSON.stringify({ findings, ok: findings.length === 0 }, null, 2)}\n`); + } else if (findings.length === 0) { + process.stdout.write("Dependabot policy passed.\n"); + } else { + for (const item of findings) { + const location = item.entry ? `${item.file}#${item.entry}` : item.file; + process.stderr.write(`${location}: ${item.rule}: ${item.message}\n`); + } + } + process.exitCode = findings.length === 0 ? 0 : 1; + } catch (error) { + const output = error instanceof Error ? error.message : String(error); + process.stderr.write(`dependabot-policy: ${output}\n`); + process.exitCode = 2; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/.github/actions/check-dependabot-policy/check-dependabot-policy.test.mjs b/.github/actions/check-dependabot-policy/check-dependabot-policy.test.mjs new file mode 100644 index 00000000..1375e38b --- /dev/null +++ b/.github/actions/check-dependabot-policy/check-dependabot-policy.test.mjs @@ -0,0 +1,876 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + auditRepository, + ConfigurationError, + parseArguments, + parseUniqueJson, +} from "./check-dependabot-policy.mjs"; + +const POLICY_FIXTURE = new URL("./fixtures/policy.json", import.meta.url); +const SYNCED_POLICY = path.join(".github", "standards", "dependabot-policy", "policy.json"); + +const temporaryRoots = []; + +const NPM_COOLDOWN = ` cooldown: + default-days: 3 + semver-major-days: 7 + exclude: + - "@anthropic-ai/claude-code" +`; + +const CONFORMANT = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + npm-minor-patch: + update-types: + - minor + - patch +`; + +function dependabot(entries) { + return `version: 2\nupdates:\n${entries}`; +} + +function withCooldown(lines) { + return CONFORMANT.replace(NPM_COOLDOWN, ` cooldown:\n${lines}`); +} + +async function repository({ config, dependabotYaml, syncedPolicy = true } = {}) { + const root = await mkdtemp(path.join(tmpdir(), "check-dependabot-policy-")); + temporaryRoots.push(root); + await mkdir(path.join(root, ".github"), { recursive: true }); + if (syncedPolicy) { + await mkdir(path.dirname(path.join(root, SYNCED_POLICY)), { recursive: true }); + await copyFile(POLICY_FIXTURE, path.join(root, SYNCED_POLICY)); + } + if (dependabotYaml !== undefined) { + await writeFile(path.join(root, ".github", "dependabot.yml"), dependabotYaml); + } + if (config !== undefined) { + await writeFile( + path.join(root, ".github", "dependabot-policy.json"), + typeof config === "string" ? config : `${JSON.stringify(config, null, 2)}\n`, + ); + } + return root; +} + +function rules(findings) { + return findings.map((item) => `${item.entry ?? item.file}:${item.rule}`).sort(); +} + +test.after(async () => { + await Promise.all(temporaryRoots.map((root) => rm(root, { force: true, recursive: true }))); +}); + +test("command-line parsing preserves defaults and follows strict Node syntax", () => { + assert.deepEqual(parseArguments(["--"]), { + root: process.cwd(), + configPath: ".github/dependabot-policy.json", + policyPath: ".github/standards/dependabot-policy/policy.json", + dependabotPath: ".github/dependabot.yml", + json: false, + }); + assert.deepEqual( + parseArguments([ + "--json", + "--root=repo", + "--config=config.json", + "--policy=policy.json", + "--dependabot=deps.yml", + ]), + { + root: "repo", + configPath: "config.json", + policyPath: "policy.json", + dependabotPath: "deps.yml", + json: true, + }, + ); + assert.equal(parseArguments(["--policy=-policy.json"]).policyPath, "-policy.json"); + + for (const [argv, pattern] of [ + [["--unknown"], /Unknown option/u], + [["--policy", "-policy.json"], /ambiguous/u], + [["repo"], /Unexpected argument/u], + ]) { + assert.throws( + () => parseArguments(argv), + (error) => error instanceof ConfigurationError && pattern.test(error.message), + ); + } +}); + +test("a fully conformant config passes", async () => { + const root = await repository({ dependabotYaml: dependabot(CONFORMANT) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("multiple conformant entries including the directories plural form pass", async () => { + const entries = `${CONFORMANT} - package-ecosystem: github-actions + directories: + - / + - /.github/actions/* + open-pull-requests-limit: 5 + schedule: + interval: weekly + cooldown: + default-days: 3 + semver-major-days: 7 + exclude: + - "anthropics/*" + - "melodic-software/*" + groups: + github-actions: + patterns: + - "*" +`; + const root = await repository({ dependabotYaml: dependabot(entries) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a longer default cooldown than the patch class passes", async () => { + const entry = CONFORMANT.replace("default-days: 3", "default-days: 14"); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a missing dependabot.yml is flagged", async () => { + const root = await repository({}); + assert.deepEqual(rules(await auditRepository({ root })), [ + ".github/dependabot.yml:dependabot-config-missing", + ]); +}); + +test("a non-weekly schedule is flagged", async () => { + const entry = CONFORMANT.replace("interval: weekly", "interval: daily"); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:schedule-not-standard"]); +}); + +test("a missing cooldown is flagged", async () => { + const entry = ` - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + npm-minor-patch: + update-types: + - minor + - patch +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), [ + "npm:/:cooldown-below-minimum", + ]); +}); + +test("a cooldown below the floor is flagged", async () => { + const entry = CONFORMANT.replace("default-days: 3", "default-days: 2"); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), [ + "npm:/:cooldown-below-minimum", + ]); +}); + +test("a match-all cooldown exclude bypasses the soak and is flagged", async () => { + const entry = withCooldown( + ' default-days: 3\n semver-major-days: 7\n exclude:\n - "*"\n', + ); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), [ + "npm:/:cooldown-exclude-outside-fast-track", + "npm:/:cooldown-soak-bypassed", + ]); +}); + +test("a cooldown include restricts the soak and is flagged", async () => { + const entry = withCooldown( + ' default-days: 3\n semver-major-days: 7\n exclude:\n - "@anthropic-ai/claude-code"\n include:\n - "some-dep"\n', + ); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:cooldown-soak-bypassed"]); +}); + +test("a cooldown waiver also suppresses a bypassed soak", async () => { + const entry = withCooldown( + ' default-days: 3\n semver-major-days: 7\n exclude:\n - "*"\n', + ); + const root = await repository({ + dependabotYaml: dependabot(entry), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { + reason: "tracks-upstream-release", + justification: "This root intentionally opts out of the soak.", + waives: ["cooldown"], + }, + }, + }, + }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a version 2 config with no updates entries fails closed", async () => { + const noUpdates = await repository({ dependabotYaml: "version: 2\n" }); + assert.deepEqual(rules(await auditRepository({ root: noUpdates })), [ + ".github/dependabot.yml:updates-missing", + ]); + const nonArray = await repository({ dependabotYaml: "version: 2\nupdates:\n foo: bar\n" }); + assert.deepEqual(rules(await auditRepository({ root: nonArray })), [ + ".github/dependabot.yml:updates-missing", + ]); +}); + +test("a missing groups block is flagged", async () => { + const entry = ` - package-ecosystem: npm + directory: / + schedule: + interval: weekly +${NPM_COOLDOWN}`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:groups-missing"]); +}); + +test("an open-pull-requests-limit above the maximum is flagged", async () => { + const entry = CONFORMANT.replace("open-pull-requests-limit: 5", "open-pull-requests-limit: 10"); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:pr-limit-too-high"]); +}); + +test("a zero open-pull-requests-limit disables updates and is flagged", async () => { + const entry = CONFORMANT.replace("open-pull-requests-limit: 5", "open-pull-requests-limit: 0"); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:pr-limit-disables-updates"]); +}); + +test("a groups block that only applies to security updates is flagged", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + security-only: + applies-to: security-updates + patterns: + - "*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:groups-missing"]); +}); + +test("a groups block that explicitly applies to version updates passes", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + versions: + applies-to: version-updates + patterns: + - "*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a version group that excludes every pattern is flagged", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + versions: + patterns: + - "*" + exclude-patterns: + - "*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:groups-missing"]); +}); + +test("a match-all ignore rule disables updates and is flagged", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + npm-minor-patch: + update-types: + - minor + - patch + ignore: + - dependency-name: "*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:ignore-disables-updates"]); +}); + +test("a narrow ignore rule still passes", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: weekly +${NPM_COOLDOWN} groups: + npm-minor-patch: + update-types: + - minor + - patch + ignore: + - dependency-name: "melodic-software/ci-workflows/*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("an omitted open-pull-requests-limit is accepted (GitHub default is the maximum)", async () => { + const entry = CONFORMANT.replace(" open-pull-requests-limit: 5\n", ""); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("an unsupported version is flagged", async () => { + const root = await repository({ dependabotYaml: `version: 1\nupdates:\n${CONFORMANT}` }); + assert.deepEqual(rules(await auditRepository({ root })), [ + ".github/dependabot.yml:unsupported-version", + ]); +}); + +test("tracks-upstream-release waives schedule and cooldown for a fast-tracked entry", async () => { + const entry = ` - package-ecosystem: npm + directory: / + open-pull-requests-limit: 5 + schedule: + interval: daily + groups: + npm-minor-patch: + update-types: + - minor + - patch +`; + const root = await repository({ + dependabotYaml: dependabot(entry), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { + reason: "tracks-upstream-release", + justification: "This root tracks the latest upstream release deliberately.", + waives: ["schedule", "cooldown"], + }, + }, + }, + }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("single-tool-ecosystem waives the groups requirement", async () => { + const entry = ` - package-ecosystem: pip + directory: /.github + schedule: + interval: weekly + cooldown: + default-days: 3 + semver-major-days: 7 +`; + const root = await repository({ + dependabotYaml: dependabot(entry), + config: { + schemaVersion: 1, + exceptions: { + "pip:/.github": { + reason: "single-tool-ecosystem", + justification: "One pinned tool; grouping is a no-op.", + waives: ["groups"], + }, + }, + }, + }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("an exception on a non-existent entry is drift", async () => { + const root = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { + "gomod:/": { + reason: "single-tool-ecosystem", + justification: "No such entry.", + waives: ["groups"], + }, + }, + }, + }); + assert.deepEqual(rules(await auditRepository({ root })), ["gomod:/:exception-inventory-drift"]); +}); + +test("a waiver for an already-satisfied rule is drift", async () => { + const root = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { + reason: "single-tool-ecosystem", + justification: "Groups are actually present, so this waiver is unused.", + waives: ["groups"], + }, + }, + }, + }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:exception-inventory-drift"]); +}); + +test("an unknown reason, unknown waiver, or missing justification fails closed", async () => { + const badReason = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { "npm:/": { reason: "because", justification: "x", waives: ["groups"] } }, + }, + }); + await assert.rejects( + auditRepository({ root: badReason }), + (error) => error instanceof ConfigurationError, + ); + const badWaive = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { reason: "single-tool-ecosystem", justification: "x", waives: ["limit"] }, + }, + }, + }); + await assert.rejects( + auditRepository({ root: badWaive }), + (error) => error instanceof ConfigurationError, + ); + const noJustification = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { "npm:/": { reason: "single-tool-ecosystem", waives: ["groups"] } }, + }, + }); + await assert.rejects( + auditRepository({ root: noJustification }), + (error) => error instanceof ConfigurationError, + ); +}); + +test("an unknown config key fails closed", async () => { + const root = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { schemaVersion: 1, exceptions: {}, extra: true }, + }); + await assert.rejects(auditRepository({ root }), (error) => error instanceof ConfigurationError); +}); + +test("a malformed dependabot.yml fails closed", async () => { + const root = await repository({ dependabotYaml: "version: 2\nupdates: : :\n" }); + await assert.rejects(auditRepository({ root }), (error) => error instanceof ConfigurationError); +}); + +test("a scalar or null update entry is flagged", async () => { + const scalar = await repository({ dependabotYaml: "version: 2\nupdates:\n - just-a-string\n" }); + assert.deepEqual(rules(await auditRepository({ root: scalar })), [ + "updates[0]:malformed-update-entry", + ]); + const nullEntry = await repository({ dependabotYaml: "version: 2\nupdates:\n - null\n" }); + assert.deepEqual(rules(await auditRepository({ root: nullEntry })), [ + "updates[0]:malformed-update-entry", + ]); +}); + +test("an update entry missing identity keys is flagged", async () => { + const noEcosystem = await repository({ + dependabotYaml: + "version: 2\nupdates:\n - directory: /\n schedule:\n interval: weekly\n", + }); + assert.deepEqual(rules(await auditRepository({ root: noEcosystem })), [ + "updates[0]:incomplete-update-entry", + ]); + const noDirectory = await repository({ + dependabotYaml: + "version: 2\nupdates:\n - package-ecosystem: npm\n schedule:\n interval: weekly\n", + }); + assert.deepEqual(rules(await auditRepository({ root: noDirectory })), [ + "updates[0]:incomplete-update-entry", + ]); +}); + +test("a semver-specific cooldown below the floor is flagged", async () => { + const entry = CONFORMANT.replace( + "semver-major-days: 7\n", + "semver-major-days: 7\n semver-patch-days: 0\n", + ); + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(rules(await auditRepository({ root })), ["npm:/:cooldown-below-minimum"]); +}); + +test("a waiver outside the reason's scope fails closed", async () => { + const single = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { reason: "single-tool-ecosystem", justification: "x", waives: ["cooldown"] }, + }, + }, + }); + await assert.rejects( + auditRepository({ root: single }), + (error) => error instanceof ConfigurationError, + ); + const tracks = await repository({ + dependabotYaml: dependabot(CONFORMANT), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { reason: "tracks-upstream-release", justification: "x", waives: ["groups"] }, + }, + }, + }); + await assert.rejects( + auditRepository({ root: tracks }), + (error) => error instanceof ConfigurationError, + ); +}); + +test("duplicate JSON members in the config fail closed", () => { + assert.throws( + () => parseUniqueJson('{"schemaVersion":1,"schemaVersion":1}', "config at /tmp/c.json"), + (error) => error instanceof ConfigurationError && error.message.includes("duplicate"), + ); +}); + +function updateClasses() { + return { + cooldownDays: { own: 0, patch: 3, minor: 3, major: 7 }, + fastTrack: { + reviewed: "2026-11-11", + basis: "test", + npm: ["@anthropic-ai/claude-code"], + "github-actions": ["anthropics/*"], + own: ["melodic-software/*"], + }, + }; +} + +function minimalPolicy() { + return { + schemaVersion: 2, + scheduleInterval: "weekly", + cooldownMinimumDays: 3, + ...updateClasses(), + maxOpenPullRequests: 5, + requireGroups: true, + autoMerge: { + ecosystems: ["github-actions"], + publishers: { "actions/*": { tier: "auto", reviewed: "2026-10-08", basis: "test" } }, + reviewOnly: [], + excludeSemverMajor: true, + requiredCheck: "ci-status", + }, + }; +} + +async function auditWithPolicy(policy) { + const root = await repository({ dependabotYaml: dependabot(CONFORMANT) }); + const policyPath = path.join(root, "policy.json"); + await writeFile(policyPath, JSON.stringify(policy)); + return auditRepository({ root, policyPath }); +} + +test("the shipped policy records the owner's cooldown classes and fast track", async () => { + const policy = parseUniqueJson(await readFile(POLICY_FIXTURE, "utf8"), "policy.json"); + assert.equal(policy.schemaVersion, 2); + assert.deepEqual(policy.cooldownDays, { own: 0, patch: 3, minor: 3, major: 7 }); + assert.equal(policy.cooldownMinimumDays, 3, "the analyzer floor stays the patch class"); + assert.deepEqual(policy.fastTrack.npm, ["@anthropic-ai/claude-code"]); + assert.deepEqual(policy.fastTrack["github-actions"], ["anthropics/*"]); + assert.deepEqual(policy.fastTrack.own, ["melodic-software/*"]); +}); + +test("a v2 policy with the update classes validates and audits clean", async () => { + assert.deepEqual(await auditWithPolicy(minimalPolicy()), []); +}); + +test("a floor that differs from the patch class fails closed", async () => { + const policy = minimalPolicy(); + policy.cooldownMinimumDays = 4; + await assert.rejects( + auditWithPolicy(policy), + (error) => + error instanceof ConfigurationError && + error.message.includes("must equal policy.cooldownDays.patch"), + ); +}); + +test("a malformed cooldown class or fast-track block fails closed", async () => { + const cases = [ + ["missing cooldownDays", (policy) => delete policy.cooldownDays], + ["missing fastTrack", (policy) => delete policy.fastTrack], + ["negative own days", (policy) => (policy.cooldownDays.own = -1)], + ["zero major days", (policy) => (policy.cooldownDays.major = 0)], + ["unknown class", (policy) => (policy.cooldownDays.prerelease = 3)], + ["npm name with a space", (policy) => (policy.fastTrack.npm = ["bad name"])], + [ + "action that is not a publisher glob", + (policy) => (policy.fastTrack["github-actions"] = ["anthropics/claude-code-action"]), + ], + ["own that is not a publisher glob", (policy) => (policy.fastTrack.own = ["melodic-software"])], + ["schema version 1", (policy) => (policy.schemaVersion = 1)], + ["schema version that is not an integer", (policy) => (policy.schemaVersion = "3")], + ]; + for (const [name, mutate] of cases) { + const policy = minimalPolicy(); + mutate(policy); + await assert.rejects( + auditWithPolicy(policy), + (error) => error instanceof ConfigurationError, + `${name} must be rejected`, + ); + } +}); + +test("an auto-merge publisher key that is not a whole publisher fails closed", async () => { + const root = await repository({ dependabotYaml: dependabot(CONFORMANT) }); + const review = { tier: "auto", reviewed: "2026-10-08", basis: "test" }; + const policy = { + schemaVersion: 2, + scheduleInterval: "weekly", + cooldownMinimumDays: 3, + ...updateClasses(), + maxOpenPullRequests: 5, + requireGroups: true, + autoMerge: { + ecosystems: ["github-actions"], + publishers: { "actions/checkout": review }, + reviewOnly: [], + excludeSemverMajor: true, + requiredCheck: "ci-status", + }, + }; + const policyPath = path.join(root, "policy.json"); + await writeFile(policyPath, JSON.stringify(policy)); + await assert.rejects( + auditRepository({ root, policyPath }), + (error) => error instanceof ConfigurationError && error.message.includes("publishers"), + ); + policy.autoMerge.publishers = { "actions/*": { ...review, tier: "unknown" } }; + await writeFile(policyPath, JSON.stringify(policy)); + await assert.rejects( + auditRepository({ root, policyPath }), + (error) => error instanceof ConfigurationError && error.message.includes("publishers"), + ); + policy.autoMerge.publishers = { "actions/*": review }; + await writeFile(policyPath, JSON.stringify(policy)); + assert.deepEqual(await auditRepository({ root, policyPath }), []); +}); + +test("a review-only dependency entry needs its scope and the control it cites", async () => { + const root = await repository({ dependabotYaml: dependabot(CONFORMANT) }); + const entry = { + repository: "melodic-software/standards", + ecosystem: "npm", + directory: "/distribution", + dependency: "yaml", + reviewed: "2026-10-16", + basis: "test", + control: "distribution/THREAT-MODEL.md#npm-supply-chain-production-engine", + }; + const policy = { + schemaVersion: 2, + scheduleInterval: "weekly", + cooldownMinimumDays: 3, + ...updateClasses(), + maxOpenPullRequests: 5, + requireGroups: true, + autoMerge: { + ecosystems: ["github-actions"], + publishers: { "actions/*": { tier: "auto", reviewed: "2026-10-08", basis: "test" } }, + reviewOnly: [entry], + excludeSemverMajor: true, + requiredCheck: "ci-status", + }, + }; + const policyPath = path.join(root, "policy.json"); + await writeFile(policyPath, JSON.stringify(policy)); + assert.deepEqual(await auditRepository({ root, policyPath }), []); + const { control: _control, ...withoutControl } = entry; + for (const reviewOnly of [ + [withoutControl], + [{ ...entry, directory: "distribution" }], + undefined, + ]) { + policy.autoMerge.reviewOnly = reviewOnly; + await writeFile(policyPath, JSON.stringify(policy)); + await assert.rejects( + auditRepository({ root, policyPath }), + (error) => error instanceof ConfigurationError, + ); + } +}); + +const GITHUB_ACTIONS_ENTRY = ` - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 3 + semver-major-days: 7 + exclude: + - "melodic-software/*" + - "anthropics/*" + - "anthropics/*" + groups: + github-actions: + patterns: + - "*" +`; + +test("cooldown classes and fast-track excludes that match the policy pass", async () => { + const root = await repository({ + dependabotYaml: dependabot(`${CONFORMANT}${GITHUB_ACTIONS_ENTRY}`), + }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("an ecosystem with no fast track passes with no exclude", async () => { + const entry = ` - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + cooldown: + default-days: 3 + semver-major-days: 7 + groups: + nuget: + patterns: + - "*" +`; + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a later schema version, a new fast-track ecosystem and a new top-level key still audit", async () => { + const policy = minimalPolicy(); + policy.schemaVersion = 3; + policy.fastTrack.pip = ["some-package"]; + policy.futureSetting = { enabled: true }; + assert.deepEqual(await auditWithPolicy(policy), []); +}); + +test("a major cooldown above the major class, or none at all, passes", async () => { + for (const entry of [ + CONFORMANT.replace("semver-major-days: 7", "semver-major-days: 14"), + CONFORMANT.replace(" semver-major-days: 7\n", ""), + ]) { + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); + } +}); + +test("a major cooldown below the major class is flagged", async () => { + const root = await repository({ + dependabotYaml: dependabot(CONFORMANT.replace("semver-major-days: 7", "semver-major-days: 5")), + }); + assert.deepEqual(await auditRepository({ root }), [ + { + rule: "cooldown-major-below-major-class", + file: ".github/dependabot.yml", + entry: "npm:/", + message: "cooldown.semver-major-days must be >= 7 (the major class), found 5", + }, + ]); +}); + +test("an exclude that is a subset of the ecosystem's fast track passes", async () => { + for (const entry of [ + CONFORMANT.replace(/ exclude:\n - "@anthropic-ai\/claude-code"\n/u, ""), + GITHUB_ACTIONS_ENTRY.replace(' - "melodic-software/*"\n', ""), + ]) { + const root = await repository({ dependabotYaml: dependabot(entry) }); + assert.deepEqual(await auditRepository({ root }), []); + } +}); + +test("an exclude naming anything outside the ecosystem's fast track is flagged", async () => { + const npm = await repository({ + dependabotYaml: dependabot( + CONFORMANT.replace( + ' - "@anthropic-ai/claude-code"\n', + ' - "@anthropic-ai/claude-code"\n - "melodic-software/*"\n', + ), + ), + }); + assert.deepEqual(await auditRepository({ root: npm }), [ + { + rule: "cooldown-exclude-outside-fast-track", + file: ".github/dependabot.yml", + entry: "npm:/", + message: + 'cooldown.exclude may list only the npm fast track ["@anthropic-ai/claude-code"]; outside it: ["melodic-software/*"]', + }, + ]); + const actions = await repository({ + dependabotYaml: dependabot(GITHUB_ACTIONS_ENTRY.replace('"anthropics/*"', '"actions/*"')), + }); + assert.deepEqual(await auditRepository({ root: actions }), [ + { + rule: "cooldown-exclude-outside-fast-track", + file: ".github/dependabot.yml", + entry: "github-actions:/", + message: + 'cooldown.exclude may list only the github-actions fast track ["anthropics/*","melodic-software/*"]; outside it: ["actions/*"]', + }, + ]); +}); + +test("a cooldown waiver suppresses the cooldown class and fast-track rules", async () => { + const entry = withCooldown(' default-days: 3\n exclude:\n - "left-pad"\n'); + const root = await repository({ + dependabotYaml: dependabot(entry), + config: { + schemaVersion: 1, + exceptions: { + "npm:/": { + reason: "tracks-upstream-release", + justification: "This root tracks the latest upstream release deliberately.", + waives: ["cooldown"], + }, + }, + }, + }); + assert.deepEqual(await auditRepository({ root }), []); +}); + +test("a missing synced policy is a configuration error naming the path", async () => { + const root = await repository({ dependabotYaml: dependabot(CONFORMANT), syncedPolicy: false }); + await assert.rejects( + auditRepository({ root }), + (error) => + error instanceof ConfigurationError && + error.message.includes(path.join(root, SYNCED_POLICY)) && + error.message.includes("standards dependabot-policy sync component"), + ); +}); diff --git a/.github/actions/check-dependabot-policy/dependabot-policy.schema.json b/.github/actions/check-dependabot-policy/dependabot-policy.schema.json new file mode 100644 index 00000000..5b7e2942 --- /dev/null +++ b/.github/actions/check-dependabot-policy/dependabot-policy.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://standards.melodic.software/dependabot-policy/dependabot-policy.schema.json", + "title": "Repository Dependabot policy", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "exceptions"], + "properties": { + "schemaVersion": { "const": 1 }, + "exceptions": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/entryKey" }, + "additionalProperties": { "$ref": "#/$defs/exception" } + } + }, + "$defs": { + "entryKey": { + "type": "string", + "pattern": "^[a-z-]+:\\S" + }, + "nonWhitespaceString": { + "type": "string", + "pattern": "\\S" + }, + "exception": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "justification", "waives"], + "properties": { + "reason": { "enum": ["tracks-upstream-release", "single-tool-ecosystem"] }, + "justification": { "$ref": "#/$defs/nonWhitespaceString" }, + "waives": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["schedule", "cooldown", "groups"] } + } + }, + "allOf": [ + { + "if": { + "required": ["reason"], + "properties": { "reason": { "const": "tracks-upstream-release" } } + }, + "then": { + "properties": { + "waives": { "type": "array", "items": { "enum": ["schedule", "cooldown"] } } + } + } + }, + { + "if": { + "required": ["reason"], + "properties": { "reason": { "const": "single-tool-ecosystem" } } + }, + "then": { + "properties": { + "waives": { "type": "array", "items": { "enum": ["groups"] } } + } + } + } + ] + } + } +} diff --git a/.github/actions/check-dependabot-policy/fixtures/policy.json b/.github/actions/check-dependabot-policy/fixtures/policy.json new file mode 100644 index 00000000..78b8b791 --- /dev/null +++ b/.github/actions/check-dependabot-policy/fixtures/policy.json @@ -0,0 +1,107 @@ +{ + "schemaVersion": 2, + "scheduleInterval": "weekly", + "cooldownMinimumDays": 3, + "cooldownDays": { + "own": 0, + "patch": 3, + "minor": 3, + "major": 7 + }, + "fastTrack": { + "reviewed": "2026-11-11", + "basis": "Owner decision 2026-11-11: Claude Code and Anthropic updates land as soon as they publish, and this organization's own releases need no soak from itself.", + "npm": ["@anthropic-ai/claude-code"], + "github-actions": ["anthropics/*"], + "own": ["melodic-software/*"] + }, + "maxOpenPullRequests": 5, + "requireGroups": true, + "autoMerge": { + "ecosystems": ["github-actions", "npm", "pip", "uv", "nuget"], + "publishers": { + "actions/*": { + "tier": "auto", + "reviewed": "2026-10-08", + "basis": "GitHub publishes the Actions platform every repository already runs on, so a non-major release adds no new trust." + }, + "github/*": { + "tier": "auto", + "reviewed": "2026-10-08", + "basis": "GitHub owns the platform these actions run on; its codeql-action publishes immutable releases." + }, + "anthropics/*": { + "tier": "auto", + "reviewed": "2026-10-08", + "basis": "Anthropic publishes the review agents every repository trusts; claude-code-action also pulls oven-sh/setup-bun (SHA-pinned) and installs bun packages and the Claude Code CLI at runtime, so this tier trusts those transitively." + } + }, + "reviewOnly": [ + { + "repository": "melodic-software/standards", + "ecosystem": "npm", + "directory": "/distribution", + "dependency": "yaml", + "reviewed": "2026-10-16", + "basis": "The sync engine parses every manifest with yaml inside the credentialed sync job, so a parser change reaches every consumer.", + "control": "distribution/THREAT-MODEL.md#npm-supply-chain-production-engine" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/", + "dependency": "@playwright/cli", + "reviewed": "2026-10-17", + "basis": ".github/workflows/eval-browser-dispatch.yml installs the root lockfile in the job that holds the lane's OAuth token, runs the playwright it pulls in and playwright-cli, and makes Bash(playwright-cli *) the eval's default tool, so it runs beside the claude process holding that token.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/.github/actions/resolve-config", + "dependency": "ajv", + "reviewed": "2026-10-18", + "basis": ".github/actions/resolve-config/action.yml:159 installs this lockfile and :160 runs resolve-config.mjs, whose resolve.mjs:7 loads ajv to validate the lane rules inside credentialed CI lanes; in pr-merge.yml:210-215 its output sets the permissions the lane token is minted with at :261, so a hijacked release can widen that token.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/.github/actions/resolve-config", + "dependency": "yaml", + "reviewed": "2026-10-18", + "basis": ".github/actions/resolve-config/action.yml:159 installs this lockfile and :160 runs resolve-config.mjs, whose resolve.mjs:8 loads yaml to parse the lane config inside credentialed CI lanes; in pr-merge.yml:210-215 its output sets the permissions the lane token is minted with at :261, so a hijacked release can widen that token.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/plugins/harness-ops/skills/control-claude-code/driver", + "dependency": "@microsoft/tui-test", + "reviewed": "2026-10-24", + "basis": "plugins/harness-ops/skills/control-claude-code/driver/driver.mjs:245 installs this lockfile on the user's machine, :420-422 load the engine and its per-platform native binary (package-lock.json:22-30) into the session holder and start claude in it after :87 clears the API credential variables, so the session runs on the saved claude.ai subscription login and a hijacked release runs beside it.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/plugins/sql-server", + "dependency": "tedious", + "reviewed": "2026-10-31", + "basis": "plugins/sql-server/package-lock.json is the tree Claude Code's automatic dependency install puts on the user's machine when the plugin is cached; src/executor.mjs:71 (claude-code-plugins#11550) loads tedious into the MCP server process and :131 hands it the SQL login, so a hijacked release receives that login.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + }, + { + "repository": "melodic-software/claude-code-plugins", + "ecosystem": "npm", + "directory": "/plugins/sql-server", + "dependency": "@azure/identity", + "reviewed": "2026-10-31", + "basis": "plugins/sql-server/package-lock.json is the tree Claude Code's automatic dependency install puts on the user's machine when the plugin is cached; src/executor.mjs:81 (claude-code-plugins#11550) loads @azure/identity into the MCP server process and :85 builds its DefaultAzureCredential, so a hijacked release runs with the user's Entra tokens.", + "control": "docs/conventions/pr-pipeline/README.md#review-only-dependencies" + } + ], + "excludeSemverMajor": true, + "requiredCheck": "ci-status" + } +} diff --git a/.github/actions/check-dependabot-policy/package-lock.json b/.github/actions/check-dependabot-policy/package-lock.json new file mode 100644 index 00000000..b1d5651d --- /dev/null +++ b/.github/actions/check-dependabot-policy/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "@melodic-software/check-dependabot-policy", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@melodic-software/check-dependabot-policy", + "version": "0.0.0", + "dependencies": { + "ajv": "8.20.0", + "yaml": "2.9.1" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/.github/actions/check-dependabot-policy/package.json b/.github/actions/check-dependabot-policy/package.json new file mode 100644 index 00000000..14541bf7 --- /dev/null +++ b/.github/actions/check-dependabot-policy/package.json @@ -0,0 +1,13 @@ +{ + "name": "@melodic-software/check-dependabot-policy", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "dependencies": { + "ajv": "8.20.0", + "yaml": "2.9.1" + } +} diff --git a/.github/actions/check-dependabot-policy/policy.schema.json b/.github/actions/check-dependabot-policy/policy.schema.json new file mode 100644 index 00000000..960a9391 --- /dev/null +++ b/.github/actions/check-dependabot-policy/policy.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://standards.melodic.software/dependabot-policy/policy.schema.json", + "title": "Dependabot policy", + "type": "object", + "required": [ + "schemaVersion", + "scheduleInterval", + "cooldownMinimumDays", + "cooldownDays", + "fastTrack", + "maxOpenPullRequests", + "requireGroups", + "autoMerge" + ], + "$defs": { + "publisherGlobs": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*/\\*$" } + } + }, + "properties": { + "schemaVersion": { "type": "integer", "minimum": 2 }, + "scheduleInterval": { "enum": ["daily", "weekly", "monthly"] }, + "cooldownMinimumDays": { "type": "integer", "minimum": 1 }, + "cooldownDays": { + "type": "object", + "additionalProperties": false, + "required": ["own", "patch", "minor", "major"], + "properties": { + "own": { "type": "integer", "minimum": 0 }, + "patch": { "type": "integer", "minimum": 1 }, + "minor": { "type": "integer", "minimum": 1 }, + "major": { "type": "integer", "minimum": 1 } + } + }, + "fastTrack": { + "type": "object", + "required": ["reviewed", "basis", "npm", "github-actions", "own"], + "properties": { + "reviewed": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "basis": { "type": "string", "minLength": 1 }, + "npm": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$" + } + }, + "github-actions": { "$ref": "#/$defs/publisherGlobs" }, + "own": { "$ref": "#/$defs/publisherGlobs" } + } + }, + "maxOpenPullRequests": { "type": "integer", "minimum": 1 }, + "requireGroups": { "type": "boolean" }, + "autoMerge": { + "type": "object", + "required": ["ecosystems", "publishers", "reviewOnly", "excludeSemverMajor", "requiredCheck"], + "properties": { + "ecosystems": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "publishers": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*/\\*$" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["tier", "reviewed", "basis"], + "properties": { + "tier": { "enum": ["auto", "review"] }, + "reviewed": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "basis": { "type": "string", "minLength": 1 } + } + } + }, + "reviewOnly": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "ecosystem", + "directory", + "dependency", + "reviewed", + "basis", + "control" + ], + "properties": { + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$" + }, + "ecosystem": { "type": "string", "minLength": 1 }, + "directory": { "type": "string", "pattern": "^/" }, + "dependency": { "type": "string", "minLength": 1 }, + "reviewed": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "basis": { "type": "string", "minLength": 1 }, + "control": { "type": "string", "minLength": 1 } + } + } + }, + "excludeSemverMajor": { "type": "boolean" }, + "requiredCheck": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5424c6a3..0a4866b4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,9 @@ updates: # into .github/actions// from "/" alone). # # Dependabot only updates `uses:` action SHAs. The tool versions pinned inside - # the actions' run scripts and input defaults have no ecosystem manifest here - # and are tracked manually at the weekly review. + # the actions' run scripts and input defaults have no ecosystem manifest here; + # .github/version-bump/policy.yaml lists them and + # maintenance-bump-pinned-versions.yml moves them. - package-ecosystem: github-actions directories: - / @@ -40,3 +41,26 @@ updates: github-actions: patterns: - '*' + + # The check-dependabot-policy action's runtime (ajv, yaml), installed from its + # own lockfile in each caller's job. Cooldown per the standards + # dependabot-policy change classes; majors fall through to their own group. + - package-ecosystem: npm + directory: /.github/actions/check-dependabot-policy + labels: ["dependencies"] + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: build + cooldown: + default-days: 3 + semver-major-days: 7 + groups: + check-dependabot-policy: + patterns: + - '*' + update-types: [minor, patch] + check-dependabot-policy-major: + patterns: + - '*' diff --git a/.github/scripts/node-version-agreement.test.cjs b/.github/scripts/node-version-agreement.test.cjs index 26376fc3..14a115ba 100644 --- a/.github/scripts/node-version-agreement.test.cjs +++ b/.github/scripts/node-version-agreement.test.cjs @@ -16,7 +16,7 @@ test("Node action defaults match the repository .node-version", () => { const nodeVersion = read(".node-version").trim(); assert.match(nodeVersion, /^\d+\.\d+\.\d+$/u); - for (const name of ["markdownlint", "biome"]) { + for (const name of ["markdownlint", "biome", "check-dependabot-policy"]) { const action = parseWorkflow( read(path.join(".github", "actions", name, "action.yml")), ); diff --git a/.github/scripts/pr-automerge-dependabot-queue.test.cjs b/.github/scripts/pr-automerge-dependabot-queue.test.cjs index 6d058ece..24065c7e 100644 --- a/.github/scripts/pr-automerge-dependabot-queue.test.cjs +++ b/.github/scripts/pr-automerge-dependabot-queue.test.cjs @@ -2,13 +2,16 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); const { parseWorkflow } = require("./workflow-yaml.cjs"); const actionDir = path.join(__dirname, "..", "actions", "automerge-dependabot"); -const { parseCommitMetadata } = require(path.join(actionDir, "gate.cjs")); +const { POLICY_PATH, parseCommitMetadata } = require( + path.join(actionDir, "gate.cjs"), +); const { runQueue } = require(path.join(actionDir, "queue.cjs")); const workflowsDir = path.join(__dirname, "..", "workflows"); const queueWorkflow = parseWorkflow( @@ -199,7 +202,26 @@ async function runWithFakes({ inQueue = {}, appErrors = {}, listError = null, + // The synced policy in the checked-out workspace; absent unless given. + policy = undefined, + // A directory at the policy path, so the read fails with something other than ENOENT. + policyIsDirectory = false, + // REVIEW_ONLY_LOCKFILE, and the text written there when given. + reviewOnlyLockfile = undefined, + lockfileText = undefined, } = {}) { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "queue-workspace-")); + const policyFile = path.join(workspace, POLICY_PATH); + if (policyIsDirectory) fs.mkdirSync(policyFile, { recursive: true }); + if (policy !== undefined) { + fs.mkdirSync(path.dirname(policyFile), { recursive: true }); + fs.writeFileSync(policyFile, policy); + } + if (lockfileText !== undefined) { + const lockfile = path.join(workspace, reviewOnlyLockfile); + fs.mkdirSync(path.dirname(lockfile), { recursive: true }); + fs.writeFileSync(lockfile, lockfileText); + } const reads = []; const appCalls = []; const notices = []; @@ -258,14 +280,88 @@ async function runWithFakes({ core, env: { APP_SLUG: "melodic-release", + GITHUB_WORKSPACE: workspace, PUBLISHER_ALLOWLIST: '["actions/*","github/*","anthropics/*"]', PACKAGE_AUTO_MERGE: "true", REVIEW_ONLY_DEPENDENCIES: "[]", + REVIEW_ONLY_LOCKFILE: reviewOnlyLockfile, }, }); + fs.rmSync(workspace, { recursive: true, force: true }); return { counts, reads, appCalls, notices, warnings }; } +test("the workspace's synced policy sets the publishers the queue arms", async () => { + const { counts, notices } = await runWithFakes({ + policy: JSON.stringify({ + autoMerge: { + publishers: { "github/*": { tier: "auto" } }, + ecosystems: ["github-actions"], + reviewOnly: [], + }, + }), + }); + assert.deepEqual(counts, { queued: 0, skipped: 1, failed: 0 }); + assert.match( + notices[0], + /actions\/setup-dotnet is not an allowlisted publisher/u, + ); +}); + +test("an unreadable synced policy leaves every pull request alone", async () => { + const { counts, appCalls, notices } = await runWithFakes({ + pulls: [dependabotPull(7), dependabotPull(8)], + policy: "{", + }); + assert.deepEqual(counts, { queued: 0, skipped: 2, failed: 0 }); + assert.deepEqual(appCalls, []); + assert.match( + notices[0], + /#7 left alone: .*is not a readable dependabot policy/u, + ); +}); + +test("an armed pull request is disarmed when the synced policy is malformed", async () => { + const { counts, appCalls, notices } = await runWithFakes({ + pulls: [ + dependabotPull(7, { + auto_merge: { enabled_by: { login: "melodic-release[bot]" } }, + }), + ], + policy: "{", + }); + assert.deepEqual(counts, { queued: 0, skipped: 1, failed: 0 }); + assert.deepEqual( + appCalls.map((call) => [call.mutation, call.variables.id]), + [["disablePullRequestAutoMerge", "PR_7"]], + ); + assert.match( + notices[0], + /#7 left alone: .*is not a readable dependabot policy/u, + ); +}); + +test("a synced policy path that cannot be read leaves pull requests alone and disarms armed ones", async () => { + const { counts, appCalls, notices } = await runWithFakes({ + pulls: [ + dependabotPull(7, { + auto_merge: { enabled_by: { login: "melodic-release[bot]" } }, + }), + dependabotPull(8), + ], + policyIsDirectory: true, + }); + assert.deepEqual(counts, { queued: 0, skipped: 2, failed: 0 }); + assert.deepEqual( + appCalls.map((call) => [call.mutation, call.variables.id]), + [["disablePullRequestAutoMerge", "PR_7"]], + ); + assert.match( + notices[0], + /#7 left alone: .*policy\.json could not be read: EISDIR/u, + ); +}); + test("an in-tier group with a green ci-status is queued at its head with the App token", async () => { const { counts, appCalls, warnings } = await runWithFakes(); assert.deepEqual(counts, { queued: 1, skipped: 0, failed: 0 }); @@ -593,10 +689,85 @@ test("the queue's tier inputs are the reusable's defaults", () => { env.PACKAGE_AUTO_MERGE, String(inputs["package-auto-merge"].default), ); - assert.equal( - env.REVIEW_ONLY_DEPENDENCIES, - inputs["review-only-dependencies"].default, - ); +}); + +// A Dependabot npm patch update to one package, as the scheduled path reads it. +const npmPull = (name, number = 7) => { + const title = `build: bump ${name} from 3.1.9 to 3.1.10 in /.github/actions/check-dependabot-policy`; + const message = `${title} + +Bumps [${name}](https://example.com) from 3.1.9 to 3.1.10. + +--- +updated-dependencies: +- dependency-name: ${name} + dependency-version: 3.1.10 + dependency-type: indirect + update-type: version-update:semver-patch +... + +Signed-off-by: dependabot[bot] `; + return { + pull: dependabotPull(number, { + title, + body: `Bumps [${name}](https://example.com) from 3.1.9 to 3.1.10.`, + head: { sha: HEAD, ref: `dependabot/npm_and_yarn/${name}-3.1.10` }, + }), + commits: { [number]: [dependabotCommit(message)] }, + }; +}; + +test("the queue holds a transitive package of the policy checker's lockfile for review", async () => { + const lockfile = step("Queue in-tier Dependabot pull requests").env + .REVIEW_ONLY_LOCKFILE; + const { pull, commits } = npmPull("fast-uri"); + const held = await runWithFakes({ + pulls: [pull], + commits, + reviewOnlyLockfile: lockfile, + lockfileText: fs.readFileSync( + path.join(__dirname, "..", "..", lockfile), + "utf8", + ), + }); + assert.deepEqual(held.counts, { queued: 0, skipped: 1, failed: 0 }); + assert.match(held.notices[0], /fast-uri is a review-only dependency/u); + const unheld = await runWithFakes({ pulls: [pull], commits }); + assert.deepEqual(unheld.counts, { queued: 1, skipped: 0, failed: 0 }); +}); + +test("the lockfile hold covers nested and scoped packages but not dev ones", async () => { + const lockfileText = JSON.stringify({ + packages: { + "": { dependencies: { a: "1.0.0" } }, + "node_modules/a": {}, + "node_modules/a/node_modules/nested": {}, + "node_modules/@scope/pkg": {}, + "node_modules/devonly": { dev: true }, + }, + }); + const outcome = async (name) => { + const { pull, commits } = npmPull(name); + const { counts } = await runWithFakes({ + pulls: [pull], + commits, + reviewOnlyLockfile: "package-lock.json", + lockfileText, + }); + return counts.queued; + }; + assert.equal(await outcome("nested"), 0); + assert.equal(await outcome("@scope/pkg"), 0); + assert.equal(await outcome("devonly"), 1); +}); + +test("a named lockfile that cannot be read leaves every pull request alone", async () => { + const { counts, appCalls, notices } = await runWithFakes({ + reviewOnlyLockfile: "missing/package-lock.json", + }); + assert.deepEqual(counts, { queued: 0, skipped: 1, failed: 0 }); + assert.deepEqual(appCalls, []); + assert.match(notices[0], /is not a readable npm lockfile/u); }); test("both tokens' API calls are logged", () => { diff --git a/.github/scripts/pr-automerge-dependabot.test.cjs b/.github/scripts/pr-automerge-dependabot.test.cjs index f9621a2f..b2ca130f 100644 --- a/.github/scripts/pr-automerge-dependabot.test.cjs +++ b/.github/scripts/pr-automerge-dependabot.test.cjs @@ -6,7 +6,9 @@ const path = require("node:path"); const test = require("node:test"); const actionDir = path.join(__dirname, "..", "actions", "automerge-dependabot"); -const { runEvent } = require(path.join(actionDir, "gate.cjs")); +const { DEFAULT_PUBLISHER_ALLOWLIST, runEvent } = require( + path.join(actionDir, "gate.cjs"), +); const workflowPath = path.join( __dirname, "..", @@ -47,6 +49,7 @@ function actionEnv(name) { const DEPENDABOT_ID = 49699333; const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); // The shape of a real Dependabot commit, read live from // `gh api repos/melodic-software/medley/pulls/2094/commits` and @@ -101,8 +104,12 @@ async function runGate({ /^'(.*)'$/u, "$1", ), + // The synced policy at the base: absent (404) unless a text or an error is given. + basePolicy = undefined, + basePolicyError = undefined, } = {}) { const graphqlCalls = []; + const policyReads = []; const notices = []; const env = { UPDATED_DEPENDENCIES_JSON: metadataJson, @@ -114,7 +121,19 @@ async function runGate({ }; const warnings = []; const github = { - rest: { pulls: { listCommits: Symbol("listCommits") } }, + rest: { + pulls: { listCommits: Symbol("listCommits") }, + repos: { + getContent: async (params) => { + policyReads.push(params); + if (basePolicyError) throw basePolicyError; + if (basePolicy === undefined) { + throw Object.assign(new Error("Not Found"), { status: 404 }); + } + return { data: basePolicy }; + }, + }, + }, paginate: async (route, params) => { assert.equal(route, github.rest.pulls.listCommits); assert.equal(params.pull_number, 7); @@ -136,6 +155,7 @@ async function runGate({ title, body, head: { sha: HEAD }, + base: { sha: BASE }, auto_merge: autoMerge, }, }, @@ -152,7 +172,7 @@ async function runGate({ const disarmCalls = graphqlCalls.filter((call) => /disablePullRequestAutoMerge/u.test(call.query), ); - return { armCalls, disarmCalls, notices, warnings }; + return { armCalls, disarmCalls, notices, warnings, policyReads }; } function assertSkipped(result, reason) { @@ -213,6 +233,212 @@ for (const malformed of [ }); } +// The publisher and fast-track shape of standards +// components/dependabot-policy/policy.json schemaVersion 2 (standards#987), +// plus one review-tier publisher the real file does not list. +const syncedPolicy = ({ fastTrack, ecosystems, reviewOnly } = {}) => + JSON.stringify({ + schemaVersion: 2, + fastTrack: fastTrack ?? { + reviewed: "2026-11-11", + basis: "owner decision", + npm: ["@anthropic-ai/claude-code"], + "github-actions": ["anthropics/*"], + own: ["melodic-software/*"], + }, + autoMerge: { + publishers: { + "actions/*": { tier: "auto" }, + "github/*": { tier: "auto" }, + "anthropics/*": { tier: "auto" }, + "example/*": { tier: "review" }, + }, + ecosystems: ecosystems ?? ["github-actions", "npm", "pip", "uv", "nuget"], + reviewOnly: reviewOnly ?? [], + }, + }); + +test("the gate's default allowlist is the reusable's publisher-allowlist default", () => { + assert.deepEqual( + DEFAULT_PUBLISHER_ALLOWLIST, + JSON.parse(inputDefault("publisher-allowlist").replace(/^'(.*)'$/u, "$1")), + ); +}); + +test("the synced policy is read at the base commit, never the head", async () => { + const { policyReads } = await runGate(); + assert.deepEqual(policyReads, [ + { + owner: "melodic-software", + repo: "medley", + path: ".github/standards/dependabot-policy/policy.json", + ref: BASE, + mediaType: { format: "raw" }, + }, + ]); +}); + +test("with no synced policy, an own action stays outside the default allowlist", async () => { + assertSkipped( + await runGate({ + updates: [ + update("melodic-software/ci-workflows", "version-update:semver-patch"), + ], + }), + /melodic-software\/ci-workflows is not an allowlisted publisher/u, + ); +}); + +test("with the synced policy, an own action's patch bump arms on the fast track", async () => { + const { armCalls, notices } = await runGate({ + updates: [ + update("melodic-software/ci-workflows", "version-update:semver-patch"), + ], + basePolicy: syncedPolicy(), + }); + assert.deepEqual(notices, []); + assert.equal(armCalls.length, 1); +}); + +test("with the synced policy, a fast-tracked major still goes to a person", async () => { + assertSkipped( + await runGate({ + updates: [ + update( + "melodic-software/ci-workflows", + "version-update:semver-major", + "github_actions", + { + newVersion: "2.0.0", + }, + ), + ], + basePolicy: syncedPolicy(), + }), + /melodic-software\/ci-workflows is not a patch or minor update/u, + ); +}); + +test("with the synced policy, a review-tier publisher is not armed", async () => { + assertSkipped( + await runGate({ + updates: [update("example/action", "version-update:semver-patch")], + basePolicy: syncedPolicy(), + }), + /example\/action is not an allowlisted publisher/u, + ); +}); + +test("a synced policy with no fast track arms only its auto-tier publishers", async () => { + assertSkipped( + await runGate({ + updates: [ + update("melodic-software/ci-workflows", "version-update:semver-patch"), + ], + basePolicy: syncedPolicy({ fastTrack: {} }), + }), + /melodic-software\/ci-workflows is not an allowlisted publisher/u, + ); +}); + +test("a caller's narrowed allowlist wins over the synced policy", async () => { + for (const name of [ + "melodic-software/ci-workflows", + "anthropics/claude-code-action", + ]) { + assertSkipped( + await runGate({ + updates: [update(name, "version-update:semver-patch")], + publisherAllowlist: '["actions/*","github/*"]', + basePolicy: syncedPolicy(), + }), + new RegExp( + `${name.replace("/", "\\/")} is not an allowlisted publisher`, + "u", + ), + ); + } +}); + +for (const [label, basePolicy] of [ + ["not JSON", "{"], + ["without autoMerge.publishers", JSON.stringify({ fastTrack: {} })], + [ + "without autoMerge.ecosystems", + JSON.stringify({ autoMerge: { publishers: {}, reviewOnly: [] } }), + ], + [ + "with a non-list fast track", + syncedPolicy({ fastTrack: { own: "melodic-software/*" } }), + ], +]) { + test(`a synced policy ${label} arms nothing and disarms an earlier arm`, async () => { + const result = await runGate({ basePolicy, autoMerge: { enabled_by: {} } }); + assert.equal(result.armCalls.length, 0); + assert.match(result.notices[0], /is not a readable dependabot policy/u); + assert.equal(result.disarmCalls.length, 1); + }); +} + +test("a synced policy the API cannot return arms nothing", async () => { + assertSkipped( + await runGate({ + basePolicyError: Object.assign(new Error("Server Error"), { + status: 500, + }), + }), + /could not be read at the base: Server Error/u, + ); +}); + +test("the synced policy's autoMerge.ecosystems leaves other ecosystems for a person", async () => { + assertSkipped( + await runGate({ + updates: [ + update("left-pad", "version-update:semver-patch", "npm_and_yarn"), + ], + basePolicy: syncedPolicy({ ecosystems: ["github-actions"] }), + }), + /left-pad is a npm_and_yarn update, which the policy's autoMerge.ecosystems leaves out/u, + ); + const { armCalls } = await runGate({ + updates: [ + update("left-pad", "version-update:semver-patch", "npm_and_yarn"), + ], + basePolicy: syncedPolicy({ ecosystems: ["npm"] }), + }); + assert.equal(armCalls.length, 1); +}); + +test("the synced policy's reviewOnly entries for this repository are never armed", async () => { + const entry = (repository) => ({ + repository, + ecosystem: "npm", + directory: "/", + dependency: "tedious", + }); + assertSkipped( + await runGate({ + updates: [ + update("tedious", "version-update:semver-patch", "npm_and_yarn"), + ], + basePolicy: syncedPolicy({ + reviewOnly: [entry("melodic-software/medley")], + }), + }), + /tedious is a review-only dependency/u, + ); + const { armCalls } = await runGate({ + updates: [update("tedious", "version-update:semver-patch", "npm_and_yarn")], + basePolicy: syncedPolicy({ reviewOnly: [entry("melodic-software/other")] }), + }); + assert.equal( + armCalls.length, + 1, + "another repository's entry does not apply here", + ); +}); + test("the job runs only on pull_request events authored by Dependabot's account id", () => { const job = workflowLines.indexOf(" enable-auto-merge:"); const ifLine = workflowLines diff --git a/.github/version-bump/policy.yaml b/.github/version-bump/policy.yaml new file mode 100644 index 00000000..18546c62 --- /dev/null +++ b/.github/version-bump/policy.yaml @@ -0,0 +1,249 @@ +# Version-bump policy for the tools this repository's actions and reusable workflows pin outside +# Dependabot: how each is resolved, verified and rewritten. Read by the bump-versions action, run +# daily by .github/workflows/maintenance-bump-pinned-versions.yml. Field meanings: the +# bump-versions entry in README.md. +# +# Cooldowns come from the standards dependabot-policy (cooldownDays per change class), read from +# the copy the standards sync places at .github/standards/dependabot-policy/policy.json. This file +# sets none of its own. +# +# Every tool is capped at its current major (line: major, or minor for Python): a bump reaches +# every caller of this repository's reusable workflows at @main as soon as it merges, so a major +# is a person's change. Below 1.0 the line also keeps the minor number. The run reports the +# newest release outside the line. + +# The npm registry's signing key, from https://registry.npmjs.org/-/npm/v1/keys (the key with no +# expiry). +npmRegistryKeys: + "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U": MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g== + +# Bullets added to each bump pull request's Verification section. +prNotes: + - Merging reaches every caller of the reusable workflows at @main, and each caller of an action on its next pin move. + +# Pins the bot does not move, each with its reason. Reported in every run's measurement. +exceptions: + - tool: actionlint + reason: The kjanat/actionlint fork's version numbers overlap upstream's, so its pins carry attestation-source-digest, the release tag's commit, which pr-require-checks verifies with the asset's build attestation. The engine has no source for a tag's commit, so version, sha256 and the attestation inputs move together by hand. + - tool: markdownlint-cli2 + reason: The markdownlint action follows melodic-software/standards' markdownlint-cli2 pin and never runs ahead of it (.github/actions/markdownlint/action.yml). + - tool: node + reason: .node-version is a standards node-runtime managed file, and the biome and markdownlint node-version defaults must equal it (.github/scripts/node-version-agreement.test.cjs); standards moves it. + - tool: zizmor + reason: pr-audit-workflows.yml keeps zizmor in step with the fleet pin in melodic-software/dotfiles (.chezmoidata/uv-tools.yaml), so the two move together by hand. + - tool: govulncheck + reason: pr-run-checks-go.yml pins the module's go.sum hash (GOVULNCHECK_MODULE_SUM), which the engine has no digest source for. + - tool: osv-scanner + reason: .github/osv-scanner-pin.json and pr-scan-dependencies.yml pin the release's source commit and its SLSA provenance digest with the slsa-verifier that checks it, and the pin file sets automaticMerge false. + - tool: osslsigncode + reason: The bump-versions installer takes the Ubuntu 24.04 archive package, which changes only with the runner image's release; there is no upstream release to track. + +tools: + # Neighbouring pins in different files that pr-require-checks keeps equal + # (actionlint-shellcheck-pin.test.cjs), so one pull request. + shellcheck: + class: third-party + group: shellcheck + line: major + file: .github/actions/shellcheck/action.yml + version: .inputs.version.default + source: {type: github-release, repo: koalaman/shellcheck, tagPrefix: v} + verify: + unverified: koalaman/shellcheck publishes no checksum or signature; the digest is of the archive GitHub serves after the cooldown. + fields: + - path: .inputs.sha256.default + digest: {assetFile: 'shellcheck-v{version}.linux.x86_64.tar.gz'} + + actionlint-shellcheck: + class: third-party + group: shellcheck + line: major + file: .github/actions/actionlint/action.yml + version: .inputs["shellcheck-version"].default + source: {type: github-release, repo: koalaman/shellcheck, tagPrefix: v} + verify: + unverified: koalaman/shellcheck publishes no checksum or signature; the digest is of the archive GitHub serves after the cooldown. + fields: + - path: .inputs["shellcheck-sha256"].default + digest: {assetFile: 'shellcheck-v{version}.linux.x86_64.tar.gz'} + + editorconfig-checker: + class: third-party + line: major + file: .github/actions/editorconfig-checker/action.yml + version: .inputs.version.default + source: {type: github-release, repo: editorconfig-checker/editorconfig-checker, tagPrefix: v} + verify: + checksums: {asset: checksums.txt, algo: sha256} + fields: + - path: .inputs.sha256.default + digest: {asset: editorconfig-checker-linux-amd64.tar.gz} + + gitleaks: + class: third-party + line: major + file: .github/actions/gitleaks/action.yml + version: .inputs.version.default + source: {type: github-release, repo: gitleaks/gitleaks, tagPrefix: v} + verify: + checksums: {asset: 'gitleaks_{version}_checksums.txt', algo: sha256} + fields: + - path: .inputs.sha256.default + digest: {asset: 'gitleaks_{version}_linux_x64.tar.gz'} + + # The offline lane installs the binary; the online reusable passes the same release to + # lychee-action, so one pull request. + lychee-offline: + class: third-party + group: lychee + line: major + file: .github/actions/lychee-offline/action.yml + version: .inputs.version.default + source: {type: github-release, repo: lycheeverse/lychee, tagPrefix: lychee-v} + verify: + note: sha256 from the .sha256 file the release publishes beside the archive + fields: + - path: .inputs.sha256.default + digest: {url: 'https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-x86_64-unknown-linux-gnu.tar.gz.sha256'} + + lychee-online: + class: third-party + group: lychee + line: major + file: .github/workflows/maintenance-check-links-online.yml + version: .on.workflow_call.inputs["lychee-version"].default + format: 'v{version}' + source: {type: github-release, repo: lycheeverse/lychee, tagPrefix: lychee-v} + verify: + note: lychee-action installs this release; the offline lane's pin in the same pull request carries its sha256 + + shfmt: + class: third-party + line: major + file: .github/actions/shfmt/action.yml + version: .inputs.version.default + source: {type: github-release, repo: mvdan/sh, tagPrefix: v} + verify: + unverified: mvdan/sh publishes no checksum file; the digest is of the binary GitHub serves after the cooldown. + fields: + - path: .inputs.sha256.default + digest: {assetFile: 'shfmt_v{version}_linux_amd64'} + + typos: + class: third-party + line: major + file: .github/actions/typos/action.yml + version: .inputs.version.default + source: {type: github-release, repo: crate-ci/typos, tagPrefix: v} + verify: + unverified: crate-ci/typos publishes no checksum file; the digest is of the archive GitHub serves after the cooldown. + fields: + - path: .inputs.sha256.default + digest: {assetFile: 'typos-v{version}-x86_64-unknown-linux-musl.tar.gz'} + + # The Linux and Windows jobs install the same release, so one pull request. + golangci-lint-linux: + class: third-party + group: golangci-lint + line: major + file: .github/workflows/pr-run-checks-go.yml + version: .jobs["check-linux"].env.GOLANGCI_LINT_VERSION + source: {type: github-release, repo: golangci/golangci-lint, tagPrefix: v} + verify: + checksums: {asset: 'golangci-lint-{version}-checksums.txt', algo: sha256} + fields: + - path: .jobs["check-linux"].env.GOLANGCI_LINT_SHA256 + digest: {asset: 'golangci-lint-{version}-linux-amd64.tar.gz'} + + golangci-lint-windows: + class: third-party + group: golangci-lint + line: major + file: .github/workflows/pr-run-checks-go.yml + version: .jobs["check-windows"].env.GOLANGCI_LINT_VERSION + source: {type: github-release, repo: golangci/golangci-lint, tagPrefix: v} + verify: + checksums: {asset: 'golangci-lint-{version}-checksums.txt', algo: sha256} + fields: + - path: .jobs["check-windows"].env.GOLANGCI_LINT_SHA256 + digest: {asset: 'golangci-lint-{version}-windows-amd64.zip'} + + yq: + class: third-party + line: major + file: .github/actions/bump-versions/install-tools.sh + version: {regex: '^yq_version=v([0-9.]+)$'} + source: {type: github-release, repo: mikefarah/yq, tagPrefix: v} + verify: + unverified: mikefarah/yq's checksums file lists every algorithm in columns the engine does not parse; the digest is of the binary GitHub serves after the cooldown. + fields: + - path: {regex: '^yq_sha256=([0-9a-f]{64})$'} + digest: {assetFile: yq_linux_amd64} + + biome: + class: third-party + line: major + file: .github/actions/biome/action.yml + version: .inputs["fallback-version"].default + source: {type: npm, package: '@biomejs/biome'} + + pyright: + class: third-party + line: major + file: .github/actions/pyright/action.yml + version: .inputs.version.default + source: {type: pypi, package: pyright} + + check-jsonschema: + class: third-party + line: major + file: .github/actions/check-jsonschema/action.yml + version: .inputs.version.default + source: {type: pypi, package: check-jsonschema} + + ruff: + class: third-party + line: major + file: .github/actions/ruff/action.yml + version: .inputs.version.default + source: {type: pypi, package: ruff} + + psscriptanalyzer: + class: third-party + line: major + file: .github/actions/psscriptanalyzer/action.yml + version: .inputs["analyzer-version"].default + source: {type: psgallery, package: PSScriptAnalyzer} + + # Every action that sets up Python takes the same patch release, so one pull request. + python-bump-versions: + class: third-party + group: python + line: minor + file: .github/actions/bump-versions/action.yml + version: .inputs["python-version"].default + source: {type: python-org} + + python-check-jsonschema: + class: third-party + group: python + line: minor + file: .github/actions/check-jsonschema/action.yml + version: .inputs["python-version"].default + source: {type: python-org} + + python-pyright: + class: third-party + group: python + line: minor + file: .github/actions/pyright/action.yml + version: .inputs["python-version"].default + source: {type: python-org} + + python-ruff: + class: third-party + group: python + line: minor + file: .github/actions/ruff/action.yml + version: .inputs["python-version"].default + source: {type: python-org} diff --git a/.github/workflows/maintenance-bump-pinned-versions.yml b/.github/workflows/maintenance-bump-pinned-versions.yml new file mode 100644 index 00000000..3dbdff51 --- /dev/null +++ b/.github/workflows/maintenance-bump-pinned-versions.yml @@ -0,0 +1,120 @@ +name: Maintenance Bump Pinned Versions + +# Moves every pin in .github/version-bump/policy.yaml (the tool versions and +# release digests the actions and reusable workflows pin outside Dependabot) to +# its newest release past the cooldown, checked against the publisher's data. +# The cooldown per change class comes from the standards dependabot-policy copy +# at .github/standards/dependabot-policy/policy.json. Each tool, or group of +# tools that must move together, gets its own pull request on +# version-bump/, opened by the melodic-release App and armed for the +# merge queue; ci-status still gates each one. +# +# Until this repository receives that synced copy, the run warns and bumps +# nothing: the policy sets no cooldown of its own, and the engine never falls +# back to a built-in one. +# +# Every run that prepares uploads results.json (per tool: current, latest, +# release date, days behind, cooldown or exception, outcome) and writes the +# same table to the job summary. + +on: + schedule: + - cron: '23 7 * * *' + workflow_dispatch: + inputs: + dry-run: + description: Resolve and verify only; write nothing and open no pull request + type: boolean + default: false + tools: + description: Comma-separated tool or group names from policy.yaml (empty for all) + type: string + default: '' + +permissions: {} + +# Branches are per tool and leased, but one run at a time keeps two runs from +# rebuilding the same branch. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + bump: + name: Bump pinned versions + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: plugin-release + # Secrets only: a daily run would otherwise write a deployment each time. + deployment: false + permissions: + contents: read + pull-requests: read # finds the bot's open pull request for a pin now current + env: + OUT: .work/version-bump + SYNCED_POLICY: .github/standards/dependabot-policy/policy.json + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Bumps are always based on main, whatever ref a dispatch ran from. + ref: main + persist-credentials: false + - name: Find the synced org update policy + id: policy + run: | + if [[ -f "$SYNCED_POLICY" ]]; then + echo "present=true" >>"$GITHUB_OUTPUT" + else + echo "present=false" >>"$GITHUB_OUTPUT" + echo "::warning::$SYNCED_POLICY is absent, so no cooldown is known and nothing was bumped. This repository needs the standards dependabot-policy sync component." + fi + - name: Resolve, verify and prepare the bumps + id: prepare + if: ${{ steps.policy.outputs.present == 'true' }} + uses: $/.github/actions/bump-versions + with: + command: prepare + policy: .github/version-bump/policy.yaml + dependabot-policy: ${{ env.SYNCED_POLICY }} + out: ${{ env.OUT }} + only: ${{ inputs.tools }} + dry-run: ${{ inputs.dry-run }} + # Minted only when there is something to publish, and only after every + # download and verification ran under the read-only job token. + - name: Mint the release App token + id: app-token + if: ${{ steps.policy.outputs.present == 'true' && !inputs.dry-run && fromJSON(steps.prepare.outputs.prepared-count) > 0 }} + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.PLUGIN_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.PLUGIN_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + # A bump rewrites pins inside .github/workflows. + permission-workflows: write + - name: Commit, open the pull requests and arm auto-merge + if: ${{ steps.policy.outputs.present == 'true' && !inputs.dry-run && fromJSON(steps.prepare.outputs.prepared-count) > 0 }} + uses: $/.github/actions/bump-versions + with: + command: publish + out: ${{ env.OUT }} + token: ${{ steps.app-token.outputs.token }} + app-slug: ${{ steps.app-token.outputs.app-slug }} + - name: Write the job summary + if: ${{ !cancelled() && hashFiles('.work/version-bump/results.json') != '' }} + uses: $/.github/actions/bump-versions + with: + command: summary + out: ${{ env.OUT }} + - name: Upload the measurement + if: ${{ !cancelled() && hashFiles('.work/version-bump/results.json') != '' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: version-bump-results + path: .work/version-bump/results.json + retention-days: 30 diff --git a/.github/workflows/pr-automerge-dependabot-queue.yml b/.github/workflows/pr-automerge-dependabot-queue.yml index 8cad897d..084d401c 100644 --- a/.github/workflows/pr-automerge-dependabot-queue.yml +++ b/.github/workflows/pr-automerge-dependabot-queue.yml @@ -80,9 +80,14 @@ jobs: APP_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} # The reusable's input defaults, which this repository's tier uses. + # A synced standards dependabot-policy copy in the checkout, once + # this repository receives one, sets the publishers instead. PUBLISHER_ALLOWLIST: '["actions/*","github/*","anthropics/*"]' PACKAGE_AUTO_MERGE: "true" - REVIEW_ONLY_DEPENDENCIES: "[]" + # Every package the check-dependabot-policy action's lockfile + # installs, transitive ones included: consumers run that checker in + # ci-status, so a bump to any of them waits for review. + REVIEW_ONLY_LOCKFILE: .github/actions/check-dependabot-policy/package-lock.json with: script: | const path = require("node:path"); diff --git a/.github/workflows/pr-automerge-dependabot.yml b/.github/workflows/pr-automerge-dependabot.yml index 3dd2b982..b8ed50a8 100644 --- a/.github/workflows/pr-automerge-dependabot.yml +++ b/.github/workflows/pr-automerge-dependabot.yml @@ -36,10 +36,19 @@ name: PR Automerge Dependabot # - every update is `github_actions`, or, unless the caller passes # `package-auto-merge: false`, `npm_and_yarn`, `pip`, `uv` or `nuget`; any # other ecosystem (docker, dotnet_sdk and the rest) is left for a human; -# - every GitHub Actions dependency name matches a glob in the caller's -# `publisher-allowlist` (default mirrors standards -# `components/dependabot-policy/policy.json` `autoMerge`); an input that is -# not a JSON array of non-empty strings arms nothing; +# - every GitHub Actions dependency name matches a glob in the allowlist. +# When the caller leaves `publisher-allowlist` at its default (which +# mirrors standards `components/dependabot-policy/policy.json` `autoMerge`) +# and the base commit holds the synced copy of that policy at +# `.github/standards/dependabot-policy/policy.json`, the allowlist is the +# copy's `tier: auto` publishers plus its `fastTrack` `github-actions` and +# `own` globs. With no synced copy, or a caller list other than the +# default, the input is the allowlist. A synced copy also narrows, at any +# input: its `autoMerge.ecosystems` bound the ecosystems armed and its +# `autoMerge.reviewOnly` entries for this repository are never armed. +# A copy that cannot be read or +# parsed, or an input that is not a JSON array of non-empty strings, arms +# nothing; # - no package dependency name is in the caller's `review-only-dependencies` # (its entries in that policy's `autoMerge.reviewOnly`); # - every update is a semver patch or minor, and a version below 1.0 keeps @@ -67,7 +76,8 @@ name: PR Automerge Dependabot # TRUST: every PR-derived value (dependency names, versions, the metadata # JSON) is untrusted. Values reach the script through `env:` only, never a # `${{ }}` expansion inside script text. No checkout: nothing from the pull -# request is executed. The gate is the automerge-dependabot composite, reached +# request is executed, and the synced policy is read through the contents API +# at the pull request's base commit, so the pull request cannot change it. The gate is the automerge-dependabot composite, reached # through `$/`, so it runs at the commit of this workflow the caller calls. on: workflow_call: @@ -91,8 +101,9 @@ on: publisher-allowlist: description: >- JSON array of GitHub Actions publisher globs (`*` matches any run of - characters) whose patch and minor bumps are armed. Narrow it to keep - a publisher's bumps for a person. + characters) whose patch and minor bumps are armed. At this default, + a synced standards dependabot-policy copy at the base sets the list + instead. Narrow it to keep a publisher's bumps for a person. type: string default: '["actions/*","github/*","anthropics/*"]' diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 90c5712c..d2ae8e19 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -438,14 +438,14 @@ jobs: - name: Check workflow and action naming uses: $/.github/actions/check-workflow-naming with: - standards-ref: 846528ba34a036ebbe8b22db6afaee79d017b67f + standards-ref: eef7cec74c88a30d287f9e15a28555126e74f9cd - name: Run a misnamed workflow fixture in enforcing mode id: bad-enforcing continue-on-error: true uses: $/.github/actions/check-workflow-naming with: root: fixtures/workflow-naming/bad - standards-ref: 846528ba34a036ebbe8b22db6afaee79d017b67f + standards-ref: eef7cec74c88a30d287f9e15a28555126e74f9cd - name: Prove the misnamed workflow fixture failed env: OUTCOME: ${{ steps.bad-enforcing.outcome }} @@ -564,8 +564,15 @@ jobs: run: bash .github/actions/check-comment-markers/scan-tree.test.sh - name: Test the workflow-naming runner run: bash .github/actions/check-workflow-naming/run.test.sh + - name: Install the version-bump engine's pinned yq + # The engine's own-policy test reads .github/version-bump/policy.yaml with it. + run: bash .github/actions/bump-versions/install-tools.sh - name: Test the version-bump engine run: python3 -B -m unittest discover -s .github/actions/bump-versions -p 'test_*.py' + - name: Test the Dependabot policy check + run: | + npm ci --prefix .github/actions/check-dependabot-policy --ignore-scripts --no-audit --no-fund + node --test .github/actions/check-dependabot-policy/*.test.mjs - name: Test the pull-request contract runner run: bash .github/actions/pr-require-checks/check-contract/run.test.sh - name: Test the ci-status aggregation and carry-forward runner diff --git a/README.md b/README.md index 9359722b..eab2e3c4 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,18 @@ consumer to audit it. while the upstream [runspace-affinity defect][pssa-1708] is open; retrying a crashing rule is not a quality gate. - `.github/actions/bump-versions` — moves a repository's release pins to the newest - release past its cooldown, as declared in a policy file (cooldown days, per-tool - source, verification and pins; a pin in a file yq cannot parse is addressed by a - regex with one capture group that must match one line). `command: prepare` resolves + release past its cooldown, as declared in a policy file (per-tool source, + verification and pins; a pin in a file yq cannot parse is addressed by a regex + with one capture group that must match one line). The cooldown days per change + class (`own`, `patch`, `minor`, `major`) come from the org update policy, the + standards `dependabot-policy` `cooldownDays`, read from the standards-synced + copy the `dependabot-policy` input names (default + `.github/standards/dependabot-policy/policy.json`). A policy file that still + sets `cooldownDays` keeps its own values; with neither, `prepare` fails rather + than assume a cooldown. Below 1.0 a minor move is class `major` (as the + Dependabot gate treats it), and `line: major` then keeps the minor number. A + bump holding any major move opens its pull request without auto-merge, so a + person reviews it. `command: prepare` resolves and verifies under the caller's read-only token and sets `prepared-count`; when that is above zero the caller mints an App token and calls `command: publish`, which commits through the API so GitHub signs it, opens or updates each pull request on @@ -160,6 +169,25 @@ consumer to audit it. Authenticode publisher under a pinned root (`osslsigncode: 'true'`). `afterBump` commands in the policy run from the repository root after the pins are rewritten and read the bump from `VERSION_BUMP_READY`. +- `.github/actions/check-dependabot-policy` — checks a repository's + `.github/dependabot.yml` against the org update policy, the standards-synced + `.github/standards/dependabot-policy/policy.json`, and fails closed (exit 2) + when that copy is absent. It runs the standards `dependabot-policy` analyzer's + rules (schedule, cooldown floor, groups, open-pull-request cap, disabling + ignores, the `.github/dependabot-policy.json` exceptions) plus two that + analyzer does not: `cooldown.semver-major-days`, when set, is at least the + `major` class, and every `cooldown.exclude` entry is in the policy's + `fastTrack` list for the entry's ecosystem (plus `fastTrack.own` for + `github-actions`; a subset passes). `default-days` is held at or above the + `patch` class by the existing floor, which the policy requires to equal it. + An exception that waives `cooldown` waives both. The schemas are standards' + with one change: they accept any `schemaVersion` from 2 up and ignore + unknown top-level, `fastTrack` and `autoMerge` keys, so a later standards + release cannot fail every consumer. The runtime (ajv, yaml) installs from + the action's own lockfile, which Dependabot tracks. This action is the + checker consumers run, as a step of a lane their `ci-status` aggregates; in + phase 4 of the org update policy standards' `dependabot-policy.mjs` becomes a + pointer to it. No repository enables it yet. - `.github/actions/verify-pulumi-deploy-policy` — verifies the complete Pulumi personal [OIDC allow-policy][pulumi-oidc] set against a versioned exact-claim contract, then [exports stack state][pulumi-stack-export] without plaintext secrets and @@ -1183,10 +1211,21 @@ GitHub continues the normal weekly patching of each hosted image generation. fetch-metadata succeeded; every update is `github_actions` or, unless the caller passes `package-auto-merge: false`, `npm_and_yarn`, `pip`, `uv` or `nuget` (docker, dotnet-sdk and other ecosystems stay manual); every GitHub - Actions dependency name matches a glob in the caller's `publisher-allowlist` - JSON array (default `["actions/*","github/*","anthropics/*"]`, mirroring - standards `dependabot-policy` `autoMerge`; an input that is not a JSON array - of non-empty strings arms nothing); no package name is in the caller's + Actions dependency name matches a glob in the allowlist (an input that is + not a JSON array of non-empty strings arms nothing). The allowlist is the + caller's `publisher-allowlist` JSON array (default + `["actions/*","github/*","anthropics/*"]`, mirroring standards + `dependabot-policy` `autoMerge`), except that at the default, when the pull + request's base commit holds the standards-synced + `.github/standards/dependabot-policy/policy.json`, it is that copy's + `tier: auto` publishers plus its `fastTrack` `github-actions` and `own` globs + (`melodic-software/*`). The copy is read through the contents API at the base + commit, so the pull request cannot change it. Whatever the input, a synced + copy also narrows: its `autoMerge.ecosystems` bound the ecosystems armed, and + its `autoMerge.reviewOnly` dependencies for this repository are never armed. + A copy that cannot be read or parsed arms nothing, and with no copy the + input stands, as before. No + package name is in the caller's `review-only-dependencies` JSON array (its `autoMerge.reviewOnly` entries); every update is semver patch or minor, and a version below 1.0 keeps its minor component; the metadata names exactly the updates and versions the @@ -1228,8 +1267,11 @@ GitHub continues the normal weekly patching of each hosted image generation. own Dependabot merge path, not a reusable. Every 30 minutes and on dispatch, on main only, it lists the open PRs authored by `49699333`, reads each head commit's Dependabot metadata, runs the same gate with the reusable's - default inputs, and requires a `ci-status` check run that succeeded on the - head. It then arms each passing PR with the melodic-release App token from + default inputs (a synced dependabot-policy copy in the checked-out main + would set the publishers the same way; this repository has none yet), and + requires a `ci-status` check run that succeeded on the head. Every package + `check-dependabot-policy/package-lock.json` installs, transitive ones + included, is review-only, since consumers run that checker. It then arms each passing PR with the melodic-release App token from the `plugin-release` environment, at the verified head and with no merge method, so the merge queue takes it. Out-of-tier PRs are left alone with a notice, and one armed on an earlier head is disarmed. Arming a PR that changes a workflow file needs the App's @@ -1237,6 +1279,18 @@ GitHub continues the normal weekly patching of each hosted image generation. `melodic-release lacks workflows: write` and moves on. No failure fails the job, and it logs both tokens' `api-calls`. +- `.github/workflows/maintenance-bump-pinned-versions.yml` — this repository's + own tool-pin updater, not a reusable. Daily and on dispatch, on main only, it + runs `bump-versions` over `.github/version-bump/policy.yaml`: the tool + versions and release digests the actions and reusable workflows pin outside + Dependabot (the policy lists each pin, and the ones it leaves to a person + with the reason). Each tool is capped at its current major, since a merged + bump reaches every `@main` caller. It opens each bump with the melodic-release + App token from the `plugin-release` environment. The cooldowns come from the + standards-synced `.github/standards/dependabot-policy/policy.json`; this + repository does not receive that file yet, so until a standards sync target + adds it the run warns and bumps nothing. + - `.github/workflows/maintenance-sync-standards.yml` — orchestrates exact-file distribution from the schema-v2 component manifest in `melodic-software/standards`. The standards checkout validates and materializes its own manifest; this workflow