diff --git a/.github/workflows/dependabot_auto_merge.yml b/.github/workflows/dependabot_auto_merge.yml index c060d569..d9004eba 100644 --- a/.github/workflows/dependabot_auto_merge.yml +++ b/.github/workflows/dependabot_auto_merge.yml @@ -2,91 +2,48 @@ name: Auto Merge Dependabot PR "on": workflow_run: - workflows: ["CI"] + # Re-evaluated whenever one of the PR workflows finishes; the gate script waits for the rest. + workflows: ["CI", "Documentation check", "Engineering governance"] types: [completed] +permissions: + contents: read + +concurrency: + # One evaluation per repository and Dependabot branch; a fork cannot reuse a same-repository key and displace a pending run. + group: dependabot-auto-merge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + jobs: auto-merge: - if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'dependabot/') + name: Enable auto-merge when every check is green + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_repository.full_name == github.repository + && startsWith(github.event.workflow_run.head_branch, 'dependabot/') runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: - contents: write - pull-requests: write + contents: write # gh pr merge --auto --delete-branch + pull-requests: write # enable auto-merge on the verified head + checks: read # read every check run on the PR head + statuses: read # read legacy commit statuses (risk-gate, pr-template) steps: - - name: Resolve Dependabot PR - id: pr + # workflow_run uses only the trusted default branch, never the PR checkout. + - name: Check out the default-branch gate script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Gate on every check and enable auto-merge env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH_NAME: ${{ github.event.workflow_run.head_branch }} EXPECTED_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - PR_PAYLOAD=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --head "${BRANCH_NAME}" --json number,headRefOid --jq '.[0] // {}') - PR_NUMBER=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("number", ""))' <<<"${PR_PAYLOAD}") - PR_HEAD_SHA=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("headRefOid", ""))' <<<"${PR_PAYLOAD}") - if [ -z "${PR_NUMBER}" ]; then - echo "No open Dependabot PR found for ${BRANCH_NAME}." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - if [ "${PR_HEAD_SHA}" != "${EXPECTED_HEAD_SHA}" ]; then - echo "Skipping auto-merge: PR #${PR_NUMBER} head ${PR_HEAD_SHA} does not match completed CI head ${EXPECTED_HEAD_SHA}." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "head_sha=${PR_HEAD_SHA}" >> "$GITHUB_OUTPUT" - - - name: Evaluate merge eligibility - id: merge_guard - if: steps.pr.outputs.pr_number != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh pr view "${{ steps.pr.outputs.pr_number }}" --repo "${GITHUB_REPOSITORY}" --json number,isDraft,author,url,body,labels > pr.json - python3 - <<'PY' - import json - import os - from pathlib import Path - - pr = json.loads(Path("pr.json").read_text(encoding="utf-8")) - author = (pr.get("author") or {}).get("login") - labels = {item.get("name", "") for item in pr.get("labels", [])} - body = pr.get("body") or "" - is_major = "update-type: version-update:semver-major" in body - dependabot_authors = {"dependabot[bot]", "app/dependabot"} - is_dependabot = author in dependabot_authors and "dependencies" in labels - should_merge = is_dependabot and not pr.get("isDraft") and not is_major - if should_merge: - reason = "ready" - elif is_major: - reason = "major_update" - else: - reason = "not_eligible_dependabot_pr" - - summary_lines = [ - "## Auto-Merge Gate", - f"- PR: {pr['url']}", - f"- Author: `{author or ''}`", - f"- Draft: `{'yes' if pr.get('isDraft') else 'no'}`", - f"- Dependabot label: `{'yes' if 'dependencies' in labels else 'no'}`", - f"- Major update: `{'yes' if is_major else 'no'}`", - f"- Final merge decision: `{'merge' if should_merge else 'skip'}`", - f"- Reason: `{reason}`", - ] - Path("pr-summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8") - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - print(f"should_merge={'true' if should_merge else 'false'}", file=output) - print(f"reason={reason}", file=output) - PY - - - name: Append merge summary - if: steps.pr.outputs.pr_number != '' - run: cat pr-summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Merge Dependabot PR - if: steps.merge_guard.outputs.should_merge == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr merge "${{ steps.pr.outputs.pr_number }}" --repo "${GITHUB_REPOSITORY}" --auto --rebase --delete-branch --match-head-commit "${{ steps.pr.outputs.head_sha }}" + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRED_CHECKS: test,Documentation check,Gitleaks,Commit message convention,Evaluate PR governance + REQUIRED_STATUSES: risk-gate,pr-template + run: python3 scripts/dependabot_auto_merge_gate.py diff --git a/scripts/dependabot_auto_merge_gate.py b/scripts/dependabot_auto_merge_gate.py new file mode 100644 index 00000000..957a05c9 --- /dev/null +++ b/scripts/dependabot_auto_merge_gate.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Fail-closed gate for the Dependabot auto-merge workflow. + +The workflow runs from the default branch after a PR workflow completes. This +script decides whether the Dependabot PR may have auto-merge enabled: + +* the PR is a same-repository, verified Dependabot update that touches only + dependency manifests or SHA-pinned action version bumps in non-protected + workflows (the same exclusions as the GdeiAssistant dependabot policy); +* EVERY check run and commit status on the PR head is green, including + non-required ones such as the commit message check. + +Anything else is skipped with a reason in the step summary and never merged. +Standard library only; the GitHub CLI supplies authentication via GH_TOKEN. +""" + +from __future__ import annotations + +import json +import os +import posixpath +import re +import subprocess +import sys +import time + +DEPENDABOT_LOGINS = {"dependabot[bot]"} +GREEN_CONCLUSIONS = {"success", "skipped", "neutral"} + +# Dependency manifests that may change without a human. Dockerfiles, vendored +# wheels, tests and docs always need a human, even in a Dependabot PR. +DEPENDENCY_FILE = re.compile(r"^(?:uv\.lock|pyproject\.toml|(?:[\w.-]+/)*requirements[\w.-]*\.txt)$") +# Workflows where a pinned-action version bump may merge unattended. Everything +# else (deploy, runtime, secrets, governance) is reviewed by a person. +ACTION_FILES = {".github/workflows/ci.yml", ".github/workflows/docs-check.yml"} +# Same list as the GdeiAssistant policy: credential-bearing or policy workflows. +PROTECTED_WORKFLOW = re.compile( + r"(deploy|release|publish|ghcr|pages|azure|risk-gate|governance|review-signal|dependabot|codeql|hygiene)", + re.IGNORECASE, +) +ACTION_USES_LINE = re.compile(r"^[+-](\s*(?:-\s*)?uses:\s*)([\w.-]+/[\w./-]+)@([a-f0-9]{40})(?:\s+#.*)?$") +VERSION = re.compile(r"^v?([1-9]\d*)\.(\d+)(?:\.(\d+))?$") +UPDATE_TYPE = re.compile(r"update-type:\s*version-update:semver-(major|minor|patch)") +FROM_TO = re.compile(r"\bfrom (\S+) to (\S+)") + + +def action_version_only(file): + """True when the diff only moves SHA-pinned `uses:` lines of the same actions.""" + name = file.get("filename", "") + if name not in ACTION_FILES or not isinstance(file.get("patch"), str): + return False + if PROTECTED_WORKFLOW.search(posixpath.basename(name)): + return False + removed, added = [], [] + for line in file["patch"].split("\n"): + if not line or line[0] not in "+-" or line.startswith(("---", "+++")): + continue + match = ACTION_USES_LINE.match(line) + if not match: + return False + (removed if line[0] == "-" else added).append(match.group(1) + match.group(2)) + return bool(removed) and len(removed) == file.get("deletions") and len(added) == file.get("additions") and removed == added + + +def files_allowed(files): + """Return (kind, reason). kind is 'actions', 'dependencies' or '' when refused.""" + if not files: + return "", "no changed files" + if any(f.get("status") != "modified" or f.get("previous_filename") for f in files): + return "", "files were added, removed or renamed" + if all(action_version_only(f) for f in files): + return "actions", "pinned action version bump" + if all(DEPENDENCY_FILE.match(f.get("filename", "")) for f in files): + return "dependencies", "dependency manifests only" + names = ", ".join(sorted(f.get("filename", "?") for f in files)) + return "", f"changed files need a human: {names}"[:300] + + +def trusted_pull_request(pr, repo, default_branch, head_sha, commits): + """Return a reason string when the PR is not a verified same-repo Dependabot PR, else ''.""" + labels = {item.get("name", "") for item in pr.get("labels", [])} + if pr.get("state") != "open" or pr.get("draft"): + return "not an open, ready pull request" + if (pr.get("user") or {}).get("login") not in DEPENDABOT_LOGINS or "dependencies" not in labels: + return "not a Dependabot dependency PR" + if (pr.get("base") or {}).get("ref") != default_branch or (pr.get("base") or {}).get("repo", {}).get("full_name") != repo: + return "base is not this repository default branch" + head = pr.get("head") or {} + if (head.get("repo") or {}).get("full_name") != repo: + return "head is not in this repository" + if not re.fullmatch(r"[a-f0-9]{40}", head_sha or "") or head.get("sha") != head_sha: + return "PR head does not match the completed run" + if not commits or len(commits) != pr.get("commits") or commits[-1].get("sha") != head_sha: + return "commit list is incomplete" + for commit in commits: + detail = commit.get("commit") or {} + # Dependabot commits are authored by the bot and committed by GitHub web-flow; a human push fails this. + if ( + (commit.get("author") or {}).get("login") not in DEPENDABOT_LOGINS + or (commit.get("committer") or {}).get("login") != "web-flow" + or (detail.get("committer") or {}).get("email") != "noreply@github.com" + or (detail.get("verification") or {}).get("verified") is not True + ): + return "a commit is not a verified Dependabot commit" + return "" + + +def version_update_allowed(kind, pr): + """Patch/minor only; for non-action dependencies also require a stable >=1.0 version.""" + types = UPDATE_TYPE.findall(pr.get("body") or "") + if len(types) != 1 or types[0] == "major": + return "update type is missing, ambiguous or major" + if kind == "actions": + return "" + match = FROM_TO.search(pr.get("title") or "") + previous = VERSION.match(match.group(1)) if match else None + new = VERSION.match(match.group(2)) if match else None + if not previous or not new or previous.group(1) != new.group(1): + return "versions are unknown, pre-1.0, prerelease or cross-major" + prev_minor, new_minor = int(previous.group(2)), int(new.group(2)) + prev_patch, new_patch = int(previous.group(3) or 0), int(new.group(3) or 0) + if types[0] == "patch": + ok = prev_minor == new_minor and new_patch > prev_patch + else: + ok = new_minor > prev_minor + return "" if ok else "version change does not match the declared update type" + + +def eligibility(pr, files, commits, repo, default_branch, head_sha): + """Return (eligible, reason).""" + reason = trusted_pull_request(pr, repo, default_branch, head_sha, commits) + if reason: + return False, reason + if len(files) != pr.get("changed_files"): + return False, "changed file list is incomplete" + kind, reason = files_allowed(files) + if not kind: + return False, reason + reason = version_update_allowed(kind, pr) + return (False, reason) if reason else (True, reason or kind) + + +def check_state(check_runs, statuses, required_checks, required_statuses): + """Return ('green'|'pending'|'red', details) over every check run and commit status.""" + latest = {} + for run in check_runs: + key = (run.get("name"), (run.get("app") or {}).get("id")) + if key not in latest or run.get("id", 0) > latest[key].get("id", 0): + latest[key] = run + red, pending = [], [] + for (name, _app), run in sorted(latest.items(), key=lambda item: str(item[0])): + if run.get("status") != "completed": + pending.append(name) + elif run.get("conclusion") not in GREEN_CONCLUSIONS: + red.append(f"{name}={run.get('conclusion')}") + by_name = {name for name, _app in latest} + for status in statuses: + if status.get("state") in ("failure", "error"): + red.append(f"{status.get('context')}={status.get('state')}") + elif status.get("state") != "success": + pending.append(str(status.get("context"))) + status_state = {s.get("context"): s.get("state") for s in statuses} + for name in required_checks: + if name not in by_name: + pending.append(f"{name} (not reported yet)") + for name in required_statuses: + if name not in status_state: + pending.append(f"{name} (not reported yet)") + if red: + return "red", sorted(red) + if pending: + return "pending", sorted(pending) + return "green", [] + + +def gh(args): + return subprocess.run(["gh", *args], check=True, capture_output=True, text=True).stdout + + +def api(route): + return json.loads(gh(["api", route])) + + +def pages(route): + return [item for page in json.loads(gh(["api", "--paginate", "--slurp", route])) for item in page] + + +def split_names(value): + return [item.strip() for item in (value or "").split(",") if item.strip()] + + +def summary(lines): + text = "\n".join(lines) + "\n" + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a", encoding="utf-8") as handle: + handle.write(text) + print(text) + + +def fetch_checks(repo, sha): + check_runs = [] + for page in json.loads(gh(["api", "--paginate", "--slurp", f"repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"])): + check_runs.extend(page.get("check_runs", [])) + statuses = api(f"repos/{repo}/commits/{sha}/status?per_page=100").get("statuses", []) + return check_runs, statuses + + +def main(): + repo = os.environ["GITHUB_REPOSITORY"] + branch = os.environ["BRANCH_NAME"] + expected_head = os.environ["EXPECTED_HEAD_SHA"] + default_branch = os.environ["DEFAULT_BRANCH"] + required_checks = split_names(os.environ.get("REQUIRED_CHECKS")) + required_statuses = split_names(os.environ.get("REQUIRED_STATUSES")) + wait_seconds = int(os.environ.get("CHECK_WAIT_SECONDS", "480")) + poll_seconds = int(os.environ.get("CHECK_POLL_SECONDS", "20")) + owner = repo.split("/")[0] + + prs = api(f"repos/{repo}/pulls?state=open&head={owner}:{branch}&per_page=2") + if len(prs) != 1: + summary(["## Auto-Merge Gate", f"- Skipped: expected exactly one open PR for `{branch}`, found {len(prs)}."]) + return 0 + number = prs[0]["number"] + pr = api(f"repos/{repo}/pulls/{number}") + commits = pages(f"repos/{repo}/pulls/{number}/commits?per_page=100") + files = pages(f"repos/{repo}/pulls/{number}/files?per_page=100") + eligible, reason = eligibility(pr, files, commits, repo, default_branch, expected_head) + lines = ["## Auto-Merge Gate", f"- PR: {pr.get('html_url')}", f"- Head: `{expected_head}`", f"- Eligibility: `{reason}`"] + if not eligible: + summary(lines + ["- Decision: `skip` (needs a human)"]) + return 0 + + deadline = time.monotonic() + wait_seconds + while True: + current = api(f"repos/{repo}/pulls/{number}") + if current.get("state") != "open" or current["head"]["sha"] != expected_head: + summary(lines + ["- Decision: `skip` (PR closed or head changed while waiting)"]) + return 0 + check_runs, statuses = fetch_checks(repo, expected_head) + state, details = check_state(check_runs, statuses, required_checks, required_statuses) + if state == "green": + break + if state == "red": + summary(lines + [f"- Decision: `skip` (not green: {', '.join(details)})"]) + return 0 + if time.monotonic() >= deadline: + summary(lines + [f"- Decision: `skip` (still pending after {wait_seconds}s: {', '.join(details)}); a later run re-evaluates"]) + return 0 + time.sleep(poll_seconds) + + # Every check run and commit status on the head is green. Branch protection still decides when the merge lands. + gh(["pr", "merge", str(number), "--repo", repo, "--auto", "--rebase", "--delete-branch", "--match-head-commit", expected_head]) + summary(lines + ["- Checks: all check runs and statuses green", "- Decision: `auto-merge enabled`"]) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as error: + detail = re.sub(r"\b(gh[pousr]_|github_pat_)\w+", "***", (error.stderr or "").strip()) + print(f"Auto-merge gate failed closed: {detail[:300]}", file=sys.stderr) + sys.exit(1) diff --git a/tests/test_dependabot_auto_merge_gate.py b/tests/test_dependabot_auto_merge_gate.py new file mode 100644 index 00000000..f0b19877 --- /dev/null +++ b/tests/test_dependabot_auto_merge_gate.py @@ -0,0 +1,164 @@ +"""Synthetic offline fixtures for the Dependabot auto-merge gate; no network.""" +import unittest + +from scripts import dependabot_auto_merge_gate as gate + +REPO = "QuantStrategyLab/ExamplePlatform" +SHA = "a" * 40 +REQUIRED = ["test", "Commit message convention"] +STATUSES = ["risk-gate", "pr-template"] + + +def commit(sha=SHA, author="dependabot[bot]", committer="web-flow", verified=True): + return { + "sha": sha, + "author": {"login": author}, + "committer": {"login": committer}, + "commit": {"committer": {"email": "noreply@github.com"}, "verification": {"verified": verified}}, + } + + +def pull(**overrides): + value = { + "state": "open", + "draft": False, + "user": {"login": "dependabot[bot]"}, + "labels": [{"name": "dependencies"}], + "base": {"ref": "main", "repo": {"full_name": REPO}}, + "head": {"sha": SHA, "repo": {"full_name": REPO}}, + "commits": 1, + "changed_files": 1, + "title": "chore(deps): bump certifi from 2026.6.17 to 2026.10.19", + "body": "update-type: version-update:semver-minor", + } + value.update(overrides) + return value + + +def lock_file(name="uv.lock"): + return {"filename": name, "status": "modified"} + + +def action_file(name=".github/workflows/ci.yml", new="b" * 40): + old = "c" * 40 + patch = ( + "@@ -1,3 +1,3 @@\n context\n" + f"- - uses: actions/checkout@{old} # v5\n" + f"+ - uses: actions/checkout@{new} # v6\n" + ) + return {"filename": name, "status": "modified", "patch": patch, "additions": 1, "deletions": 1} + + +def run(name, conclusion="success", status="completed", id_=1, app=15368): + return {"name": name, "status": status, "conclusion": conclusion, "id": id_, "app": {"id": app}} + + +def ok_statuses(): + return [{"context": "risk-gate", "state": "success"}, {"context": "pr-template", "state": "success"}] + + +class EligibilityTest(unittest.TestCase): + def check(self, pr, files, commits=None): + return gate.eligibility(pr, files, commits or [commit()], REPO, "main", SHA) + + def test_dependency_minor_is_eligible(self): + self.assertEqual(self.check(pull(), [lock_file()]), (True, "dependencies")) + + def test_patch_requires_same_minor(self): + pr = pull(title="chore(deps): bump x from 1.2.3 to 1.3.0", body="update-type: version-update:semver-patch") + self.assertFalse(self.check(pr, [lock_file()])[0]) + pr = pull(title="chore(deps): bump x from 1.2.3 to 1.2.4", body="update-type: version-update:semver-patch") + self.assertTrue(self.check(pr, [lock_file()])[0]) + + def test_major_and_pre_one_are_refused(self): + self.assertFalse(self.check(pull(body="update-type: version-update:semver-major"), [lock_file()])[0]) + pr = pull(title="chore(deps): bump pyasn1 from 0.6.4 to 0.6.5", body="update-type: version-update:semver-patch") + self.assertFalse(self.check(pr, [lock_file()])[0]) + + def test_missing_update_type_is_refused(self): + self.assertFalse(self.check(pull(body=""), [lock_file()])[0]) + + def test_docker_and_protected_workflows_need_a_human(self): + self.assertFalse(self.check(pull(), [lock_file("Dockerfile")])[0]) + for name in ( + ".github/workflows/execution-report-heartbeat-protected.yml", + ".github/workflows/runtime-target-lifecycle.yml", + ".github/workflows/risk-gate.yml", + ".github/workflows/deploy-pilot-no-traffic.yml", + ): + pr = pull(body="update-type: version-update:semver-minor") + self.assertFalse(self.check(pr, [action_file(name)])[0], name) + + def test_pinned_action_bump_in_ci_is_eligible(self): + self.assertEqual(self.check(pull(), [action_file()]), (True, "actions")) + + def test_action_bump_with_extra_edit_is_refused(self): + file = action_file() + file["patch"] += "+ run: echo extra\n" + file["additions"] = 2 + self.assertFalse(self.check(pull(), [file])[0]) + + def test_action_bump_must_keep_the_same_action(self): + file = action_file() + file["patch"] = file["patch"].replace("+ - uses: actions/checkout", "+ - uses: actions/evil") + self.assertFalse(self.check(pull(), [file])[0]) + + def test_mixed_files_are_refused(self): + self.assertFalse(self.check(pull(changed_files=2), [lock_file(), action_file()])[0]) + + def test_untrusted_pull_requests_are_refused(self): + cases = [ + pull(draft=True), + pull(labels=[]), + pull(user={"login": "someone"}), + pull(head={"sha": SHA, "repo": {"full_name": "fork/ExamplePlatform"}}), + pull(base={"ref": "dev", "repo": {"full_name": REPO}}), + ] + for pr in cases: + self.assertFalse(self.check(pr, [lock_file()])[0]) + + def test_human_or_unsigned_commits_are_refused(self): + for bad in (commit(author="human"), commit(committer="human"), commit(verified=False)): + self.assertFalse(self.check(pull(), [lock_file()], [bad])[0]) + + def test_head_mismatch_and_incomplete_lists_are_refused(self): + self.assertFalse(gate.eligibility(pull(), [lock_file()], [commit()], REPO, "main", "d" * 40)[0]) + self.assertFalse(self.check(pull(commits=2), [lock_file()])[0]) + self.assertFalse(self.check(pull(changed_files=3), [lock_file()])[0]) + + def test_renamed_or_added_files_are_refused(self): + self.assertFalse(self.check(pull(), [{"filename": "uv.lock", "status": "added"}])[0]) + self.assertFalse(self.check(pull(), [{"filename": "uv.lock", "status": "modified", "previous_filename": "x"}])[0]) + + +class CheckStateTest(unittest.TestCase): + def state(self, runs, statuses=None): + return gate.check_state(runs, ok_statuses() if statuses is None else statuses, REQUIRED, STATUSES)[0] + + def test_all_green(self): + runs = [run("test"), run("Commit message convention"), run("CodeRabbit", "skipped")] + self.assertEqual(self.state(runs), "green") + + def test_non_required_red_blocks(self): + runs = [run("test"), run("Commit message convention", "failure"), run("extra", "cancelled")] + self.assertEqual(self.state(runs), "red") + self.assertEqual(self.state([run("test"), run("Commit message convention"), run("other", "timed_out")]), "red") + + def test_pending_and_missing_wait(self): + self.assertEqual(self.state([run("test"), run("Commit message convention", None, "in_progress")]), "pending") + self.assertEqual(self.state([run("test")]), "pending") + self.assertEqual(self.state([run("test"), run("Commit message convention")], []), "pending") + + def test_red_status_blocks(self): + bad = [{"context": "risk-gate", "state": "failure"}, {"context": "pr-template", "state": "success"}] + self.assertEqual(self.state([run("test"), run("Commit message convention")], bad), "red") + + def test_latest_run_wins_over_superseded_cancelled_run(self): + runs = [run("test"), run("Commit message convention"), run("Evaluate", "cancelled", id_=1), run("Evaluate", "success", id_=2)] + self.assertEqual(self.state(runs), "green") + runs[-1] = run("Evaluate", "failure", id_=3) + self.assertEqual(self.state(runs), "red") + + +if __name__ == "__main__": + unittest.main()