From 5be67f05cf018c47a5d46524896f265bf2040538 Mon Sep 17 00:00:00 2001 From: Mario Apra Date: Wed, 23 Sep 2026 11:55:24 +0100 Subject: [PATCH] feat(image-freshness): add decide.py rule engine adds image-freshness/decide.py, the pure decision function for the scheduled image freshness check (ANG-3523). it takes a facts json doc (repo visibility, HEAD, registry image, running digests, xray scan, last rebuild dispatch, previous run state) and returns ok / fail / rebuild with the rule number and reason. rules are evaluated in order, first match wins, and anything unknown or malformed fails closed instead of mapping to ok. stdlib only, no I/O beyond reading the facts file and printing, so every rule is unit tested (45 tests in image-freshness/tests). the adapters that actually gather the facts come later. adds a Makefile with `make test` (actionlint over .github/workflows plus the unit tests), so local hooks and CI can't drift, they both call this one target. new .github/workflows/ci.yaml runs it on push to main and on PRs, actions SHA-pinned, read-only token, no persisted credentials. ports the actionlint harness (tests/lint_workflows.sh, tests/actionlint-legacy.txt, tools/actionlint go tool module) from the closed ang-2720-deploy-gate-watchdog branch. legacy workflows that already had findings stay exempted by name, and that list can only shrink from here. also adds a dependabot gomod entry for /tools/actionlint so the actionlint pin gets updated same as the actions already are, and a one-liner in the README: run make test before pushing, CI runs the same target. Claude-Session: https://claude.ai/code/session_01FKzBJ35DrcARUiQCh9iTKM --- .github/dependabot.yml | 7 + .github/workflows/ci.yaml | 29 +++ .gitignore | 1 + Makefile | 8 + README.md | 2 + image-freshness/decide.py | 239 +++++++++++++++++++++++ image-freshness/tests/test_decide.py | 282 +++++++++++++++++++++++++++ tests/actionlint-legacy.txt | 15 ++ tests/lint_workflows.sh | 84 ++++++++ tools/actionlint/go.mod | 20 ++ tools/actionlint/go.sum | 27 +++ 11 files changed, 714 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 Makefile create mode 100755 image-freshness/decide.py create mode 100644 image-freshness/tests/test_decide.py create mode 100644 tests/actionlint-legacy.txt create mode 100755 tests/lint_workflows.sh create mode 100644 tools/actionlint/go.mod create mode 100644 tools/actionlint/go.sum diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d94dce0..f7da1c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,10 @@ updates: all-github-actions: patterns: - "*" + - package-ecosystem: gomod + directory: /tools/actionlint + schedule: + interval: weekly + commit-message: + prefix: "fix" + include: "scope" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..05f0b99 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: make test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: tools/actionlint/go.mod + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - run: make test diff --git a/.gitignore b/.gitignore index 8b2ff37..e846aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /docs/superpowers/plans/ +__pycache__/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d02290a --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: test lint unit +test: lint unit ## everything CI runs + +lint: + tests/lint_workflows.sh + +unit: + python3 -m unittest discover -s image-freshness/tests diff --git a/README.md b/README.md index 348188f..23c51c6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This repository contains reusable GitHub Actions workflows for Nethermind projects. +Run `make test` before pushing; CI runs the same target. + The workflows follow a simple name convention: - technology-action-flavor.yaml diff --git a/image-freshness/decide.py b/image-freshness/decide.py new file mode 100755 index 0000000..50ae896 --- /dev/null +++ b/image-freshness/decide.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Image freshness decision (ANG-3523). + +Pure function from a facts document to a decision. No I/O besides reading the +facts file and printing the decision, so every rule is unit-testable. Rules are +evaluated in order and the first match wins; nothing unknown maps to "ok". + +Usage: decide.py FACTS_JSON -> prints the decision JSON on stdout. +""" + +import hashlib +import json +import re +import sys +from datetime import datetime, timedelta, timezone + +MINUTE = timedelta(minutes=1) +HOUR = timedelta(hours=1) +DAY = timedelta(days=1) + +FUTURE_SKEW = 5 * MINUTE +BEHIND_MAIN_GRACE = HOUR +DEPLOY_GRACE = 30 * MINUTE +SCAN_GRACE = 2 * HOUR +DISPATCH_STUCK = HOUR +DISPATCH_FAILURE_WINDOW = DAY +REBUILD_RATE_LIMIT = 6 * HOUR + +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +SHA = re.compile(r"^[0-9a-f]{40}$") +DISPATCH_STATUSES = {"queued", "in_progress", "success", "failure", "cancelled"} + + +class FactError(Exception): + def __init__(self, fact, detail): + super().__init__(f"{fact}: {detail}") + self.fact = fact + self.detail = detail + + +def parse_time(fact, value): + if not isinstance(value, str) or not value.endswith("Z"): + raise FactError(fact, f"expected RFC3339 UTC timestamp, got {value!r}") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError as exc: + raise FactError(fact, f"expected RFC3339 UTC timestamp, got {value!r}") from exc + + +def typed(facts, name, allowed): + fact = facts.get(name) + if not isinstance(fact, dict) or "status" not in fact: + raise FactError(name, "missing") + status = fact["status"] + if status == "error": + raise FactError(name, str(fact.get("detail", "unspecified error"))) + if status not in allowed: + raise FactError(name, f"unexpected status {status!r}") + return status, fact.get("value") + + +def require(fact, value, key, pattern=None): + if not isinstance(value, dict) or key not in value: + raise FactError(fact, f"missing {key}") + item = value[key] + if pattern is not None and (not isinstance(item, str) or not pattern.match(item)): + raise FactError(fact, f"invalid {key}: {item!r}") + return item + + +def findings_hash(findings): + keys = sorted( + (f["issue_id"], f["component"], f.get("fixed_version") or "") + for f in findings + ) + return "sha256:" + hashlib.sha256(json.dumps(keys).encode()).hexdigest() + + +def decision(action, rule, reason, **extra): + out = {"action": action, "rule": rule, "reason": reason} + out.update(extra) + return out + + +def decide(facts): + event = facts.get("event") or {} + config = facts.get("config") or {} + default_branch = config.get("default_branch") + if event.get("name") != "schedule" or not default_branch or event.get("ref") != f"refs/heads/{default_branch}": + return decision("fail", 1, "refused: runs only on schedule from the default branch") + + max_age_days = config.get("max_age_days") + if not isinstance(max_age_days, int) or isinstance(max_age_days, bool) or not 1 <= max_age_days <= 7: + return decision("fail", 2, f"invalid max_age_days: {max_age_days!r}") + + try: + return evaluate(facts, default_branch, max_age_days) + except FactError as exc: + return decision("fail", 3, f"fact gathering failed: {exc.fact}: {exc.detail}") + + +def evaluate(facts, default_branch, max_age_days): + now = parse_time("now", facts.get("now")) + + _, visibility = typed(facts, "repo_visibility", {"ok"}) + if visibility not in {"public", "private", "internal"}: + raise FactError("repo_visibility", f"unexpected value {visibility!r}") + show_details = visibility in {"private", "internal"} + + _, head = typed(facts, "head", {"ok"}) + head_sha = require("head", head, "sha", SHA) + head_pushed = parse_time("head.pushed_at", require("head", head, "pushed_at")) + + # Every later fact is about the image, so a missing image is reported as + # itself rather than as whichever dependent lookup failed first. + image_status, image = typed(facts, "image", {"ok", "not_found"}) + if image_status == "not_found": + ref = facts["image"].get("detail") + if not isinstance(ref, str) or not ref: + raise FactError("image", "not_found without the image reference in detail") + return decision("fail", 4, f"image not found: {ref}") + + running_status, running = typed(facts, "running", {"ok", "disabled"}) + _, xray = typed(facts, "xray", {"ok"}) + dispatch_status, dispatch = typed(facts, "last_dispatch", {"ok", "none"}) + previous_status, previous = typed(facts, "previous", {"ok", "none"}) + + digest = require("image", image, "digest", DIGEST) + revision = require("image", image, "revision", SHA) + created = parse_time("image.created", require("image", image, "created")) + attested = require("image", image, "attested") + if not isinstance(attested, bool): + raise FactError("image", f"invalid attested: {attested!r}") + + running_digests = [] + if running_status == "ok": + running_digests = require("running", running, "digests") + if not isinstance(running_digests, list) or not running_digests or not all( + isinstance(d, str) and DIGEST.match(d) for d in running_digests + ): + raise FactError("running", f"invalid digests: {running_digests!r}") + + scan = require("xray", xray, "scan") + if scan not in {"done", "pending"}: + raise FactError("xray", f"invalid scan: {scan!r}") + findings = require("xray", xray, "findings") + if not isinstance(findings, list) or not all( + isinstance(f, dict) and isinstance(f.get("issue_id"), str) and isinstance(f.get("component"), str) + for f in findings + ): + raise FactError("xray", "invalid findings") + + last = None + if dispatch_status == "ok": + status = require("last_dispatch", dispatch, "status") + if status not in DISPATCH_STATUSES: + raise FactError("last_dispatch", f"invalid status: {status!r}") + if status != "cancelled": + last = { + "status": status, + "created": parse_time("last_dispatch.created_at", require("last_dispatch", dispatch, "created_at")), + "url": require("last_dispatch", dispatch, "url"), + } + + previous_hash = None + if previous_status == "ok": + previous_hash = (previous or {}).get("rebuild_findings_hash") + if previous_hash is not None and not isinstance(previous_hash, str): + raise FactError("previous", f"invalid rebuild_findings_hash: {previous_hash!r}") + + current_hash = findings_hash(findings) if findings else None + details = findings if show_details else [] + common = {"findings_count": len(findings), "details": details} + + def out(action, rule, reason, carry=True): + rebuild_hash = previous_hash if (carry and findings) else None + return decision(action, rule, reason, rebuild_findings_hash=rebuild_hash, **common) + + if not attested: + return out("fail", 5, f"provenance check failed for {digest}") + + for label, when in (("image.created", created), ("head.pushed_at", head_pushed)): + if when > now + FUTURE_SKEW: + return out("fail", 6, f"timestamp in the future: {label}") + + image_age = now - created + + if revision != head_sha and now - head_pushed >= BEHIND_MAIN_GRACE: + return out("fail", 7, f"image revision {revision[:7]} behind {default_branch} {head_sha[:7]}: build for HEAD failed or never ran") + + stale_running = sorted(d for d in running_digests if d != digest) + if stale_running and image_age >= DEPLOY_GRACE: + return out("fail", 8, f"deployed {stale_running[0]} != registry {digest}: image-updater or ArgoCD stalled") + + if scan == "pending": + if image_age < SCAN_GRACE: + return out("ok", 9, "scan pending") + return out("fail", 10, "image not scanned by Xray after 2h") + + if last and last["status"] in {"queued", "in_progress"}: + if now - last["created"] < DISPATCH_STUCK: + return out("ok", 11, "rebuild in progress") + return out("fail", 12, f"rebuild stuck: {last['url']}") + + if last and last["status"] == "failure" and now - last["created"] < DISPATCH_FAILURE_WINDOW: + return out("fail", 13, f"rebuild failed: {last['url']}") + + if findings: + if previous_hash is not None and previous_hash == current_hash: + return out("fail", 14, f"findings survived a rebuild: needs a code change or an upstream fix: {len(findings)}") + if last and last["status"] == "success" and now - last["created"] < REBUILD_RATE_LIMIT: + until = (last["created"] + REBUILD_RATE_LIMIT).strftime("%Y-%m-%dT%H:%M:%SZ") + return out("ok", 15, f"rebuild rate-limited until {until}") + return decision("rebuild", 16, f"fixable findings: {len(findings)}", rebuild_findings_hash=current_hash, **common) + + if image_age > timedelta(days=max_age_days): + return out("rebuild", 17, f"max age exceeded: {image_age.days}d", carry=False) + + return out("ok", 18, "fresh", carry=False) + + +def main(argv): + if len(argv) != 2: + print("usage: decide.py FACTS_JSON", file=sys.stderr) + return 2 + try: + with open(argv[1], encoding="utf-8") as handle: + facts = json.load(handle) + if not isinstance(facts, dict): + raise ValueError("top level is not an object") + result = decide(facts) + except (OSError, ValueError) as exc: + result = decision("fail", 3, f"fact gathering failed: input: {exc}") + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/image-freshness/tests/test_decide.py b/image-freshness/tests/test_decide.py new file mode 100644 index 0000000..a9bda5b --- /dev/null +++ b/image-freshness/tests/test_decide.py @@ -0,0 +1,282 @@ +import copy +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +import decide # noqa: E402 + +DIGEST = "sha256:" + "a" * 64 +OLD_DIGEST = "sha256:" + "b" * 64 +HEAD = "1" * 40 +OLD = "2" * 40 +NOW = "2026-09-23T12:00:00Z" +FINDING = {"issue_id": "XRAY-1", "component": "openssl", "fixed_version": "3.5.1"} + + +def facts(**overrides): + base = { + "now": NOW, + "config": {"default_branch": "main", "max_age_days": 7}, + "event": {"name": "schedule", "ref": "refs/heads/main"}, + "repo_visibility": {"status": "ok", "value": "internal"}, + "head": {"status": "ok", "value": {"sha": HEAD, "pushed_at": "2026-09-22T12:00:00Z"}}, + "image": {"status": "ok", "value": {"digest": DIGEST, "revision": HEAD, "created": "2026-09-22T12:10:00Z", "attested": True}}, + "running": {"status": "ok", "value": {"digests": [DIGEST]}}, + "xray": {"status": "ok", "value": {"scan": "done", "findings": []}}, + "last_dispatch": {"status": "none"}, + "previous": {"status": "none"}, + } + for key, value in overrides.items(): + base[key] = value + return base + + +def with_value(name, **fields): + f = facts() + f[name] = copy.deepcopy(f[name]) + f[name]["value"].update(fields) + return f + + +def run(f): + return decide.decide(f) + + +class RuleTests(unittest.TestCase): + def assertDecision(self, result, action, rule, reason): + self.assertEqual((result["action"], result["rule"], result["reason"]), (action, rule, reason)) + + # rule 1 + def test_refuses_push_event(self): + self.assertDecision(run(facts(event={"name": "push", "ref": "refs/heads/main"})), "fail", 1, "refused: runs only on schedule from the default branch") + + def test_refuses_other_branch(self): + self.assertDecision(run(facts(event={"name": "schedule", "ref": "refs/heads/feature"})), "fail", 1, "refused: runs only on schedule from the default branch") + + def test_refuses_workflow_dispatch(self): + self.assertDecision(run(facts(event={"name": "workflow_dispatch", "ref": "refs/heads/main"})), "fail", 1, "refused: runs only on schedule from the default branch") + + # rule 2 + def test_max_age_bounds(self): + for bad in (0, 8, -1, "7", True, None): + f = facts(config={"default_branch": "main", "max_age_days": bad}) + self.assertDecision(run(f), "fail", 2, f"invalid max_age_days: {bad!r}") + for good in (1, 7): + f = facts(config={"default_branch": "main", "max_age_days": good}) + self.assertEqual(run(f)["rule"], 18) + + # rule 3 + def test_error_fact_fails_closed(self): + f = facts(xray={"status": "error", "detail": "HTTP 500 from /xray/api/v1/violations"}) + self.assertDecision(run(f), "fail", 3, "fact gathering failed: xray: HTTP 500 from /xray/api/v1/violations") + + def test_missing_fact_fails_closed(self): + f = facts() + del f["previous"] + self.assertDecision(run(f), "fail", 3, "fact gathering failed: previous: missing") + + def test_unknown_status_fails_closed(self): + f = facts(running={"status": "maybe"}) + self.assertDecision(run(f), "fail", 3, "fact gathering failed: running: unexpected status 'maybe'") + + def test_bad_digest_fails_closed(self): + f = with_value("image", digest="latest") + self.assertDecision(run(f), "fail", 3, "fact gathering failed: image: invalid digest: 'latest'") + + def test_bad_timestamp_fails_closed(self): + f = with_value("image", created="2026-09-22 12:10:00") + self.assertDecision(run(f), "fail", 3, "fact gathering failed: image.created: expected RFC3339 UTC timestamp, got '2026-09-22 12:10:00'") + + def test_unknown_dispatch_status_fails_closed(self): + f = facts(last_dispatch={"status": "ok", "value": {"status": "timed_out", "created_at": NOW, "url": "u"}}) + self.assertDecision(run(f), "fail", 3, "fact gathering failed: last_dispatch: invalid status: 'timed_out'") + + def test_unknown_visibility_fails_closed(self): + f = facts(repo_visibility={"status": "ok", "value": "secret"}) + self.assertDecision(run(f), "fail", 3, "fact gathering failed: repo_visibility: unexpected value 'secret'") + + def test_empty_running_digests_fails_closed(self): + f = with_value("running", digests=[]) + self.assertDecision(run(f), "fail", 3, "fact gathering failed: running: invalid digests: []") + + # rule 4 + def test_image_not_found(self): + f = facts(image={"status": "not_found", "detail": "angkor-oci-local-prod/x:main"}, xray={"status": "error", "detail": "no digest"}) + self.assertDecision(run(f), "fail", 4, "image not found: angkor-oci-local-prod/x:main") + + # rule 5 + def test_unattested_image(self): + self.assertDecision(run(with_value("image", attested=False)), "fail", 5, f"provenance check failed for {DIGEST}") + + # rule 6 + def test_future_created(self): + self.assertDecision(run(with_value("image", created="2026-09-23T12:05:01Z")), "fail", 6, "timestamp in the future: image.created") + + def test_created_within_skew_is_allowed(self): + self.assertEqual(run(with_value("image", created="2026-09-23T12:05:00Z"))["rule"], 18) + + def test_future_push(self): + self.assertDecision(run(with_value("head", pushed_at="2026-09-24T00:00:00Z")), "fail", 6, "timestamp in the future: head.pushed_at") + + # rule 7 + def test_behind_main_after_grace(self): + f = with_value("image", revision=OLD) + f["head"]["value"]["pushed_at"] = "2026-09-23T11:00:00Z" + self.assertDecision(run(f), "fail", 7, "image revision 2222222 behind main 1111111: build for HEAD failed or never ran") + + def test_behind_main_within_grace(self): + f = with_value("image", revision=OLD) + f["head"]["value"]["pushed_at"] = "2026-09-23T11:00:01Z" + self.assertEqual(run(f)["rule"], 18) + + # rule 8 + def test_deploy_stalled(self): + f = with_value("running", digests=[OLD_DIGEST]) + self.assertDecision(run(f), "fail", 8, f"deployed {OLD_DIGEST} != registry {DIGEST}: image-updater or ArgoCD stalled") + + def test_deploy_within_grace(self): + f = with_value("running", digests=[OLD_DIGEST]) + f["image"]["value"]["created"] = "2026-09-23T11:30:01Z" + self.assertEqual(run(f)["rule"], 18) + + def test_running_disabled_skips_rule_8(self): + self.assertEqual(run(facts(running={"status": "disabled"}))["rule"], 18) + + # rules 9-10 + def test_scan_pending_within_grace(self): + f = with_value("xray", scan="pending") + f["image"]["value"]["created"] = "2026-09-23T10:00:01Z" + f["running"]["value"]["digests"] = [DIGEST] + self.assertDecision(run(f), "ok", 9, "scan pending") + + def test_scan_pending_too_long(self): + f = with_value("xray", scan="pending") + f["image"]["value"]["created"] = "2026-09-23T10:00:00Z" + self.assertDecision(run(f), "fail", 10, "image not scanned by Xray after 2h") + + # rules 11-13 + def dispatch(self, status, created_at): + return {"status": "ok", "value": {"status": status, "created_at": created_at, "url": "https://github.com/o/r/actions/runs/1"}} + + def test_rebuild_in_progress(self): + f = facts(last_dispatch=self.dispatch("queued", "2026-09-23T11:00:01Z")) + self.assertDecision(run(f), "ok", 11, "rebuild in progress") + + def test_rebuild_stuck(self): + f = facts(last_dispatch=self.dispatch("in_progress", "2026-09-23T11:00:00Z")) + self.assertDecision(run(f), "fail", 12, "rebuild stuck: https://github.com/o/r/actions/runs/1") + + def test_rebuild_failed_recently(self): + f = facts(last_dispatch=self.dispatch("failure", "2026-09-22T12:00:01Z")) + self.assertDecision(run(f), "fail", 13, "rebuild failed: https://github.com/o/r/actions/runs/1") + + def test_old_failure_is_ignored(self): + f = facts(last_dispatch=self.dispatch("failure", "2026-09-22T12:00:00Z")) + self.assertEqual(run(f)["rule"], 18) + + def test_cancelled_dispatch_counts_as_none(self): + f = facts(last_dispatch=self.dispatch("cancelled", "2026-09-23T11:59:00Z")) + self.assertEqual(run(f)["rule"], 18) + + # rules 14-16 + def test_findings_trigger_rebuild(self): + result = run(with_value("xray", findings=[FINDING])) + self.assertDecision(result, "rebuild", 16, "fixable findings: 1") + self.assertEqual(result["rebuild_findings_hash"], decide.findings_hash([FINDING])) + + def test_same_findings_after_rebuild_fail(self): + f = with_value("xray", findings=[FINDING]) + f["previous"] = {"status": "ok", "value": {"rebuild_findings_hash": decide.findings_hash([FINDING])}} + result = run(f) + self.assertDecision(result, "fail", 14, "findings survived a rebuild: needs a code change or an upstream fix: 1") + self.assertEqual(result["rebuild_findings_hash"], decide.findings_hash([FINDING])) + + def test_new_findings_after_rebuild_rebuild_again(self): + other = dict(FINDING, issue_id="XRAY-2") + f = with_value("xray", findings=[FINDING, other]) + f["previous"] = {"status": "ok", "value": {"rebuild_findings_hash": decide.findings_hash([FINDING])}} + self.assertEqual(run(f)["rule"], 16) + + def test_findings_hash_ignores_order(self): + other = dict(FINDING, issue_id="XRAY-2") + self.assertEqual(decide.findings_hash([FINDING, other]), decide.findings_hash([other, FINDING])) + + def test_rate_limited_after_recent_rebuild(self): + f = with_value("xray", findings=[FINDING]) + f["last_dispatch"] = self.dispatch("success", "2026-09-23T06:00:01Z") + f["previous"] = {"status": "ok", "value": {"rebuild_findings_hash": None}} + self.assertDecision(run(f), "ok", 15, "rebuild rate-limited until 2026-09-23T12:00:01Z") + + def test_rate_limit_expires(self): + f = with_value("xray", findings=[FINDING]) + f["last_dispatch"] = self.dispatch("success", "2026-09-23T06:00:00Z") + self.assertEqual(run(f)["rule"], 16) + + def test_hash_carried_while_waiting(self): + f = with_value("xray", findings=[FINDING]) + f["last_dispatch"] = self.dispatch("in_progress", "2026-09-23T11:30:00Z") + f["previous"] = {"status": "ok", "value": {"rebuild_findings_hash": "sha256:prev"}} + result = run(f) + self.assertEqual((result["rule"], result["rebuild_findings_hash"]), (11, "sha256:prev")) + + def test_hash_reset_when_clean(self): + f = facts(previous={"status": "ok", "value": {"rebuild_findings_hash": "sha256:prev"}}) + self.assertIsNone(run(f)["rebuild_findings_hash"]) + + # rules 17-18 + def test_max_age_rebuild(self): + f = with_value("image", created="2026-09-16T11:59:59Z") + f["head"]["value"]["pushed_at"] = "2026-09-16T11:00:00Z" + self.assertDecision(run(f), "rebuild", 17, "max age exceeded: 7d") + + def test_exactly_max_age_is_fresh(self): + f = with_value("image", created="2026-09-16T12:00:00Z") + f["head"]["value"]["pushed_at"] = "2026-09-16T11:00:00Z" + self.assertDecision(run(f), "ok", 18, "fresh") + + def test_first_run_fresh(self): + self.assertDecision(run(facts()), "ok", 18, "fresh") + + # details visibility + def test_details_hidden_for_public_repos(self): + f = with_value("xray", findings=[FINDING]) + f["repo_visibility"] = {"status": "ok", "value": "public"} + result = run(f) + self.assertEqual((result["findings_count"], result["details"]), (1, [])) + + def test_details_shown_for_internal_repos(self): + self.assertEqual(run(with_value("xray", findings=[FINDING]))["details"], [FINDING]) + + +class CliTests(unittest.TestCase): + def cli(self, content): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + handle.write(content) + try: + proc = subprocess.run([sys.executable, os.path.join(HERE, "..", "decide.py"), handle.name], capture_output=True, text=True, check=False) + finally: + os.unlink(handle.name) + return proc.returncode, json.loads(proc.stdout) + + def test_cli_prints_decision(self): + code, out = self.cli(json.dumps(facts())) + self.assertEqual((code, out["action"], out["rule"]), (0, "ok", 18)) + + def test_cli_malformed_json_fails_closed(self): + code, out = self.cli("{not json") + self.assertEqual((code, out["action"], out["rule"]), (0, "fail", 3)) + self.assertTrue(out["reason"].startswith("fact gathering failed: input: ")) + + def test_cli_non_object_fails_closed(self): + code, out = self.cli("[]") + self.assertEqual(out["reason"], "fact gathering failed: input: top level is not an object") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/actionlint-legacy.txt b/tests/actionlint-legacy.txt new file mode 100644 index 0000000..8fd7376 --- /dev/null +++ b/tests/actionlint-legacy.txt @@ -0,0 +1,15 @@ +# Workflows that already had actionlint findings when linting was introduced, so that +# the gate could be turned on for everything else without a mass edit of files this +# change has no business touching. +# +# This list may only shrink. tests/lint_workflows.sh fails if a file named here now +# lints clean, which forces the entry out in the same commit that fixes the file, and it +# fails if a name here no longer exists, so a rename cannot quietly widen the exemption. +# +# Everything outstanding is shellcheck info/style (SC2086 word splitting, SC2004, SC2129) +# inside steps that interpolate no untrusted input. +.github/workflows/compute-terraform-module-name.yaml +.github/workflows/docker-build-push-jfrog.yaml +.github/workflows/docker-promote-dockerhub.yaml +.github/workflows/docker-promote-jfrog.yaml +.github/workflows/publish-terraform-module.yaml diff --git a/tests/lint_workflows.sh b/tests/lint_workflows.sh new file mode 100755 index 0000000..0c0e65c --- /dev/null +++ b/tests/lint_workflows.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# actionlint over the workflows in this repository. +# +# actionlint's value here is not style. It resolves ${{ }} expressions against the real +# context schema, so a typo in an input name is an error rather than an empty string at +# 03:00, and it runs shellcheck over every run: block, which is where a reusable workflow +# actually executes other people's data. +# +# The linter arrived after the workflows did, and five of them already had findings. +# Gating on a clean repository would have meant either leaving the linter off or editing +# five unrelated workflows in a change about something else. Instead they are exempted by +# name in tests/actionlint-legacy.txt, and that list can only shrink: a file listed there +# that now lints clean fails this script, so the exemption is removed by the commit that +# earns it rather than outliving the problem. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LEGACY_LIST="${ROOT}/tests/actionlint-legacy.txt" + +# Built once rather than invoked through `go run` per file: six `go run` calls is six +# link steps, and `go -C` would also leave every reported path relative to the tool's own +# module directory, which makes the findings harder to click than they need to be. +ACTIONLINT="$(mktemp -d)/actionlint" +go -C "${ROOT}/tools/actionlint" build -o "${ACTIONLINT}" github.com/rhysd/actionlint/cmd/actionlint + +actionlint() { + ( cd "${ROOT}" && "${ACTIONLINT}" -no-color -oneline "$@" ) +} + +is_legacy() { + local candidate="$1" entry + while IFS= read -r entry; do + [[ "${entry}" == "${candidate}" ]] && return 0 + done < <(legacy_entries) + return 1 +} + +legacy_entries() { + sed -e 's/#.*//' -e 's/[[:space:]]*$//' "${LEGACY_LIST}" | grep -v '^$' || true +} + +status=0 + +# Every entry must still name a real file, or a rename silently widens the exemption. +while IFS= read -r entry; do + if [[ ! -f "${ROOT}/${entry}" ]]; then + echo "::error::${LEGACY_LIST} lists ${entry}, which does not exist. Remove it." + status=1 + fi +done < <(legacy_entries) + +gated=() +while IFS= read -r workflow; do + relative="${workflow#"${ROOT}/"}" + if is_legacy "${relative}"; then + continue + fi + gated+=("${relative}") +done < <(find "${ROOT}/.github/workflows" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) | sort) + +if (( ${#gated[@]} == 0 )); then + echo "::error::no workflows left to lint, which means the exemption list swallowed all of them" + exit 1 +fi + +echo "linting ${#gated[@]} workflow(s)" +if ! actionlint "${gated[@]}"; then + status=1 +fi + +# The ratchet. A legacy file that now passes must leave the list. +while IFS= read -r entry; do + [[ -f "${ROOT}/${entry}" ]] || continue + if actionlint "${entry}" >/dev/null 2>&1; then + echo "::error::${entry} now lints clean. Remove it from ${LEGACY_LIST}." + status=1 + fi +done < <(legacy_entries) + +if (( status == 0 )); then + echo "workflows lint clean" +fi +exit "${status}" diff --git a/tools/actionlint/go.mod b/tools/actionlint/go.mod new file mode 100644 index 0000000..87f691f --- /dev/null +++ b/tools/actionlint/go.mod @@ -0,0 +1,20 @@ +module github.com/NethermindEth/github-workflows/tools/actionlint + +go 1.27.1 + +tool github.com/rhysd/actionlint/cmd/actionlint + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/rhysd/actionlint v1.7.12 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/tools/actionlint/go.sum b/tools/actionlint/go.sum new file mode 100644 index 0000000..ddd65b3 --- /dev/null +++ b/tools/actionlint/go.sum @@ -0,0 +1,27 @@ +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY= +github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=