From d7b89bd13c85a72692d094df7186559bb0880a60 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 12 Feb 2026 13:41:49 +0100 Subject: [PATCH 1/3] Workflow to check any ASF project's action usage This change adds a GitHub workflow to be called from ASF projects' CI to verify that the referenced actions are approved by ASF Infrastructure. Fixes #482 --- .github/workflows/check-project-actions.yml | 62 ++++++++ README.md | 42 ++++++ gateway/check_repository_actions.py | 154 ++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 .github/workflows/check-project-actions.yml create mode 100644 gateway/check_repository_actions.py diff --git a/.github/workflows/check-project-actions.yml b/.github/workflows/check-project-actions.yml new file mode 100644 index 00000000..ec94faf3 --- /dev/null +++ b/.github/workflows/check-project-actions.yml @@ -0,0 +1,62 @@ +# Workflow to be called from ASF project repository workflows to check +# whether the referenced GitHub actions are approved. +# +# The README.md of ASF Infrastructure Actions repository https://github.com/apache/infrastructure-actions +# contains usage instructions. +# +# See: ASF Infrastructure GitHub Actions Policy: https://infra.apache.org/github-actions-policy.html + + +name: check-project-actions.yml +on: + workflow_call: + inputs: + repository: + required: false + description: The `repository` parameter for `actions/checkout` + type: string + ref: + required: false + description: The `ref` parameter for `actions/checkout` + type: string + fetch-depth: + required: false + description: The `fetch-depth` parameter for `actions/checkout` + type: number + default: 1 + submodules: + required: false + description: The `submodules` parameter for `actions/checkout` + type: boolean + default: false + +jobs: + check-project-actions: + runs-on: ubuntu-latest + steps: + - name: "Checkout apache/infrastructure-actions" + uses: actions/checkout@v2 + with: + repository: 'apache/infrastructure-actions' + ref: 'main' + path: infrastructure-actions + - name: "Checkout repository to be checked" + uses: actions/checkout@v2 + with: + repository: '${{ inputs.repository }}' + ref: ${{ inputs.ref }} + fetch-depth: ${{ inputs.fetch-depth }} + submodules: ${{ inputs.submodules }} + path: repository + + - run: pip install ruyaml + + - name: Check project actions in repository + working-directory: infrastructure-actions + shell: python + run: | + import sys + sys.path.append("./gateway/") + + import check_repository_actions as c + c.check_project_actions('../repository', '../infrastructure-actions/approved_patterns.yml') diff --git a/README.md b/README.md index b7f95247..3eaa19ee 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ This repository hosts GitHub Actions developed by the ASF community and approved for any ASF top level project to use. It also manages the organization wide allow list of GitHub Actions via 'Configuration as Code'. +- [Checking the Action Usage in an ASF Project](#checking-the-action-usage-in-an-asf-project) - [Submitting an Action](#submitting-an-action) - [Available GitHub Actions](#available-github-actions) - [Organization-wide GitHub Actions Allow List](#management-of-organization-wide-github-actions-allow-list) @@ -11,6 +12,47 @@ This repository hosts GitHub Actions developed by the ASF community and approved - [Manual Version Addition](#manual-addition-of-specific-versions) - [Removing a Version](#removing-a-version-manually) +## Checking the Action Usage in an ASF Project + +You can let your CI workflows check if the Actions used in your project are approved for use in the ASF. + +Either create a new workflow in your project repository, e.g. `.github/workflows/check-project-actions.yml`, +like the following example, or call the workflow from a job from your existing CI workflow in your repository. + +```yaml + name: Check action references + on: + workflow_dispatch: + push: + branches: + - main + paths: + - ".github/**" + pull_request: + paths: + - ".github/**" + jobs: + # This is the job that verifies your project's usage of approved GitHub actions + check: + name: Check actions usage + uses: apache/infrastructure-actions/.github/workflows/check-project-actions.yml@main +``` + +When calling the `check-project-actions` from a `push` or `pull_request` event, the workflow should work +automatically against the "right" reference. + +You can also pass the `repository`, `ref`, `fetch-depth` and `submodules` parameters, as documented for +the the [GitHub `actions/checkout` action](https://github.com/actions/checkout?tab=readme-ov-file#usage) +to the workflow call to check against a specific commit or tag. For example: +```yaml + check: + name: Check actions usage + uses: apache/infrastructure-actions/.github/workflows/check-project-actions.yml@main + with: + repository: apacha/my-project + ref: my-branch +``` + ## Submitting an Action To contribute a GitHub Action to this repository: diff --git a/gateway/check_repository_actions.py b/gateway/check_repository_actions.py new file mode 100644 index 00000000..a0e6837d --- /dev/null +++ b/gateway/check_repository_actions.py @@ -0,0 +1,154 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "ruyaml", +# ] +# /// + +import fnmatch +import os +import re +import sys + +from pathlib import Path + +from gateway import load_yaml, on_gha + +re_action = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?(@(.+))?$" +re_local_file = r"^[.]/.+" + + +def _iter_uses_nodes(node: dict, yaml_path: str = ""): + """ + Walk the entire YAML structure (dicts/lists/scalars) and yield every value + whose key is exactly 'uses', along with a best-effort YAML-path string. + """ + if isinstance(node, dict): + for k, v in node.items(): + next_path = f"{"" if len(yaml_path) == 0 else f"{yaml_path}."}{k}" + if k == "uses": + yield next_path, v + yield from _iter_uses_nodes(v, next_path) + elif isinstance(node, list): + for i, item in enumerate(node): + next_path = f"{yaml_path}[{i}]" + yield from _iter_uses_nodes(item, next_path) + else: + return + + +def check_project_actions(repository: str | os.PathLike, approved_patterns_file: str | os.PathLike) -> None: + """ + Check that all GitHub actions used in workflows and actions are approved. + + See GitHub documentation https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise + + @param repository: Path to the repository root directory to check. YAML files under '.github/workflows' and '.github/actions' will be checked. + @param approved_patterns_file: Path to the YAML file containing approved action patterns. + """ + repo_root = Path(repository) + if not repo_root.exists(): + raise FileNotFoundError(f"Repository path does not exist: {repo_root}") + + # Only consider workflows under '.github/workflows' (the only directory mentioned). + github_dir = repo_root / ".github" + if not github_dir.is_dir(): + print(f"No directory found at: {github_dir}") + return + + yaml_files: list[Path] = sorted( + [ + *github_dir.rglob("workflows/*.yml"), + *github_dir.rglob("workflows/*.yaml"), + *github_dir.rglob("actions/**/*.yml"), + *github_dir.rglob("actions/**/*.yaml") + ] + ) + + approved_patterns_yaml = load_yaml(Path(approved_patterns_file)) + if not isinstance(approved_patterns_yaml, list): + raise ValueError( + f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(approved_patterns_yaml)}") + approved_patterns: list[str] = [] + for entry in approved_patterns_yaml: + if not isinstance(entry, str): + raise ValueError( + f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(entry)}") + for e in entry.split(","): + approved_patterns.append(e.strip()) + print(f"There are {len(approved_patterns)} entries in the approved patterns file {approved_patterns_file}:") + for p in sorted(approved_patterns): + print(f"- {p}") + + print(f"Found {len(yaml_files)} workflow or action YAML file(s) under {github_dir}:") + failures: list[str] = [] + for p in yaml_files: + relative_path = p.relative_to(repo_root) + print(f"Checking file {relative_path}") + yaml = load_yaml(p) + uses_entries = list(_iter_uses_nodes(yaml)) + for yaml_path, uses_value in uses_entries: + matcher = re.match(re_action, uses_value) + if matcher is not None: + print(f" {yaml_path}: {uses_value}") + if uses_value.startswith("./"): + print(f" ✅ Local file reference, allowing") + elif uses_value.startswith("docker://apache/"): + print(f" ✅ Apache project image, allowing") + elif uses_value.startswith("apache/"): + print(f" ✅ Apache action reference, allowing") + elif uses_value.startswith("actions/"): + print(f" ✅ GitHub action reference, allowing") + else: + approved = False + blocked = False + for pattern in approved_patterns: + blocked = pattern.startswith("!") + if blocked: + pattern = pattern[1:] + matches = fnmatch.fnmatch(uses_value, pattern) + if matches: + if blocked: + approved = False + break + approved = True + if approved: + print(f" ✅ Approved pattern") + elif blocked: + print(f" ❌ Action is explicitly blocked") + failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is explicitly blocked") + else: + print(f" ❌ Not approved") + failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is not approved") + + if on_gha(): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: + f.write(f"# GitHub Actions verification result\n") + f.write("\n") + f.write("For more information visit the [ASF Infrastructure GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html) page\n") + f.write("and the [ASF Infrastructure Actions](https://github.com/apache/infrastructure-actions) repository.\n") + f.write("\n") + if len(failures) > 0: + f.write(f"## Failures ({len(failures)})\n") + for msg in failures: + f.write(f"{msg}\n\n") + else: + f.write(f"✅ Success, all action usages match the currently approved patterns.\n") + + if len(failures) > 0: + raise Exception(f"One or more action references are not approved or explicitly blocked:\n{"\n".join(failures)}") + + +def run_main(args: list[str]): + approved_patterns_file = Path(os.getcwd()) / "approved_patterns.yml" + if len(args) > 0: + check_path = args[0] + if len(args) > 1: + approved_patterns_file = args[1] + else: + check_path = Path(os.getcwd()) + check_project_actions(check_path, approved_patterns_file) + + +if __name__ == "__main__": + run_main(sys.argv[1:]) From 83c2c57acd922aaf6115946da369b5658f662c6f Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Sat, 14 Feb 2026 10:14:04 +0100 Subject: [PATCH 2/3] Add license header --- gateway/check_repository_actions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/gateway/check_repository_actions.py b/gateway/check_repository_actions.py index a0e6837d..2f17ed6f 100644 --- a/gateway/check_repository_actions.py +++ b/gateway/check_repository_actions.py @@ -4,6 +4,25 @@ # "ruyaml", # ] # /// +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# import fnmatch import os From 40b4b22f0da4c26876c888b44346ba40197ddb7d Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Sat, 14 Feb 2026 10:14:28 +0100 Subject: [PATCH 3/3] Add warnings if commit-ID/image-hash is missing --- gateway/check_repository_actions.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/gateway/check_repository_actions.py b/gateway/check_repository_actions.py index 2f17ed6f..60ce847e 100644 --- a/gateway/check_repository_actions.py +++ b/gateway/check_repository_actions.py @@ -36,6 +36,8 @@ re_action = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?(@(.+))?$" re_local_file = r"^[.]/.+" +re_docker_sha = r"^docker://[A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+@sha256:[0-9a-f]{64}$" +re_action_hash = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?@[0-9a-f]{40}$" def _iter_uses_nodes(node: dict, yaml_path: str = ""): """ @@ -101,6 +103,7 @@ def check_project_actions(repository: str | os.PathLike, approved_patterns_file: print(f"Found {len(yaml_files)} workflow or action YAML file(s) under {github_dir}:") failures: list[str] = [] + warnings: list[str] = [] for p in yaml_files: relative_path = p.relative_to(repo_root) print(f"Checking file {relative_path}") @@ -110,15 +113,30 @@ def check_project_actions(repository: str | os.PathLike, approved_patterns_file: matcher = re.match(re_action, uses_value) if matcher is not None: print(f" {yaml_path}: {uses_value}") + if uses_value.startswith("./"): print(f" ✅ Local file reference, allowing") elif uses_value.startswith("docker://apache/"): print(f" ✅ Apache project image, allowing") + # The following three are always allowed, see 'External actions' in + # the Apache Infrastructure GitHub Actions Policy. elif uses_value.startswith("apache/"): print(f" ✅ Apache action reference, allowing") + elif uses_value.startswith("github/"): + print(f" ✅ GitHub github/* action reference, allowing") elif uses_value.startswith("actions/"): - print(f" ✅ GitHub action reference, allowing") + print(f" ✅ GitHub action/* action reference, allowing") else: + # These should actually be failures, not warnings according to + # the Apache Infrastructure GitHub Actions Policy. + if uses_value.startswith("docker:"): + if not re.match(re_docker_sha, uses_value): + warnings.append(f"⚠️ Mandatory SHA256 digest missing for Docker action reference: {uses_value}") + print(" ️⚠️ Mandatory SHA256 digest missing") + elif not re.match(re_action_hash, uses_value): + warnings.append(f"⚠️ Mandatory Git Commit ID missing for action reference: {uses_value}") + print(" ️⚠️ Mandatory Git Commit ID digest missing") + approved = False blocked = False for pattern in approved_patterns: @@ -146,12 +164,17 @@ def check_project_actions(repository: str | os.PathLike, approved_patterns_file: f.write("\n") f.write("For more information visit the [ASF Infrastructure GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html) page\n") f.write("and the [ASF Infrastructure Actions](https://github.com/apache/infrastructure-actions) repository.\n") - f.write("\n") if len(failures) > 0: + f.write("\n") f.write(f"## Failures ({len(failures)})\n") for msg in failures: f.write(f"{msg}\n\n") - else: + if len(warnings) > 0: + f.write("\n") + f.write(f"## Warnings ({len(warnings)})\n") + for msg in warnings: + f.write(f"{msg}\n\n") + if len(failures) == 0: f.write(f"✅ Success, all action usages match the currently approved patterns.\n") if len(failures) > 0: