From 595d686ff54117ff3c056679797bea17a83fb4d6 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 13 Jul 2026 03:22:06 +0200 Subject: [PATCH] allowlist-check: warn about upcoming action expirations (closes #987) Approved action versions are given an expires_at grace period (typically ~3 months) when a newer version lands, and are eventually removed from the allowlist -- at which point workflows still pinning the old SHA start failing the allowlist check. Projects previously had no advance notice. allowlist-check now also fetches actions.yml (which carries the expires_at metadata that approved_patterns.yml lacks) and emits a non-failing ::warning:: for any allowlisted pin in the caller's workflows that expires within 'expiry-warning-days' (new input, default 30). Warnings never change the exit code; they surface in the job log / PR annotations so projects can bump the pin before it breaks. - check_asf_allowlist.py: load_expiry_map() + upcoming_expiry_warnings() pure helpers, emit_expiry_warnings(), and an optional second CLI arg for actions.yml (1-arg invocation stays backward-compatible). - action.yml: fetch actions.yml (best-effort) and pass EXPIRY_WARNING_DAYS. - Tests for the new logic (48 pass) and README documentation. Generated-by: Claude Opus 4.8 (1M context) via Claude Code --- allowlist-check/README.md | 29 ++++ allowlist-check/action.yml | 22 ++- allowlist-check/check_asf_allowlist.py | 139 +++++++++++++++- allowlist-check/test_check_asf_allowlist.py | 171 ++++++++++++++++++++ 4 files changed, 357 insertions(+), 4 deletions(-) diff --git a/allowlist-check/README.md b/allowlist-check/README.md index 20a715a91..6ec2db07b 100644 --- a/allowlist-check/README.md +++ b/allowlist-check/README.md @@ -63,6 +63,7 @@ That's it — two steps. The `actions/checkout` step checks out your repo so `.g | Input | Required | Default | Description | |---|---|---|---| | `scan-glob` | No | `.github/**/*.yml` | Glob pattern for YAML files to scan for action refs | +| `expiry-warning-days` | No | `30` | Emit a non-failing warning when a workflow pins an allowlisted action whose approved version expires within this many days. Set to `0` to warn only once a pin has actually expired. | ### Custom scan glob @@ -110,6 +111,34 @@ When all refs pass: All 15 unique action refs are on the ASF allowlist ``` +### Expiry warnings + +Approved *versions* of an action don't live forever: when a newer version is +approved, older pinned SHAs are given an `expires_at` date (a grace period, +typically three months) and are eventually removed from the allowlist. Once a +pin is removed, workflows still using it start failing the allowlist check. + +To give projects advance notice, the action also fetches +[`actions.yml`](../actions.yml) (which carries the `expires_at` metadata) and +emits a **non-failing** `::warning::` for any allowlisted pin in your workflows +that expires within `expiry-warning-days` (default 30): + +``` +1 allowlisted ref(s) expiring within 30 day(s) -- bump these to a newer approved version before they are removed: +::warning file=.github/workflows/ci.yml::some-org/some-action@abc123 is allowlisted but its approved pin expires on 2026-07-25 (in 12 day(s)); bump to a newer approved version to avoid a future CI failure. +``` + +Warnings never change the exit code — they surface in the job log and the PR +"Files changed" annotations so you can bump the pin before it breaks. This is +best-effort: if `actions.yml` can't be fetched, the check still runs, just +without expiry warnings. + +```yaml +- uses: apache/infrastructure-actions/allowlist-check@main + with: + expiry-warning-days: "60" # start warning two months ahead +``` + ## Dependencies - Python 3 (pre-installed on GitHub-hosted runners) diff --git a/allowlist-check/action.yml b/allowlist-check/action.yml index 1b82f6421..36377ea24 100644 --- a/allowlist-check/action.yml +++ b/allowlist-check/action.yml @@ -29,6 +29,13 @@ inputs: description: "Glob pattern for YAML files to scan for action refs" required: false default: ".github/**/*.yml" + expiry-warning-days: + description: > + Emit a (non-failing) warning when a workflow pins an allowlisted action + whose approved version expires within this many days. Set to 0 to warn + only once a pin has actually expired. + required: false + default: "30" runs: using: composite @@ -36,14 +43,25 @@ runs: - name: Install ruyaml shell: bash run: pip install ruyaml - - name: Fetch latest approved_patterns.yml from main + - name: Fetch latest approved_patterns.yml and actions.yml from main shell: bash run: | curl -sSfL \ "https://raw.githubusercontent.com/apache/infrastructure-actions/main/approved_patterns.yml" \ -o "${{ runner.temp }}/approved_patterns.yml" + # actions.yml carries the expires_at metadata used for expiry warnings. + # Best-effort: if it can't be fetched, the check still runs, just + # without expiry warnings. + curl -sSfL \ + "https://raw.githubusercontent.com/apache/infrastructure-actions/main/actions.yml" \ + -o "${{ runner.temp }}/actions.yml" \ + || echo "::notice::Could not fetch actions.yml; expiry warnings disabled for this run." - name: Verify all action refs are allowlisted shell: bash - run: python3 "${{ github.action_path }}/check_asf_allowlist.py" "${{ runner.temp }}/approved_patterns.yml" + run: | + python3 "${{ github.action_path }}/check_asf_allowlist.py" \ + "${{ runner.temp }}/approved_patterns.yml" \ + "${{ runner.temp }}/actions.yml" env: GITHUB_YAML_GLOB: ${{ inputs.scan-glob }} + EXPIRY_WARNING_DAYS: ${{ inputs.expiry-warning-days }} diff --git a/allowlist-check/check_asf_allowlist.py b/allowlist-check/check_asf_allowlist.py index c285ee24e..57c709325 100644 --- a/allowlist-check/check_asf_allowlist.py +++ b/allowlist-check/check_asf_allowlist.py @@ -31,6 +31,7 @@ Exits with code 1 if any action ref is not allowlisted. """ +import datetime import fnmatch import glob import os @@ -55,6 +56,9 @@ # YAML key that references a GitHub Action USES_KEY = "uses" +# How many days before an allowlisted pin's expiry to start warning about it. +DEFAULT_EXPIRY_WARNING_DAYS = 30 + def find_action_refs(node: Any) -> Generator[str, None, None]: """Recursively find all `uses:` values from a parsed YAML tree. @@ -151,6 +155,85 @@ def is_allowed(action_ref: str, allowlist: list[str]) -> bool: return any(fnmatch.fnmatch(action_ref, pattern) for pattern in allowlist) +def load_expiry_map(actions_path: str) -> dict[str, datetime.date]: + """Map each exactly-pinned, expiring allowlist ref to its expiry date. + + Parses ``actions.yml`` (the source of truth, which -- unlike + ``approved_patterns.yml`` -- carries ``expires_at`` metadata) and returns a + mapping of ``owner/action@`` -> expiry date for every ref that has an + ``expires_at``. Wildcard / ``keep`` entries never expire and are omitted. + + Returns an empty dict if the file is missing or unparseable, so expiry + warnings are strictly best-effort and never break the check. + + Args: + actions_path: Path to the ``actions.yml`` source file. + + Returns: + dict: Mapping of ``owner/action@ref`` to its expiry ``datetime.date``. + """ + try: + yaml = ruyaml.YAML() + with open(actions_path) as f: + actions = yaml.load(f) + except (OSError, ruyaml.YAMLError): + return {} + if not actions: + return {} + + expiry: dict[str, datetime.date] = {} + for name, refs in actions.items(): + if not isinstance(refs, dict): + continue + for ref, details in refs.items(): + if not isinstance(details, dict): + continue + when = details.get("expires_at") + if isinstance(when, datetime.date): + expiry[f"{name}@{ref}"] = when + elif isinstance(when, str): + try: + expiry[f"{name}@{ref}"] = datetime.date.fromisoformat(when) + except ValueError: + continue + return expiry + + +def upcoming_expiry_warnings( + action_refs: dict[str, list[str]], + expiry_map: dict[str, datetime.date], + warning_days: int, + today: datetime.date, +) -> list[tuple[str, str, datetime.date, int]]: + """Find caller refs whose allowlisted pin expires within ``warning_days``. + + Only refs that exactly match an expiring ``actions.yml`` entry are + considered (a ref allowed via a ``@*`` wildcard has no expiry). + + Args: + action_refs: Mapping of action ref -> files that use it. + expiry_map: Output of :func:`load_expiry_map`. + warning_days: Warn when a pin expires within this many days. + today: Reference date for the countdown (injected for testability). + + Returns: + list: ``(filepath, action_ref, expiry_date, days_left)`` tuples, one per + (ref, file) pair, soonest expiry first. ``days_left`` is negative for a + pin that is already past its expiry but still listed. + """ + warnings: list[tuple[str, str, datetime.date, int]] = [] + for ref, filepaths in action_refs.items(): + expiry_date = expiry_map.get(ref) + if expiry_date is None: + continue + days_left = (expiry_date - today).days + if days_left <= warning_days: + for filepath in filepaths: + warnings.append((filepath, ref, expiry_date, days_left)) + warnings.sort(key=lambda w: (w[3], w[1], w[0])) + return warnings + + def build_gh_pr_command(action_name: str, refs: list[str], repo_name: str) -> str: """Build a shell command that creates a PR adding one action to the allowlist. @@ -199,12 +282,59 @@ def build_gh_pr_command(action_name: str, refs: list[str], repo_name: str) -> st ) +def emit_expiry_warnings( + action_refs: dict[str, list[str]], actions_path: str +) -> None: + """Print GitHub ``::warning::`` annotations for soon-to-expire pins. + + Best-effort and never fatal: a missing/unparseable ``actions.yml`` or a bad + ``EXPIRY_WARNING_DAYS`` value simply yields no warnings. + """ + try: + warning_days = int( + os.environ.get("EXPIRY_WARNING_DAYS", "") or DEFAULT_EXPIRY_WARNING_DAYS + ) + except ValueError: + warning_days = DEFAULT_EXPIRY_WARNING_DAYS + + expiry_map = load_expiry_map(actions_path) + warnings = upcoming_expiry_warnings( + action_refs, expiry_map, warning_days, datetime.date.today() + ) + if not warnings: + return + + print( + f"\n{len(warnings)} allowlisted ref(s) expiring within {warning_days} day(s) " + "-- bump these to a newer approved version before they are removed:" + ) + for filepath, ref, expiry_date, days_left in warnings: + if days_left < 0: + detail = ( + f"its approved pin EXPIRED on {expiry_date.isoformat()} " + f"({-days_left} day(s) ago)" + ) + else: + detail = ( + f"its approved pin expires on {expiry_date.isoformat()} " + f"(in {days_left} day(s))" + ) + print( + f"::warning file={filepath}::{ref} is allowlisted but {detail}; " + "bump to a newer approved version to avoid a future CI failure." + ) + + def main(): - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + if len(sys.argv) not in (2, 3): + print( + f"Usage: {sys.argv[0]} [actions_yml_path]", + file=sys.stderr, + ) sys.exit(2) allowlist_path = sys.argv[1] + actions_path = sys.argv[2] if len(sys.argv) == 3 else None allowlist = load_allowlist(allowlist_path) scan_glob = os.environ.get("GITHUB_YAML_GLOB", DEFAULT_GITHUB_YAML_GLOB) action_refs = collect_action_refs(scan_glob) @@ -227,6 +357,11 @@ def main(): for filepath in filepaths: violations.append((filepath, action_ref)) + # Best-effort expiry warnings (never fail the build); shown whether or not + # there are hard violations, so projects get advance notice to bump pins. + if actions_path: + emit_expiry_warnings(action_refs, actions_path) + if violations: print( f"::error::Found {len(violations)} action ref(s) not on the ASF allowlist:" diff --git a/allowlist-check/test_check_asf_allowlist.py b/allowlist-check/test_check_asf_allowlist.py index efaa589f6..847d36da4 100644 --- a/allowlist-check/test_check_asf_allowlist.py +++ b/allowlist-check/test_check_asf_allowlist.py @@ -24,13 +24,17 @@ import unittest from unittest.mock import patch +import datetime + from check_asf_allowlist import ( build_gh_pr_command, collect_action_refs, find_action_refs, is_allowed, load_allowlist, + load_expiry_map, main, + upcoming_expiry_warnings, ) from insert_actions import insert_actions @@ -471,5 +475,172 @@ def test_main_prints_verbose_check_output(self): self.assertIn("Checking 2 unique action ref(s)", output) +class TestLoadExpiryMap(unittest.TestCase): + """Tests for parsing expires_at metadata out of actions.yml.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.actions_yml = os.path.join(self.tmpdir, "actions.yml") + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def _write(self, text): + with open(self.actions_yml, "w") as f: + f.write(textwrap.dedent(text)) + + def test_extracts_only_expiring_exact_refs(self): + self._write( + """\ + org/a: + '1111': + tag: v1 + expires_at: 2026-08-16 + '2222': + keep: true + org/wild: + '*': + keep: true + org/empty: + '3333': {} + """ + ) + result = load_expiry_map(self.actions_yml) + self.assertEqual(result, {"org/a@1111": datetime.date(2026, 8, 16)}) + + def test_string_dates_are_parsed(self): + # ruyaml normally yields datetime.date, but a quoted value stays a str. + self._write( + """\ + org/a: + '1111': + expires_at: "2026-09-18" + """ + ) + self.assertEqual( + load_expiry_map(self.actions_yml), + {"org/a@1111": datetime.date(2026, 9, 18)}, + ) + + def test_missing_file_returns_empty(self): + self.assertEqual(load_expiry_map("/no/such/actions.yml"), {}) + + def test_empty_file_returns_empty(self): + self._write("") + self.assertEqual(load_expiry_map(self.actions_yml), {}) + + +class TestUpcomingExpiryWarnings(unittest.TestCase): + """Tests for the pure expiry-window selection logic.""" + + TODAY = datetime.date(2026, 7, 13) + + def _refs(self): + return { + "org/soon@aaa": ["a.yml"], + "org/later@bbb": ["b.yml"], + "org/nope@ccc": ["c.yml"], # not in expiry_map + } + + def _map(self): + return { + "org/soon@aaa": datetime.date(2026, 7, 20), # 7 days away + "org/later@bbb": datetime.date(2026, 12, 1), # far off + } + + def test_only_within_window(self): + result = upcoming_expiry_warnings(self._refs(), self._map(), 30, self.TODAY) + self.assertEqual(len(result), 1) + filepath, ref, expiry, days = result[0] + self.assertEqual((filepath, ref, days), ("a.yml", "org/soon@aaa", 7)) + + def test_wider_window_includes_more(self): + result = upcoming_expiry_warnings(self._refs(), self._map(), 365, self.TODAY) + self.assertEqual({r[1] for r in result}, {"org/soon@aaa", "org/later@bbb"}) + + def test_already_expired_has_negative_days(self): + refs = {"org/x@aaa": ["w.yml"]} + emap = {"org/x@aaa": datetime.date(2026, 7, 10)} # 3 days ago + result = upcoming_expiry_warnings(refs, emap, 30, self.TODAY) + self.assertEqual(result[0][3], -3) + + def test_sorted_soonest_first(self): + refs = {"org/b@2": ["b"], "org/a@1": ["a"]} + emap = { + "org/b@2": datetime.date(2026, 7, 15), # 2 days + "org/a@1": datetime.date(2026, 7, 14), # 1 day + } + result = upcoming_expiry_warnings(refs, emap, 30, self.TODAY) + self.assertEqual([r[1] for r in result], ["org/a@1", "org/b@2"]) + + def test_no_expiry_data_no_warnings(self): + self.assertEqual( + upcoming_expiry_warnings(self._refs(), {}, 30, self.TODAY), [] + ) + + +class TestMainExpiryWarnings(unittest.TestCase): + """Integration: main() emits ::warning:: annotations for expiring pins.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.github_dir = os.path.join(self.tmpdir, ".github", "workflows") + os.makedirs(self.github_dir) + self.sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + with open(os.path.join(self.github_dir, "ci.yml"), "w") as f: + f.write( + textwrap.dedent( + f"""\ + name: CI + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: some-org/some-action@{self.sha} + """ + ) + ) + # allowlist contains the pin, so it is NOT a violation. + self.allowlist_path = os.path.join(self.tmpdir, "allowlist.yml") + with open(self.allowlist_path, "w") as f: + f.write(f"- some-org/some-action@{self.sha}\n") + + self.actions_path = os.path.join(self.tmpdir, "actions.yml") + soon = (datetime.date.today() + datetime.timedelta(days=10)).isoformat() + with open(self.actions_path, "w") as f: + f.write(f"some-org/some-action:\n '{self.sha}':\n expires_at: {soon}\n") + + self.scan_glob = os.path.join(self.tmpdir, ".github/**/*.yml") + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def _run(self): + with ( + patch.dict(os.environ, {"GITHUB_YAML_GLOB": self.scan_glob}), + patch( + "sys.argv", + ["check_asf_allowlist.py", self.allowlist_path, self.actions_path], + ), + patch("sys.stdout") as mock_stdout, + ): + # No violations -> main() returns without SystemExit. + main() + return "".join(c.args[0] for c in mock_stdout.write.call_args_list) + + def test_warns_on_upcoming_expiry(self): + output = self._run() + self.assertIn("::warning", output) + self.assertIn(f"some-org/some-action@{self.sha}", output) + self.assertIn("expires on", output) + + def test_no_warning_outside_window(self): + with patch.dict(os.environ, {"EXPIRY_WARNING_DAYS": "3"}): + output = self._run() + self.assertNotIn("::warning", output) + + if __name__ == "__main__": unittest.main()