Skip to content

Commit 8056239

Browse files
authored
Merge pull request #486 from snazy/check-repo-actions
Workflow to check any ASF project's action usage
2 parents 831ed85 + 70b55cf commit 8056239

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
20+
# Workflow to be called from ASF project repository workflows to check
21+
# whether the GitHub actions referenced in GitHub workflows (`.github/workflows`) and
22+
# composite actions (`.github/actions`) are approved.
23+
#
24+
# The README.md of ASF Infrastructure Actions repository https://github.com/apache/infrastructure-actions
25+
# contains usage instructions.
26+
#
27+
# See: ASF Infrastructure GitHub Actions Policy: https://infra.apache.org/github-actions-policy.html
28+
29+
30+
name: Check Actions Usage
31+
on:
32+
workflow_call:
33+
inputs:
34+
repository:
35+
required: false
36+
description: |
37+
Optional, the `repository` parameter for `actions/checkout`.
38+
If not specified, the default is to use the repository of the calling workflow.
39+
See https://github.com/actions/checkout?tab=readme-ov-file#usage for details.
40+
type: string
41+
ref:
42+
required: false
43+
description: |
44+
Optional, the `ref` parameter for `actions/checkout`
45+
If not specified, the default is to use the repository of the calling workflow.
46+
See https://github.com/actions/checkout?tab=readme-ov-file#usage for details.
47+
type: string
48+
fetch-depth:
49+
required: false
50+
description: |
51+
Optional, the `fetch-depth` parameter for `actions/checkout`.
52+
See https://github.com/actions/checkout?tab=readme-ov-file#usage for details.
53+
type: number
54+
default: 1
55+
submodules:
56+
required: false
57+
description: |
58+
Optional, the `submodules` parameter for `actions/checkout`.
59+
See https://github.com/actions/checkout?tab=readme-ov-file#usage for details.
60+
type: boolean
61+
default: false
62+
63+
jobs:
64+
check-project-actions:
65+
runs-on: ubuntu-latest
66+
permissions:
67+
contents: read
68+
steps:
69+
- name: "Checkout apache/infrastructure-actions"
70+
uses: actions/checkout@v2
71+
with:
72+
repository: 'apache/infrastructure-actions'
73+
ref: 'main'
74+
75+
- name: "Checkout repository to be checked"
76+
uses: actions/checkout@v2
77+
with:
78+
repository: '${{ inputs.repository }}'
79+
ref: ${{ inputs.ref }}
80+
fetch-depth: ${{ inputs.fetch-depth }}
81+
submodules: ${{ inputs.submodules }}
82+
path: repository-to-be-checked
83+
84+
- run: pip install ruyaml
85+
86+
- name: Check allowed actions usage
87+
shell: python
88+
run: |
89+
import sys
90+
sys.path.append("./gateway/")
91+
92+
import check_repository_actions as c
93+
c.check_project_actions('./repository-to-be-checked', './approved_patterns.yml')

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
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'.
2222

