Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions allowlist-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 20 additions & 2 deletions allowlist-check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,39 @@ 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
steps:
- 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 }}
139 changes: 137 additions & 2 deletions allowlist-check/check_asf_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Exits with code 1 if any action ref is not allowlisted.
"""

import datetime
import fnmatch
import glob
import os
Expand All @@ -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.
Expand Down Expand Up @@ -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@<sha>`` -> 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.

Expand Down Expand Up @@ -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]} <allowlist_path>", file=sys.stderr)
if len(sys.argv) not in (2, 3):
print(
f"Usage: {sys.argv[0]} <allowlist_path> [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)
Expand All @@ -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:"
Expand Down
Loading
Loading