From 5a169a74932306c0119b0cf68e020f5205719b1a Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 4 Oct 2026 17:08:55 -0400 Subject: [PATCH] Version the dependency audit action with release tags The org CI rule asks every pin to carry a version comment, and Sponge's action supply-chain test rejects the "# main" comment the first rollout used. Repositories now pin the commit of hraness/.github's newest vX.Y.Z tag with that version as the comment, so Dependabot's weekly GitHub Actions update can move every repository to a new release. The sync script also keeps a repository's own actions/checkout pin, taken from its existing audit workflow or its other workflows. A repository that reviews one checkout commit, as Sponge does, gets the same commit instead of a second one. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 2 +- actions/dependency-audit/README.md | 12 +++- scripts/sync-dependency-audit.py | 97 +++++++++++++++++++---------- templates/dependency-audit.yml | 2 +- tests/test_sync_dependency_audit.py | 71 ++++++++++++++------- 5 files changed, 124 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d953ca..9856399 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ - `scripts/ci-cost-lint.py` checks a repository's workflows against the cost rules in the `hraness-ci` block; `.github/workflows/ci-cost-lint.yml` is the reusable workflow that runs it. - `scripts/actions-cost-report.py` summarizes the organization's GitHub billing usage per repository and SKU, the daily net trend, and budget headroom. - `actions/npm-visible/` checks public npm version, archive, tag, and provenance visibility within one deadline; its reference documents inputs, outputs, and failure behavior. -- `actions/dependency-audit/` scans every lockfile in a repository with a pinned OSV-Scanner: pull requests fail only on vulnerabilities they add, and the daily run on `main` keeps one `dependency-audit` issue current. `scripts/sync-dependency-audit.py` checks or installs its workflow from `templates/dependency-audit.yml` in every repository that tracks a lockfile; `.github/workflows/dependency-audit-coverage.yml` runs the check weekly over public repositories, and private ones are checked from a signed-in machine with `--org hraness --visibility private`. +- `actions/dependency-audit/` scans every lockfile in a repository with a pinned OSV-Scanner: pull requests fail only on vulnerabilities they add, and the daily run on `main` keeps one `dependency-audit` issue current. `scripts/sync-dependency-audit.py` checks or installs its workflow from `templates/dependency-audit.yml`, pinned to this repository's newest `vX.Y.Z` tag, in every repository that tracks a lockfile; `.github/workflows/dependency-audit-coverage.yml` runs the check weekly over public repositories, and private ones are checked from a signed-in machine with `--org hraness --visibility private`. - `BROWSER_AUTOMATION.md` describes browser provisioning, selection, cleanup, and regression evidence. - `scripts/register-browser-policy.py` checks or installs the browser rule in global agent files or selected repository roots, preserving other instructions. Global files cover local sessions; repository files and the Devin personal-cloud plugin make the rule portable. - `plugins/browser-automation/` is a rule-only Devin plugin for account-wide cloud, CLI, and Desktop registration; it carries the same canonical browser rule and no tools, hooks, MCP servers, or dependencies. diff --git a/actions/dependency-audit/README.md b/actions/dependency-audit/README.md index a01a2d6..55b242a 100644 --- a/actions/dependency-audit/README.md +++ b/actions/dependency-audit/README.md @@ -10,15 +10,21 @@ which GitHub's dependency graph does not read. Every Hraness repository uses the same workflow, rendered from [`templates/dependency-audit.yml`](../../templates/dependency-audit.yml) with the -action pinned to a full commit of this repository. Install or update it with: +action pinned to the commit of this repository's newest `vX.Y.Z` tag and that +version as the pin's comment. Install or update it with: ```sh scripts/sync-dependency-audit.py --apply --repo hraness/ ``` The script opens a pull request with the rendered workflow and enables auto-merge. -Change the template here rather than a repository's copy: the coverage check below -reports any difference. +It keeps the repository's own `actions/checkout` pin, so repositories that review +each action commit stay consistent. Change the template here rather than a +repository's copy: the coverage check below reports any difference. + +To release a change to the action, merge it, then tag `main` with the next +`vX.Y.Z`. Each repository's weekly Dependabot update for GitHub Actions moves its +pin to the new tag, and the coverage check reports any repository still behind. ## What it checks diff --git a/scripts/sync-dependency-audit.py b/scripts/sync-dependency-audit.py index 299b52d..175a3e3 100644 --- a/scripts/sync-dependency-audit.py +++ b/scripts/sync-dependency-audit.py @@ -2,13 +2,15 @@ """Check or install the shared dependency audit workflow in organization repositories. usage: scripts/sync-dependency-audit.py (--check | --apply) [--org ORG] [--visibility public|private|all] - [--repo OWNER/NAME ...] [--include-forks] [--action-sha SHA] [--json] + [--repo OWNER/NAME ...] [--include-forks] + [--action-version vX.Y.Z] [--json] Every repository that tracks a lockfile listed in actions/dependency-audit/lockfiles.json should carry .github/workflows/dependency-audit.yml exactly as templates/dependency-audit.yml -renders it, with the action pinned to the newest commit of this repository's main branch -that changed actions/dependency-audit (or --action-sha). A newer actions/checkout commit -pinned with its version comment, as Dependabot updates it, still counts as current. +renders it, with the action pinned to the commit of this repository's newest vX.Y.Z tag +(or --action-version) and that version as the pin's comment. Any actions/checkout commit +pinned with its version comment counts as current, so Dependabot updates and a +repository's own reviewed checkout pin both pass. Each target is one of: @@ -50,9 +52,12 @@ WORKFLOW = ".github/workflows/dependency-audit.yml" ACTION = "hraness/.github/actions/dependency-audit" PLACEHOLDER = "__ACTION_SHA__" -USES = re.compile(r"^(\s*- uses: hraness/\.github/actions/dependency-audit@)([0-9a-f]{40})( # main)$", re.MULTILINE) +VERSION_PLACEHOLDER = "__ACTION_VERSION__" +USES = re.compile(r"^(\s*- uses: hraness/\.github/actions/dependency-audit@)[0-9a-f]{40} # v\d+\.\d+\.\d+$", re.MULTILINE) CHECKOUT = re.compile(r"^(\s*- uses: actions/checkout@)[0-9a-f]{40} # v\d+(?:\.\d+)*$", re.MULTILINE) +CHECKOUT_PIN = re.compile(r"actions/checkout@([0-9a-f]{40}) # (v\d+(?:\.\d+)*)\s*$", re.MULTILINE) SHA = re.compile(r"^[0-9a-f]{40}$") +VERSION = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") FAILING = ("outdated", "drifted", "missing") @@ -86,13 +91,26 @@ def is_lockfile(path: str, globs: list[str]) -> bool: return "node_modules" not in parts and any(fnmatch.fnmatchcase(parts[-1], glob) for glob in globs) -def render(sha: str) -> str: +def render(sha: str, version: str, checkout: tuple[str, str] | None = None) -> str: if not SHA.match(sha): raise ValueError(f"action commit must be a full 40-character SHA, not {sha!r}") + if not VERSION.match(version): + raise ValueError(f"action version must look like vX.Y.Z, not {version!r}") template = TEMPLATE.read_text() - if template.count(PLACEHOLDER) != 1: - raise ValueError(f"{TEMPLATE} must contain {PLACEHOLDER} exactly once") - return template.replace(PLACEHOLDER, sha) + if template.count(PLACEHOLDER) != 1 or template.count(VERSION_PLACEHOLDER) != 1: + raise ValueError(f"{TEMPLATE} must contain {PLACEHOLDER} and {VERSION_PLACEHOLDER} exactly once") + text = template.replace(PLACEHOLDER, sha).replace(VERSION_PLACEHOLDER, version) + if checkout is not None: + text = CHECKOUT.sub(lambda match: f"{match.group(1)}{checkout[0]} # {checkout[1]}", text) + return text + + +def checkout_pin(workflows: list[str]) -> tuple[str, str] | None: + pins: dict[tuple[str, str], int] = {} + for text in workflows: + for match in CHECKOUT_PIN.finditer(text): + pins[(match.group(1), match.group(2))] = pins.get((match.group(1), match.group(2)), 0) + 1 + return max(sorted(pins), key=lambda pin: pins[pin]) if pins else None def classify(current: str | None, expected: str, applicable: bool) -> str: @@ -103,16 +121,22 @@ def classify(current: str | None, expected: str, applicable: bool) -> str: current, expected = (CHECKOUT.sub(r"\1PINNED", text) for text in (current, expected)) if current == expected: return "ok" - if USES.sub(r"\1" + "0" * 40 + r"\3", current) == USES.sub(r"\1" + "0" * 40 + r"\3", expected): + if USES.sub(r"\1PINNED", current) == USES.sub(r"\1PINNED", expected): return "outdated" return "drifted" -def action_sha() -> str: - commits = gh_api(f"repos/hraness/.github/commits?sha=main&path={quote('actions/dependency-audit')}&per_page=1") - if not commits: - raise RuntimeError("found no commit of actions/dependency-audit on hraness/.github main") - return commits[0]["sha"] +def action_release(version: str | None = None) -> tuple[str, str]: + tags = gh_api("repos/hraness/.github/tags?per_page=100", paginate=True) or [] + releases = {t["name"]: t["commit"]["sha"] for t in tags if VERSION.match(t.get("name", ""))} + if version is not None: + if version not in releases: + raise RuntimeError(f"hraness/.github has no {version} tag") + return releases[version], version + if not releases: + raise RuntimeError("hraness/.github has no vX.Y.Z tag for the dependency audit action") + newest = max(releases, key=lambda name: tuple(int(part) for part in VERSION.match(name).groups())) + return releases[newest], newest def org_repos(org: str, visibility: str, include_forks: bool) -> list[dict]: @@ -127,16 +151,23 @@ def repo_info(name: str) -> dict: return info -def inspect(repo: dict, expected: str, globs: list[str]) -> dict: +def read_file(name: str, path: str, branch: str) -> str | None: + blob = gh_api(f"repos/{name}/contents/{quote(path)}?ref={quote(branch)}") + return base64.b64decode(blob["content"]).decode() if isinstance(blob, dict) and blob.get("content") else None + + +def inspect(repo: dict, sha: str, version: str, globs: list[str]) -> dict: name, branch = repo["full_name"], repo.get("default_branch") or "main" tree = gh_api(f"repos/{name}/git/trees/{quote(branch)}?recursive=1") or {} paths = [item["path"] for item in tree.get("tree", []) if item.get("type") == "blob"] lockfiles = sorted(path for path in paths if is_lockfile(path, globs)) - blob = gh_api(f"repos/{name}/contents/{WORKFLOW}?ref={quote(branch)}") - current = base64.b64decode(blob["content"]).decode() if isinstance(blob, dict) and blob.get("content") else None + current = read_file(name, WORKFLOW, branch) if WORKFLOW in paths else None + others = [path for path in paths if path.startswith(".github/workflows/") and path.endswith((".yml", ".yaml")) and path != WORKFLOW] + pin = checkout_pin([current] if current else []) or (checkout_pin([read_file(name, path, branch) or "" for path in others]) if lockfiles else None) + expected = render(sha, version, pin) status = classify(current, expected, bool(lockfiles)) entry = {"repo": name, "branch": branch, "status": status, "lockfiles": len(lockfiles), "truncated": bool(tree.get("truncated")), - "run": None, "issue": None} + "run": None, "issue": None, "expected": expected} if current is not None: runs = gh_api(f"repos/{name}/actions/workflows/dependency-audit.yml/runs?branch={quote(branch)}&per_page=1") or {} latest = (runs.get("workflow_runs") or [None])[0] @@ -153,7 +184,7 @@ def inspect(repo: dict, expected: str, globs: list[str]) -> dict: PR_TITLE = "Run the shared dependency audit" PR_BODY = """## Summary -- Adds or updates `.github/workflows/dependency-audit.yml` from `templates/dependency-audit.yml` in hraness/.github, pinned to action commit `{sha}`. +- Adds or updates `.github/workflows/dependency-audit.yml` from `templates/dependency-audit.yml` in hraness/.github, pinned to the `{version}` release of the action. - The audit scans every tracked lockfile with a pinned OSV-Scanner on pull requests that change a lockfile, on pushes to `main`, daily, and on demand. - A pull request fails the audit only when it adds a known vulnerability. The run on `main` keeps one `dependency-audit` issue current with every known vulnerability and closes it when none remain. - The check is advisory: do not add it to required checks. @@ -165,9 +196,9 @@ def inspect(repo: dict, expected: str, globs: list[str]) -> dict: """ -def open_pull_request(entry: dict, content: str, sha: str) -> str: - name, base = entry["repo"], entry["branch"] - branch = f"hraness/dependency-audit-{sha[:12]}" +def open_pull_request(entry: dict, version: str) -> str: + name, base, content = entry["repo"], entry["branch"], entry["expected"] + branch = f"hraness/dependency-audit-{version}" existing = gh_api(f"repos/{name}/pulls?state=open&head={quote(name.split('/')[0] + ':' + branch)}") or [] if existing: return existing[0]["html_url"] @@ -175,13 +206,13 @@ def open_pull_request(entry: dict, content: str, sha: str) -> str: if gh_api(f"repos/{name}/git/ref/heads/{quote(branch)}") is None: gh_api(f"repos/{name}/git/refs", method="POST", body={"ref": f"refs/heads/{branch}", "sha": head["object"]["sha"]}) current = gh_api(f"repos/{name}/contents/{WORKFLOW}?ref={quote(branch)}") - body = {"message": f"{PR_TITLE}\n\nInstall the dependency audit workflow from hraness/.github at {sha}.", + body = {"message": f"{PR_TITLE}\n\nInstall the dependency audit workflow from hraness/.github {version}.", "content": base64.b64encode(content.encode()).decode(), "branch": branch} if isinstance(current, dict) and current.get("sha"): body["sha"] = current["sha"] gh_api(f"repos/{name}/contents/{WORKFLOW}", method="PUT", body=body) pull = gh_api(f"repos/{name}/pulls", method="POST", - body={"title": PR_TITLE, "head": branch, "base": base, "body": PR_BODY.format(sha=sha)}) + body={"title": PR_TITLE, "head": branch, "base": base, "body": PR_BODY.format(version=version)}) url = pull["html_url"] merge = run([gh(), "pr", "merge", "--auto", "--squash", url]) if merge.returncode != 0: @@ -206,29 +237,31 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--visibility", choices=["public", "private", "all"], default="public") ap.add_argument("--repo", action="append", default=[]) ap.add_argument("--include-forks", action="store_true", help="include active forks in organization discovery") - ap.add_argument("--action-sha", help="pin this commit instead of the newest commit that changed the action") + ap.add_argument("--action-version", help="pin this vX.Y.Z tag of hraness/.github instead of the newest one") ap.add_argument("--json", action="store_true") a = ap.parse_args(argv) if not a.org and not a.repo: ap.error("give --org or --repo") try: - sha = a.action_sha or action_sha() - expected = render(sha) + sha, version = action_release(a.action_version) + render(sha, version) globs = lockfile_globs() repos = org_repos(a.org, a.visibility, a.include_forks) if a.org else [] repos += [repo_info(name) for name in a.repo if name not in {r["full_name"] for r in repos}] - results = [inspect(repo, expected, globs) for repo in repos] + results = [inspect(repo, sha, version, globs) for repo in repos] if a.apply: for entry in results: if entry["status"] in FAILING: - entry["pull_request"] = open_pull_request(entry, expected, sha) + entry["pull_request"] = open_pull_request(entry, version) except (RuntimeError, ValueError) as error: print(f"sync-dependency-audit: {error}", file=sys.stderr) return 2 + for entry in results: + entry.pop("expected", None) if a.json: - print(json.dumps({"action_sha": sha, "results": results}, indent=2)) + print(json.dumps({"action_sha": sha, "action_version": version, "results": results}, indent=2)) else: - print(f"Dependency audit coverage, action {ACTION}@{sha[:12]}\n") + print(f"Dependency audit coverage, action {ACTION} {version} ({sha[:12]})\n") for entry in results: print(describe(entry) + (f"\n pull request: {entry['pull_request']}" if entry.get("pull_request") else "")) counts = {status: sum(1 for e in results if e["status"] == status) for status in ("ok", *FAILING, "not-applicable")} diff --git a/templates/dependency-audit.yml b/templates/dependency-audit.yml index 50fdac9..cfe208b 100644 --- a/templates/dependency-audit.yml +++ b/templates/dependency-audit.yml @@ -91,4 +91,4 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: hraness/.github/actions/dependency-audit@__ACTION_SHA__ # main + - uses: hraness/.github/actions/dependency-audit@__ACTION_SHA__ # __ACTION_VERSION__ diff --git a/tests/test_sync_dependency_audit.py b/tests/test_sync_dependency_audit.py index 4258170..14be6b4 100644 --- a/tests/test_sync_dependency_audit.py +++ b/tests/test_sync_dependency_audit.py @@ -4,6 +4,7 @@ import re import unittest from contextlib import redirect_stdout +from urllib.parse import unquote from helpers import ROOT, load_script @@ -12,18 +13,34 @@ SHA = "a" * 40 OLD = "b" * 40 +VERSION = "v1.2.0" +OLD_VERSION = "v1.1.0" class Template(unittest.TestCase): - def test_render_pins_one_full_commit(self): - text = sync.render(SHA) - self.assertIn(f"- uses: hraness/.github/actions/dependency-audit@{SHA} # main", text) + def test_render_pins_one_full_commit_with_its_release(self): + text = sync.render(SHA, VERSION) + self.assertIn(f"- uses: hraness/.github/actions/dependency-audit@{SHA} # {VERSION}", text) self.assertNotIn(sync.PLACEHOLDER, text) + self.assertNotIn(sync.VERSION_PLACEHOLDER, text) with self.assertRaises(ValueError): - sync.render("main") + sync.render("main", VERSION) + with self.assertRaises(ValueError): + sync.render(SHA, "main") + + def test_render_keeps_a_repository_checkout_pin(self): + text = sync.render(SHA, VERSION, ("9" * 40, "v7.0.0")) + self.assertIn(f"- uses: actions/checkout@{'9' * 40} # v7.0.0", text) + self.assertEqual(sync.classify(text, sync.render(SHA, VERSION), True), "ok") + + def test_checkout_pin_prefers_the_most_common_reviewed_pin(self): + one = f" - uses: actions/checkout@{'1' * 40} # v7.0.0\n" + two = f" - uses: actions/checkout@{'2' * 40} # v7.0.1\n" + self.assertEqual(sync.checkout_pin([one + one, two]), ("1" * 40, "v7.0.0")) + self.assertIsNone(sync.checkout_pin([" - uses: actions/checkout@v7\n"])) def test_pull_request_and_push_paths_list_every_scanned_lockfile(self): - text = sync.render(SHA) + text = sync.render(SHA, VERSION) expected = [f'"**/{glob}"' for glob in sync.lockfile_globs()] + ['"**/osv-scanner.toml"', ".github/workflows/dependency-audit.yml"] blocks = re.findall(r" paths:\n((?: - .+\n)+)", text) self.assertEqual(len(blocks), 2) @@ -31,10 +48,15 @@ def test_pull_request_and_push_paths_list_every_scanned_lockfile(self): self.assertEqual([line.removeprefix(" - ") for line in block.splitlines()], expected) def test_rendered_workflow_passes_the_cost_lint_in_a_private_repository(self): - self.assertEqual(lint.lint_workflow("dependency-audit.yml", sync.render(SHA)), []) + self.assertEqual(lint.lint_workflow("dependency-audit.yml", sync.render(SHA, VERSION)), []) + + def test_every_pin_carries_a_version_comment(self): + for line in sync.render(SHA, VERSION).splitlines(): + if "uses:" in line: + self.assertRegex(line, r"@[0-9a-f]{40} # v\d+(?:\.\d+){0,2}$") def test_workflow_is_advisory_and_least_privileged(self): - text = sync.render(SHA) + text = sync.render(SHA, VERSION) self.assertIn("permissions:\n contents: read\n", text) self.assertIn(" contents: read\n issues: write\n", text) self.assertNotIn("Required", text) @@ -59,20 +81,20 @@ def test_the_action_reads_the_same_list(self): class Classify(unittest.TestCase): def test_statuses(self): - expected = sync.render(SHA) + expected = sync.render(SHA, VERSION) self.assertEqual(sync.classify(expected, expected, True), "ok") - self.assertEqual(sync.classify(sync.render(OLD), expected, True), "outdated") + self.assertEqual(sync.classify(sync.render(OLD, OLD_VERSION), expected, True), "outdated") self.assertEqual(sync.classify(expected.replace("37 5 * * *", "0 0 * * *"), expected, True), "drifted") self.assertEqual(sync.classify(None, expected, True), "missing") self.assertEqual(sync.classify(None, expected, False), "not-applicable") self.assertEqual(sync.classify(expected, expected, False), "not-applicable") def test_a_dependabot_checkout_update_is_still_current(self): - expected = sync.render(SHA) + expected = sync.render(SHA, VERSION) bumped = expected.replace("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", f"actions/checkout@{'d' * 40} # v7.1.0") self.assertNotEqual(bumped, expected) self.assertEqual(sync.classify(bumped, expected, True), "ok") - self.assertEqual(sync.classify(bumped.replace(f"@{SHA}", f"@{OLD}"), expected, True), "outdated") + self.assertEqual(sync.classify(bumped.replace(f"@{SHA} # {VERSION}", f"@{OLD} # {OLD_VERSION}"), expected, True), "outdated") self.assertEqual(sync.classify(expected.replace("# v7.0.1", "# main"), expected, True), "drifted") @@ -87,16 +109,18 @@ def __call__(self, path, paginate=False, method="GET", body=None): if path.endswith("/pulls"): return {"html_url": f"https://github.com/{path.split('/')[1]}/{path.split('/')[2]}/pull/1"} return {} - if path.startswith("repos/hraness/.github/commits"): - return [{"sha": SHA}] + if path.startswith("repos/hraness/.github/tags"): + return [{"name": VERSION, "commit": {"sha": SHA}}, {"name": OLD_VERSION, "commit": {"sha": OLD}}, {"name": "v1.10", "commit": {"sha": "f" * 40}}] if path.startswith("orgs/"): return [{"full_name": f"hraness/{name}", "default_branch": "main", "archived": False, "fork": name == "fork"} for name in self.repos] name = path.split("/")[2] repo = self.repos.get(name, {}) if "/git/trees/" in path: - return {"tree": [{"path": p, "type": "blob"} for p in repo.get("files", [])], "truncated": False} - if "/contents/.github/workflows/dependency-audit.yml" in path: - text = repo.get("workflow") + files = repo.get("files", []) + ([".github/workflows/dependency-audit.yml"] if repo.get("workflow") else []) + list(repo.get("workflows", {})) + return {"tree": [{"path": p, "type": "blob"} for p in files], "truncated": False} + if "/contents/" in path: + file = unquote(path.split("/contents/", 1)[1].split("?", 1)[0]) + text = repo.get("workflow") if file == ".github/workflows/dependency-audit.yml" else repo.get("workflows", {}).get(file) return {"content": base64.b64encode(text.encode()).decode(), "sha": "blob"} if text else None if "/actions/workflows/" in path: return {"workflow_runs": [{"conclusion": "failure", "event": "schedule", "html_url": "u", "created_at": "t"}]} @@ -126,10 +150,10 @@ def run_main(self, fake, *argv): def repos(self): return { - "current": {"files": ["bun.lock"], "workflow": sync.render(SHA)}, - "stale": {"files": ["site/bun.lock"], "workflow": sync.render(OLD)}, + "current": {"files": ["bun.lock"], "workflow": sync.render(SHA, VERSION)}, + "stale": {"files": ["site/bun.lock"], "workflow": sync.render(OLD, OLD_VERSION)}, "custom": {"files": ["Cargo.lock"], "workflow": "name: Dependency audit\n"}, - "absent": {"files": ["uv.lock", "README.md"]}, + "absent": {"files": ["uv.lock", "README.md"], "workflows": {".github/workflows/ci.yml": f" - uses: actions/checkout@{'9' * 40} # v7.0.0\n"}}, "docs": {"files": ["README.md"]}, "fork": {"files": ["bun.lock"]}, } @@ -146,7 +170,7 @@ def test_check_reports_every_status_and_fails_on_gaps(self): self.assertEqual(results["hraness/current"]["run"]["conclusion"], "failure") def test_check_passes_when_every_applicable_repository_is_current(self): - repos = {"current": {"files": ["bun.lock"], "workflow": sync.render(SHA)}, "docs": {"files": []}} + repos = {"current": {"files": ["bun.lock"], "workflow": sync.render(SHA, VERSION)}, "docs": {"files": []}} code, out = self.run_main(FakeGitHub(repos), "--check", "--org", "hraness") self.assertEqual(code, 0) self.assertIn("1 ok, 0 outdated, 0 drifted, 0 missing, 1 not-applicable", out) @@ -157,9 +181,10 @@ def test_apply_opens_pull_requests_only_for_gaps(self): self.assertEqual(code, 0) pulls = sorted(path for method, path, _ in fake.writes if path.endswith("/pulls")) self.assertEqual(pulls, ["repos/hraness/absent/pulls", "repos/hraness/custom/pulls", "repos/hraness/fork/pulls", "repos/hraness/stale/pulls"]) - puts = [(path, body) for method, path, body in fake.writes if method == "PUT"] - self.assertTrue(all(body["branch"] == f"hraness/dependency-audit-{SHA[:12]}" for _, body in puts)) - self.assertTrue(all(base64.b64decode(body["content"]).decode() == sync.render(SHA) for _, body in puts)) + puts = {path.split("/")[2]: body for method, path, body in fake.writes if method == "PUT"} + self.assertTrue(all(body["branch"] == f"hraness/dependency-audit-{VERSION}" for body in puts.values())) + self.assertEqual(base64.b64decode(puts["absent"]["content"]).decode(), sync.render(SHA, VERSION, ("9" * 40, "v7.0.0"))) + self.assertEqual(base64.b64decode(puts["stale"]["content"]).decode(), sync.render(SHA, VERSION)) self.assertFalse(any("/git/refs/heads/main" in path or body and body.get("branch") == "main" for _, path, body in fake.writes))