23+
- [Checking the Action Usage in an ASF Project](#checking-the-action-usage-in-an-asf-project)
2324
- [Submitting an Action](#submitting-an-action)
2425
- [Available GitHub Actions](#available-github-actions)
2526
- [Organization-wide GitHub Actions Allow List](#management-of-organization-wide-github-actions-allow-list)
@@ -34,6 +35,26 @@ This repository hosts GitHub Actions developed by the ASF community and approved
3435
- [Removing a Version](#removing-a-version-manually)
3536
- [Auditing Repositories for Actions Security Tooling](#auditing-repositories-for-actions-security-tooling)
3637

38+
## Checking the Action Usage in an ASF Project
39+
40+
You can let your CI workflows check if the Actions used in your project are approved for use in the ASF.
41+
42+
An example workflow that can be used as a template for your project's CI can be found
43+
[here `check-actions-usage/sample-ci-workflow.yml`](check-actions-usage/sample-ci-workflow.yml).
44+
It is usually enough to add the following job to an existing `.github/workflows/ci.yml` file:
45+
```yaml
46+
jobs:
47+
check:
48+
name: Check actions usage
49+
uses: apache/infrastructure-actions/.github/workflows/check-project-actions.yml@main
50+
permissions:
51+
# Only read access to the repository's content
52+
contents: read
53+
```
54+
55+
When calling the `check-project-actions` workflow from a `push` or `pull_request` event, it should work
56+
automatically against the "right" reference. See the sample workflow linked above for more details.
57+
3758
## Submitting an Action
3859

3960
To contribute a GitHub Action to this repository:
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
20+
name: Example CI workflow validating allowed GH Actions usage in an ASF project
21+
on:
22+
# If you want to run this workflow manually, keep `workflow_dispatch`. Otherwise, remove this trigger.
23+
workflow_dispatch:
24+
# Trigger the workflow on push or pull requests when the contents of your `.github` directory change.
25+
# Note: the cheeck-project-actions.yml workflow inspects the `.github/workflows` and `.github/actions` directories.
26+
push:
27+
branches:
28+
- main
29+
paths:
30+
- ".github/**"
31+
pull_request:
32+
paths:
33+
- ".github/**"
34+
35+
permissions:
36+
# Only read access is required.
37+
contents: read
38+
# All other permissions are "none".
39+
40+
jobs:
41+
# This is the job that verifies your project's usage of approved GitHub actions
42+
check:
43+
name: Check actions usage
44+
uses: apache/infrastructure-actions/check-project-actions/check-project-actions.yml@main
45+
permissions:
46+
# Only read access to the repository's content.
47+
contents: read
48+
# All other permissions are "none".
49+
# Optionally, you can specify a different repository and/or ref to check. These options are passed to
50+
# GitHub actions/checkout, see https://github.com/actions/checkout?tab=readme-ov-file#usage for details.
51+
#with:
52+
#repository: apache/my-project
53+
#ref: my-branch
54+
#fetch-depth:
55+
#submodules:
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# /// script
2+
# requires-python = ">=3.13"
3+
# dependencies = [
4+
# "ruyaml",
5+
# ]
6+
# ///
7+
#
8+
# Licensed to the Apache Software Foundation (ASF) under one
9+
# or more contributor license agreements. See the NOTICE file
10+
# distributed with this work for additional information
11+
# regarding copyright ownership. The ASF licenses this file
12+
# to you under the Apache License, Version 2.0 (the
13+
# "License"); you may not use this file except in compliance
14+
# with the License. You may obtain a copy of the License at
15+
#
16+
# http://www.apache.org/licenses/LICENSE-2.0
17+
#
18+
# Unless required by applicable law or agreed to in writing,
19+
# software distributed under the License is distributed on an
20+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
21+
# KIND, either express or implied. See the License for the
22+
# specific language governing permissions and limitations
23+
# under the License.
24+
#
25+
#
26+
27+
import fnmatch
28+
import os
29+
import re
30+
import sys
31+
32+
from pathlib import Path
33+
34+
from gateway import load_yaml, on_gha
35+
36+
re_action = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?(@(.+))?$"
37+
re_local_file = r"^[.]/.+"
38+
39+
re_docker_sha = r"^docker://[A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+@sha256:[0-9a-f]{64}$"
40+
re_action_hash = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?@[0-9a-f]{40}$"
41+
42+
def _iter_uses_nodes(node: dict, yaml_path: str = ""):
43+
"""
44+
Walk the entire YAML structure (dicts/lists/scalars) and yield every value
45+
whose key is exactly 'uses', along with a best-effort YAML-path string.
46+
"""
47+
if isinstance(node, dict):
48+
for k, v in node.items():
49+
next_path = f"{"" if len(yaml_path) == 0 else f"{yaml_path}."}{k}"
50+
if k == "uses":
51+
yield next_path, v
52+
yield from _iter_uses_nodes(v, next_path)
53+
elif isinstance(node, list):
54+
for i, item in enumerate(node):
55+
next_path = f"{yaml_path}[{i}]"
56+
yield from _iter_uses_nodes(item, next_path)
57+
else:
58+
return
59+
60+
61+
def check_project_actions(repository: str | os.PathLike, approved_patterns_file: str | os.PathLike) -> None:
62+
"""
63+
Check that all GitHub actions used in workflows and actions are approved.
64+
65+
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
66+
67+
@param repository: Path to the repository root directory to check. YAML files under '.github/workflows' and '.github/actions' will be checked.
68+
@param approved_patterns_file: Path to the YAML file containing approved action patterns.
69+
"""
70+
repo_root = Path(repository)
71+
if not repo_root.exists():
72+
raise FileNotFoundError(f"Repository path does not exist: {repo_root}")
73+
74+
# Only consider workflows under '.github/workflows' (the only directory mentioned).
75+
github_dir = repo_root / ".github"
76+
if not github_dir.is_dir():
77+
print(f"No directory found at: {github_dir}")
78+
return
79+
80+
yaml_files: list[Path] = sorted(
81+
[
82+
*github_dir.rglob("workflows/*.yml"),
83+
*github_dir.rglob("workflows/*.yaml"),
84+
*github_dir.rglob("actions/**/*.yml"),
85+
*github_dir.rglob("actions/**/*.yaml")
86+
]
87+
)
88+
89+
approved_patterns_yaml = load_yaml(Path(approved_patterns_file))
90+
if not isinstance(approved_patterns_yaml, list):
91+
raise ValueError(
92+
f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(approved_patterns_yaml)}")
93+
approved_patterns: list[str] = []
94+
for entry in approved_patterns_yaml:
95+
if not isinstance(entry, str):
96+
raise ValueError(
97+
f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(entry)}")
98+
for e in entry.split(","):
99+
approved_patterns.append(e.strip())
100+
print(f"There are {len(approved_patterns)} entries in the approved patterns file {approved_patterns_file}:")
101+
for p in sorted(approved_patterns):
102+
print(f"- {p}")
103+
104+
print(f"Found {len(yaml_files)} workflow or action YAML file(s) under {github_dir}:")
105+
failures: list[str] = []
106+
warnings: list[str] = []
107+
for p in yaml_files:
108+
relative_path = p.relative_to(repo_root)
109+
print(f"Checking file {relative_path}")
110+
yaml = load_yaml(p)
111+
uses_entries = list(_iter_uses_nodes(yaml))
112+
for yaml_path, uses_value in uses_entries:
113+
matcher = re.match(re_action, uses_value)
114+
if matcher is not None:
115+
print(f" {yaml_path}: {uses_value}")
116+
117+
if uses_value.startswith("./"):
118+
print(f" ✅ Local file reference, allowing")
119+
elif uses_value.startswith("docker://apache/"):
120+
print(f" ✅ Apache project image, allowing")
121+
# The following three are always allowed, see 'External actions' in
122+
# the Apache Infrastructure GitHub Actions Policy.
123+
elif uses_value.startswith("apache/"):
124+
print(f" ✅ Apache action reference, allowing")
125+
elif uses_value.startswith("github/"):
126+
print(f" ✅ GitHub github/* action reference, allowing")
127+
elif uses_value.startswith("actions/"):
128+
print(f" ✅ GitHub action/* action reference, allowing")
129+
else:
130+
# These should actually be failures, not warnings according to
131+
# the Apache Infrastructure GitHub Actions Policy.
132+
if uses_value.startswith("docker:"):
133+
if not re.match(re_docker_sha, uses_value):
134+
warnings.append(f"⚠️ Mandatory SHA256 digest missing for Docker action reference: {uses_value}")
135+
print(" ️⚠️ Mandatory SHA256 digest missing")
136+
elif not re.match(re_action_hash, uses_value):
137+
warnings.append(f"⚠️ Mandatory Git Commit ID missing for action reference: {uses_value}")
138+
print(" ️⚠️ Mandatory Git Commit ID digest missing")
139+
140+
approved = False
141+
blocked = False
142+
for pattern in approved_patterns:
143+
blocked = pattern.startswith("!")
144+
if blocked:
145+
pattern = pattern[1:]
146+
matches = fnmatch.fnmatch(uses_value, pattern)
147+
if matches:
148+
if blocked:
149+
approved = False
150+
break
151+
approved = True
152+
if approved:
153+
print(f" ✅ Approved pattern")
154+
elif blocked:
155+
print(f" ❌ Action is explicitly blocked")
156+
failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is explicitly blocked")
157+
else:
158+
print(f" ❌ Not approved")
159+
failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is not approved")
160+
161+
if on_gha():
162+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
163+
f.write(f"# GitHub Actions verification result\n")
164+
f.write("\n")
165+
f.write("For more information visit the [ASF Infrastructure GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html) page\n")
166+
f.write("and the [ASF Infrastructure Actions](https://github.com/apache/infrastructure-actions) repository.\n")
167+
if len(failures) > 0:
168+
f.write("\n")
169+
f.write(f"## Failures ({len(failures)})\n")
170+
for msg in failures:
171+
f.write(f"{msg}\n\n")
172+
if len(warnings) > 0:
173+
f.write("\n")
174+
f.write(f"## Warnings ({len(warnings)})\n")
175+
for msg in warnings:
176+
f.write(f"{msg}\n\n")
177+
if len(failures) == 0:
178+
f.write(f"✅ Success, all action usages match the currently approved patterns.\n")
179+
180+
if len(failures) > 0:
181+
raise Exception(f"One or more action references are not approved or explicitly blocked:\n{"\n".join(failures)}")
182+
183+
184+
def run_main(args: list[str]):
185+
approved_patterns_file = Path(os.getcwd()) / "approved_patterns.yml"
186+
if len(args) > 0:
187+
check_path = args[0]
188+
if len(args) > 1:
189+
approved_patterns_file = args[1]
190+
else:
191+
check_path = Path(os.getcwd())
192+
check_project_actions(check_path, approved_patterns_file)
193+
194+
195+
if __name__ == "__main__":
196+
run_main(sys.argv[1:])

0 commit comments

Comments
 (0)