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
- Submitting an Action
- Available GitHub Actions
- Versioning and Pinning Actions
- Organization-wide GitHub Actions Allow List
- Auditing Repositories for Actions Security Tooling
- Snapshotting Queued and Running Actions Jobs
You can let your CI workflows check if the Actions used in your project are approved for use in the ASF.
An example workflow that can be used as a template for your project's CI can be found
here allowlist-check/README.md.
It is usually enough to add the following job to an existing .github/workflows/ci.yml file:
jobs:
asf-allowlist-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: apache/infrastructure-actions/allowlist-check@mainWhen calling the check-project-actions workflow from a push or pull_request event, it should work
automatically against the "right" reference. See the sample workflow linked above for more details.
To pin to an immutable, Dependabot-trackable version instead of @main, see
Versioning and Pinning Actions.
To contribute a GitHub Action to this repository:
- Fork this repository
- Add your action code:
- Create a subdirectory for your proposed GHA at the root level (e.g.,
/MyNewAction) - Add all required files for your action in this subdirectory
- Include a comprehensive README.md that explains:
- What the action does
- Required inputs and available outputs
- Example usage configurations
- Any special considerations or limitations
- Create a subdirectory for your proposed GHA at the root level (e.g.,
- Create a pull request to merge your branch into the main branch
The Infrastructure team will review each proposed Action based on:
- Overall usefulness to the ASF community
- Maintenance complexity
- Security considerations
- Code quality
Once approved, the Infrastructure team will merge the pull request and add the new Action to the list of available Actions for all ASF projects.
We highly appreciate contributed reviews, especially from people associated with the projects that (would like to) use a particular action, even if they're not committers on this project: you're especially qualified to judge and vouch for the safety and correctness of the action.
- ASF Infrastructure Pelican Action: Generate and publish project websites with GitHub Actions
- Stash Action: Manage large build caches
- ASF Allowlist Check: Verify workflow action refs are on the ASF allowlist
The actions in this repo are a monorepo of actions, and each one is released
under its own path-prefixed tag so you can pin a specific version and let
Dependabot propose bumps. The tag prefix is the action's leaf directory name,
which you repeat after the @:
| Action | Pin it like this |
|---|---|
allowlist-check |
apache/infrastructure-actions/allowlist-check@<sha> # allowlist-check/v1.2.3 |
pelican |
apache/infrastructure-actions/pelican@<sha> # pelican/v1.2.3 |
stash/save |
apache/infrastructure-actions/stash/save@<sha> # save/v1.2.3 |
stash/restore |
apache/infrastructure-actions/stash/restore@<sha> # restore/v1.2.3 |
Pinning to a commit SHA with the version in a trailing comment is the
recommended, Zizmor-friendly form: the SHA is immutable,
and Dependabot's github_actions ecosystem recognises the # <prefix>/vX.Y.Z
comment and opens a PR (updating both the SHA and the comment) when a newer
tag for that prefix is published. Support for this monorepo leaf-prefix scheme
was added to Dependabot in
dependabot/dependabot-core#11286,
contributed specifically for this repository.
A release is the tag pair (<prefix>/vX.Y.Z plus the moving <prefix>/vN) and
nothing more — this repo publishes no GitHub Release objects, because
Dependabot resolves versions from the tags themselves.
Tracking @main (as in the quick-start above) also works and always gives you
the latest code, but it drifts from any pinned SHA and Zizmor will flag the
unpinned ref. See RELEASING.md for how releases are cut.
As stated in the ASF GitHub Actions Policy, GitHub Actions from external sources are blocked by default in all apache/* repositories. Only actions from the following namespaces are automatically allowed:
apache/*github/*actions/*
All other actions must be explicitly added to the allow list after undergoing a security review. This review process applies to both new actions and new versions of previously approved actions (though reviews for new versions are typically expedited).
actions.yml is the source of truth for approved actions. From it, two generated files are kept in sync automatically: approved_patterns.yml (consumed by the ASF org-wide allow list) and .github/actions/for-dependabot-triggered-reviews/action.yml (the composite action Dependabot watches, so it can propose version bumps). The sections below describe the two entry points — manual PRs to add a new action, and the Dependabot-driven flow for updating versions of already-approved actions — and the workflows that implement each.
The diagram below summarizes every entry point, workflow and generated file involved in keeping the allow list in shape. Each subsequent section zooms in on one slice of this flow.
graph LR
human["Human PR<br/>(add action / older version /<br/>urgent removal)"]
dependabot["Dependabot PR<br/>(version bump)"]
cron["Daily 02:04 UTC"]
actions["actions.yml<br/><i>source of truth</i>"]
composite[".github/actions/<br/>for-dependabot-triggered-reviews/<br/>action.yml"]
approved["approved_patterns.yml<br/><i>ASF org allow list</i>"]
human-->actions
dependabot-->composite
dependabot-.verified by.-verify["<b>verify</b> job<br/>(rebuild & diff)"]
composite=="<b>update</b> job<br/>(on merge)"==>actions
cron=="<b>remove_expired</b> job"==>actions
actions=="<b>update</b> job"==>composite
actions=="<b>update</b> job<br/>(cap check + regen)"==>approved
classDef source fill:#fff3b0,stroke:#8a6d0b,color:#333
classDef generated fill:#e0f0ff,stroke:#2563a6,color:#333
classDef trigger fill:#f3e0ff,stroke:#6a1b9a,color:#333
classDef job fill:#e6ffe6,stroke:#1b5e20,color:#333
class actions source
class composite,approved generated
class human,dependabot,cron trigger
class verify job
Solid arrows (==>) are regeneration edges — the "source → generated" flows that keep actions.yml, approved_patterns.yml and the dependabot composite in sync. Thin arrows feed the pipeline with new content (human or Dependabot PRs, cron), and dotted arrows are observer jobs that verify rather than mutate. Bold labels are job names (rather than workflow filenames) — update lives in update.yml, verify in verify_dependabot_action.yml / verify_manual_action.yml, remove_expired in remove_expired.yml.
Note
The 800/1000-entry cap on approved_patterns.yml is enforced as a step inside the update job. It runs after regeneration and before commit/push, so an over-cap state never lands on main. The push uses the ALLOWLIST_WORKFLOW_TOKEN PAT because main is a protected branch and the default GITHUB_TOKEN is blocked by branch protection (GH006); the PAT has bypass rights for this workflow's automated commit.
graph TD;
manual["manual PR"]--new entry-->actions.yml
actions.yml--"<b>update</b> job"-->composite[".github/actions/for-dependabot-triggered-reviews/action.yml"]
actions.yml--"<b>update</b> job"-->approved["approved_patterns.yml"]
A human-authored PR edits actions.yml directly. Once it merges to main, the update job (in update.yml) regenerates both .github/actions/for-dependabot-triggered-reviews/action.yml and approved_patterns.yml from the new entries, so contributors never have to hand-edit the generated files.
To request addition of an action to the allow list:
- Fork this repository
- Add an entry to
actions.ymlusing the following format:
repo/owner:
'<exact-commit-sha>':
tag: vX.Y.Z-
Create a PR against the
mainbranch -
Include in your PR description:
- Why this action is needed for your project
- Any alternatives you've considered
- Any security concerns you've identified
-
Wait for review by the infrastructure team
Note
Always pin actions to exact commit SHAs, never use tags or branch references.
The infrastructure team will review your request and either approve, request changes, or provide feedback on alternatives.
graph TD;
dependabot--"PR updates"-->composite[".github/actions/for-dependabot-triggered-reviews/action.yml"]
dependabot-.verified by.-verify["<b>verify</b> job"]
composite--"<b>update</b> job (on merge)"-->actions.yml
actions.yml--"<b>update</b> job"-->approved["approved_patterns.yml"]
In most cases, new versions are automatically added through Dependabot:
- Dependabot opens PRs against
.github/actions/for-dependabot-triggered-reviews/action.ymlto update actions to the newest releases - The
verifyjob (inverify_dependabot_action.yml) runs on each such PR, rebuilds the action's compiled JavaScript in Docker, and diffs it against the published version (see Automated Verification in CI) - Once a reviewer merges the PR, the
updatejob (inupdate.yml) reflects the new commit SHAs back intoactions.yml, regeneratesapproved_patterns.yml, and enforces the 800/1000-entry cap inline before pushing - The previously approved version is marked with an
expires_atdate 3 months out, giving projects a grace period to update their workflows; see Automatic Expiration of Old Versions for how the cleanup runs
Projects are encouraged to help review updates to actions they use. Please have a look at the diff and mention in your approval what you have checked and why you think the action is safe.
Many GitHub Actions ship pre-compiled JavaScript in their dist/ directory. To verify that the published compiled JS matches a clean rebuild from source, use the verification script:
uv run utils/verify-action-build.py org/repo@commit_hashFor example:
uv run utils/verify-action-build.py dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3The script will:
- Clone the action at the specified commit inside an isolated Docker container
- Save the original
dist/files as published in the repository - Rebuild the action from source, picking the right toolchain automatically — Node.js (
npm ci && npm run build,yarn, orpnpm), Dart (dart compile jswhen apubspec.yamlis present), or Deno (deno task bundlewhen adeno.json/deno.jsoncis present) - Reformat both versions of the JavaScript for readable comparison
- Show a colored diff of any differences
A clean result confirms that the compiled JS was built from the declared source. Any differences will be flagged for manual inspection.
Non-minified compiled JS (e.g. Deno deno task bundle output, Dart dart compile js readable output) is handled differently: a clean rebuild for these tends to produce toolchain-version noise (esbuild/ncc/webpack boilerplate differences) rather than actionable diffs. The script keeps these files in place during the pre-rebuild deletion step and instead diffs them against the previously approved version of the action, so reviewers see real source changes rather than rebuild artifacts. The detection threshold mirrors the comparison heuristic — fewer than 10 lines or an average line length above 500 chars is treated as minified.
Files that appear only in the rebuild are reported as informational rather than as a failure. The action does not publish them, so they never reach a consumer's runner and cannot be a supply-chain vector. In practice they are intermediate build output from a multi-stage build that upstream deliberately does not commit — for example JetBrains/qodana-action declares main: scan/dist/index.js, so the output directory resolves to the whole scan/ sub-project and the rebuild's gitignored scan/lib/*.js (stage one of its tsc → esbuild build) lands inside the compared tree. The inverse remains a hard failure: JS present in the published tree but absent from the rebuild is unaccounted-for shipped code, as is a published tree with no compiled JS at all when the rebuild produced some — there is then nothing to reconcile the rebuild against.
The source diff vs approved and the Script analysis check both cover more than the language the entrypoint is written in. A node action is free to shell out to a script committed beside it — uraimo/run-on-arch-action declares main: src/run-on-arch.js, which then exec()s src/run-on-arch.sh — so shell/interpreter scripts (.sh, .bash, .ps1, .py, .rb, .pl) and Dockerfile* are diffed alongside the JS/TS sources, and committed shell scripts are discovered from the repo tree rather than only from the files action.yml or a Dockerfile happen to name. Script analysis runs for every action type; for JavaScript actions its findings are reported in the summary but do not change the pass/fail verdict.
When reviewing an action (new or updated), watch for these potential issues in the source diff between the approved and new versions:
- Credential exfiltration: code that reads secrets, tokens, or environment variables (e.g.
GITHUB_TOKEN,AWS_*,ACTIONS_RUNTIME_TOKEN) and sends them to external endpoints viafetch,http,net, or shell commands (curl,wget). - Arbitrary code execution: use of
eval(),new Function(),child_process.exec/spawnwith unsanitised inputs, or downloading and running scripts from remote URLs at build or runtime. - Unexpected network calls: outbound requests to domains unrelated to the action's stated purpose, especially in
postor cleanup steps that run after the main action. - Workflow permission escalation: actions that request or rely on elevated permissions (
contents: write,id-token: write,packages: write) beyond what their functionality requires. - Supply-chain risks: new or changed dependencies in
package.jsonthat are unpopular, recently published, or have been involved in known compromises; mismatches betweenpackage-lock.jsonandpackage.json. - Obfuscated code: hex-encoded strings, base64 blobs, or intentionally unreadable code in source files (not in compiled
dist/). - File-system tampering: writing to locations outside the workspace (
$GITHUB_WORKSPACE), modifying$GITHUB_ENV,$GITHUB_PATH, or$GITHUB_OUTPUTin unexpected ways to influence subsequent workflow steps. - Compiled JS mismatch: any unexplained diff between the published
dist/and a clean rebuild — this is the primary check the verification script performs. - Pre-compiled native binaries shipped in-tree: actions that commit Go/Rust/C-style binaries (
main-linux-amd64,*.exe,*.dll,*.so,*.dylib,*.jar,*.wasm, etc.) directly in the repo and exec them from a small launcher are running opaque executable code on the runner. The JS-rebuild check verifies the launcher but cannot reconcile the binaries with source on its own.verify-action-build's In-tree binary check tries to close the gap automatically: each detected binary is verified first by the clean rebuild (binaries a bundler copies into the output directory —.wasm,.node, native libraries — are deleted before the rebuild along with the minified JS, so one that comes back byte-identical was regenerated from the lockfile-pinned dependency tree and needs no release provenance of its own;1Password/load-secrets-actionshipsdist/core_bg.wasmthis way, copied bynccout of@1password/sdk-core), then viagh attestation verify --owner <org>(the SLSA attestation transparency log populated byactions/attest-build-provenance), then by SHA256-comparing each binary against the release'sSHA256SUMSasset. Binaries that pass any of the three are ✓; binaries that pass none are a hard reject. Push back on actions in this shape until upstream adds attestation orSHA256SUMSso the chain from release to artifact can be verified. - Runtime binary downloads without an in-source checksum: some actions pull their tool binary at runtime via
tc.downloadTool/curl/fetchand rely on the publishing pipeline (GitHub release immutability + Sigstore attestation) for integrity rather than an inlinesha256sum -c/cosign verify-blob. The Binary Download Verification check fails these by default. A per-action escape hatch lives inutils/verify_action_build/security.pyas theTRUSTED_DOWNLOAD_PROVENANCEdict — an entry asserts that the configuredrelease_repopublishes immutable releases AND emits Sigstore attestations viaactions/attest-build-provenance. Adding an entry is a security review decision and the rationale must link the upstream confirmation (e.g. a maintainer comment). The config alone is not enough: at scan time the verify pipeline GETsreleases/latestof the configuredrelease_repo, confirmsrelease.immutableis true, downloads one small attested asset (.sbom.jsonpreferred), and runsgh attestation verifyagainst it. Only when both halves pass are the action's unverified-download findings reclassified as warnings; if the runtime check fails, failures stay failures and the reason is printed. Note the scope: the spot-check proves the release repo's pipeline attests and that its latest release is immutable — it does not machine-verify that the action downloads from thatrelease_repo, nor that the specific version it fetches is itself immutable (onlyreleases/latestis checked). That binding remains the reviewer's call, backed by the entry'srationale.
For the full approval policy and requirements, see the ASF GitHub Actions Policy.
To review all open dependabot PRs at once, run:
uv run utils/verify-action-build.py --check-dependabot-prsThis will:
- List all open PRs from dependabot
- For each PR, extract the action reference from the diff
- Run the full build verification (rebuild in Docker, compare compiled JS)
- Show source changes between the previously approved version and the new one
- If verification passes, ask whether to approve and merge the PR
- On merge, add a review comment documenting what was verified
If you prefer not to install the gh CLI, you can use --no-gh to make all GitHub API calls via Python requests instead. In this mode you must provide a GitHub token either via --github-token or the GITHUB_TOKEN environment variable:
# Using the flag:
uv run utils/verify-action-build.py --no-gh --github-token ghp_... org/repo@commit_hash
# Or via environment variable:
export GITHUB_TOKEN=ghp_...
uv run utils/verify-action-build.py --no-gh --check-dependabot-prsIf neither is set and gh happens to be installed and logged in, the token is taken from
gh auth token as a last resort — so --no-gh only needs an explicit token when there is no
authenticated gh to borrow one from.
The --no-gh mode supports all the same features as the default gh-based mode.
Even in the default
gh-based mode, some checks (lockfile discovery, in-tree binary lookups) callapi.github.comdirectly and readGITHUB_TOKENfrom the environment. Unauthenticated those calls share a 60-requests/hour budget, which a single run can exhaust — so whenGITHUB_TOKENis unset,verify-action-buildfills it in from--github-tokenorgh auth tokenfor the duration of the run. Nothing to configure: just stay logged in withgh auth login.
Two workflows in .github/workflows/ run verify-action-build on PRs that touch the allow list, so the verification status is visible on every PR as a required-candidate status check:
verifyjob inverify_dependabot_action.yml— triggers on Dependabot PRs that modify.github/actions/for-dependabot-triggered-reviews/action.yml. Extracts the action reference from the PR, rebuilds the compiled JavaScript in Docker, and compares it against the published version.verifyjob inverify_manual_action.yml— triggers on human-authored PRs that modifyactions.ymlorapproved_patterns.yml(i.e. manual allow-list additions / version bumps). Dependabot-authored PRs are skipped, since they are already covered by the workflow above.check_action_tagsjob incheck_action_tags.yml— triggers whenactions.yml,approved_patterns.yml, the generated Dependabot composite action, the update workflow, or gateway verification code changes. It verifies that configured action SHAs exist and, when atagis recorded, that the SHA is reachable from that Git tag or branch.
These workflows use regular pull_request triggers with read-only permissions and no PR comments — pass/fail is surfaced through the status check. They do not auto-approve or merge; a human reviewer must still approve.
The script exits with code 1 (failure) when something is unexpectedly broken — for example, the action cannot be compiled, the rebuilt JavaScript is invalid, or required tools are missing. In all other cases it exits with code 0 and produces reviewable diffs: a large diff does not by itself cause an error (e.g. major version bumps will naturally have big diffs). It is always up to a human reviewer to inspect the output, assess the changes, and decide whether the update is safe to approve.
To verify a specific PR locally (non-interactively), use:
uv run utils/verify-action-build.py --ci --from-pr 123The --ci flag skips all interactive prompts (auto-selects the newest approved version for diffing, auto-accepts exclusions, disables paging). The --from-pr flag extracts the action reference from the given PR number.
Additional flags:
--no-cache— rebuild the Docker image from scratch without using the layer cache.--show-build-steps— display a summary of Docker build steps on successful builds (the summary is always shown on failure).
Note
Prerequisites: docker and uv. When using the default mode (without --no-gh), gh (GitHub CLI, authenticated via gh auth login) is also required. The build runs in a node:20-slim container so no local Node.js installation is needed.
.github/dependabot.yml groups updates so that related bumps arrive as one PR:
| Ecosystem | Group | What it collects |
|---|---|---|
github-actions (/.github/workflows, …) |
codeql-action |
github/codeql-action* — init/autobuild/analyze must run the same version, so a split PR fails the Analyze jobs |
uv (/, /pelican/, /stash/) |
dev-tooling |
everything in the PEP 735 dev group (ruff, mypy, pytest, pylint, types-*) — lint/test tooling that never ships in the published action |
uv |
runtime-minor-patch |
patch and minor bumps of runtime dependencies |
Dependabot opens one PR per directory per group, so /pelican and /stash each get a single dev-tooling PR rather than one per tool.
Two things stay deliberately ungrouped:
- Major bumps of runtime dependencies match no group, so each gets its own PR. A major version of something the action ships can break consumers and deserves to be reviewed and released on its own.
- The allow-list ecosystem (
/.github/actions/for-dependabot-triggered-reviews). Every bump there is an allow-list change with its own security review and its ownverifyrun, approved or held on its own merits. Grouping would tie an action that fails verification to unrelated ones that passed, so a single bad actor would block the whole batch.
This repository uses a Dependabot cooldown period of 0 days so that maintainers can review before Dependabot opens a PR on project repositories.
Tip
We recommend that ASF projects configure a cooldown in their own dependabot.yml to avoid being overwhelmed by update PRs and to catch up with approved actions here:
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default: 4Adjust the default value (in days) to match your project's review capacity.
If you need to add a specific version of an already approved action (especially an older one):
- Fork this repository
- Add a new version entry to an existing action in
actions.yml. Choose its metadata based on why the version is needed.
For the newest version:
existing/action:
'<exact-commit-sha>':
tag: vX.Y.ZThe current version must have neither keep nor expires_at, so that it is included in the
composite action watched by Dependabot. Each action must have at most one such version. When adding
a new current version manually, add expires_at: <date> to the previous current version to give
projects time to migrate.
For an older version that is needed temporarily:
existing/action:
'<exact-commit-sha>':
expires_at: 2025-01-01
tag: vX.Y.ZUse keep: true only as an exceptional alternative when an older version must remain available
indefinitely:
existing/action:
'<exact-commit-sha>':
# Explain why this version must remain available indefinitely.
keep: true
tag: vX.Y.ZA reference with keep: true is retained indefinitely and is not watched for updates by
Dependabot. To keep the action updated, it must also have a separate current version with neither
keep nor expires_at. Never set both keep and expires_at on the same reference.
- Create a PR against the
mainbranch - Include in your PR description:
- Specific reason why this version is required
- Any blockers preventing upgrade to newer versions
- Risk assessment for using an older version
- Expected timeline for migration to newer versions (if applicable)
Warning
Older versions may contain security vulnerabilities or performance issues. Always evaluate if using the latest version is possible before requesting older versions.
graph TD;
entry["actions.yml entry<br/>with expires_at"]--"<b>remove_expired</b> job (daily, 02:04 UTC)"-->actions.yml
actions.yml--"<b>update</b> job"-->composite[".github/actions/for-dependabot-triggered-reviews/action.yml"]
actions.yml--"<b>update</b> job"-->approved["approved_patterns.yml"]
Routine cleanup of superseded versions is automated:
- Any entry in
actions.ymlwith anexpires_at: YYYY-MM-DDfield is a candidate for removal. - Dependabot-driven updates (see Updating Version of Already Approved Action) set
expires_atto 3 months out on the previously approved version. For manually added older versions, setexpires_atexplicitly (see Manual Addition of Specific Versions). - The
remove_expiredjob (inremove_expired.yml) runs daily at 02:04 UTC. Every entry whoseexpires_atdate has passed is deleted fromactions.yml; the job then commits the change and lets theupdatejob inupdate.ymlregenerateapproved_patterns.ymland the dependabot composite. - Entries without
expires_at(for example,keep: truewildcards and the current approved version) are never auto-removed — removal of those requires a manual PR.
No human action is required for the routine case: projects get a 3-month grace window after a version bump, and the old entry disappears on its own afterwards.
Routine removal is already automated: set expires_at on the entry and the daily remove_expired job (in remove_expired.yml) will delete it once the date passes. Use the manual process below only when you need an immediate removal that can't wait for the entry to expire.
Important
If a version or entire action needs to be removed immediately due to a security vulnerability:
- Fork this repository
- Remove the relevant entry from
actions.yml - Create a PR against the
mainbranch - Mark it as urgent in the PR title (e.g., "URGENT: Remove vulnerable action X")
- Include in your PR description:
- The reason for removal
- Any CVE or security advisory ID if applicable
- Impact on projects currently using the action
- Recommended alternatives if available
The infrastructure team will prioritize these removal requests and may take additional steps to notify affected projects if necessary.
For 'regular' removals (not security responses), you can use ./utils/action-usage.sh someorg/theaction to see if/how an action is still used anywhere in the ASF, and create a 'regular' PR removing it from actions.yml (or adding an expiration date) when it is no longer used.
Recent security breaches have shown that GitHub Actions can fail silently, leaving repositories vulnerable without any visible indication. The actions-audit.py script helps ensure that all Apache repositories using GitHub Actions have a baseline set of security tooling in place.
GitHub Actions workflows can introduce security risks in several ways:
- Unpinned or unreviewed action versions may contain malicious code or vulnerabilities
- Missing static analysis means workflow misconfigurations (secret exposure, injection vulnerabilities) go undetected
- No dependabot means action versions never get updated, accumulating known vulnerabilities over time
The audit script checks each repository for four security configurations and can automatically open PRs to add any that are missing:
| Check | What it does |
|---|---|
| Dependabot | Keeps GitHub Actions dependencies up to date with a 4-day cooldown to avoid overwhelming reviewers |
| CodeQL | Runs static analysis on workflow files to detect security issues in Actions syntax |
| Zizmor | Specialized scanner for GitHub Actions anti-patterns: credential leaks, injection vulnerabilities, excessive permissions |
| ASF Allowlist Check | Ensures every action used is on the ASF Infrastructure approved allowlist |
- Python 3.11+ and uv >= 0.9.17 (dependencies are managed inline via PEP 723). Make sure your uv is up to date — depending on how you installed it, run
uv self update,pip install --upgrade uv,pipx upgrade uv, orbrew upgrade uv gh(GitHub CLI, authenticated viagh auth login) — or provide a--github-tokenwithreposcope and use--no-gh. With--no-ghand no token given, an authenticatedghis still used once to mint one viagh auth tokenzizmor(install instructions) — required for PR creation mode; not needed for--dry-run. If missing, zizmor pre-checks are skipped with a warning
Always start with --dry-run to see what the script would do without making any changes:
# Audit all repos for a specific PMC (prefix before first '-' in repo name)
uv run utils/actions-audit.py --dry-run --pmc spark --max-num 10
# Audit multiple PMCs
uv run utils/actions-audit.py --dry-run --pmc kafka --pmc flink
# Audit the first 50 repos (no PMC filter)
uv run utils/actions-audit.py --dry-run --max-num 50
# Increase GraphQL page size for fewer API round-trips
uv run utils/actions-audit.py --dry-run --max-num 200 --batch-size 100When satisfied with the dry-run output, remove --dry-run to create PRs:
# Create PRs for spark repos missing security tooling
uv run utils/actions-audit.py --pmc spark --max-num 10| Flag | Description |
|---|---|
--pmc PMC |
Filter by PMC prefix (repeatable). The prefix is the text before the first - in the repo name, e.g. spark matches spark, spark-connect-go, spark-docker. |
--dry-run |
Report findings without creating PRs or branches. |
--max-num N |
Maximum number of repositories to check (0 = unlimited, default). |
--batch-size N |
Number of repos to fetch per GraphQL request (default: 50, max: 100). |
--github-token TOKEN |
GitHub token. Defaults to GH_TOKEN or GITHUB_TOKEN environment variable. With --no-gh, falls back to gh auth token when neither is set. |
--no-gh |
Use Python requests instead of the gh CLI for all API calls. Needs a token — from --github-token, a token env var, or an authenticated gh. |
The --pmc flag matches repos by prefix: the text before the first hyphen in the repository name. For example, --pmc spark matches apache/spark, apache/spark-connect-go, and apache/spark-docker. If the repo name has no hyphen, the full name is used as the prefix.
The script downloads the list of known PMCs from whimsy.apache.org on first run and caches it locally (~/.cache/asf-actions-audit/pmc-list.json) for 24 hours. If a --pmc value doesn't match any known PMC, a warning is printed but it is still used as a prefix filter.
For each repository that is missing one or more checks, the script creates a single PR on a branch named asf-actions-security-audit containing only the missing files:
.github/dependabot.yml— created or updated to include thegithub-actionsecosystem with a 4-day cooldown.github/workflows/codeql-analysis.yml— CodeQL scanning for theactionslanguage.github/workflows/zizmor.yml— Zizmor scanning with SARIF upload.github/workflows/allowlist-check.yml— ASF allowlist verification on workflow changes
Before creating a PR, the script runs zizmor against the repository's existing workflow files. If zizmor finds errors, the CodeQL and Zizmor workflow files are added but commented out, with instructions explaining:
- That zizmor found existing issues in the workflows
- How to auto-fix common issues (
zizmor --fix .github/workflows/) - That the PMC should uncomment the workflows and fix remaining issues in a follow-up PR
This avoids creating PRs that would immediately fail CI due to pre-existing problems.
When not in --dry-run mode, the script prompts for confirmation before creating each PR:
Create PR for apache/spark?
Will add: dependabot, codeql, zizmor, allowlist-check
Proceed? [yes/no/quit] (yes):
- yes (default) — create the PR
- no — skip this repository and continue to the next
- quit — stop processing entirely and print the summary
The script is safe to re-run. Before creating a PR for a repository, it checks whether a PR with the branch name asf-actions-security-audit already exists — open, closed, or merged — and skips the repo if so.
When the ASF runners feel slow, the first question is always "who is using them right now, and
who is waiting?" The actions-queue-status.py script answers that across the whole organisation
without needing org-admin rights.
The obvious endpoint — GET /orgs/apache/actions/runners, which reports each runner's status
and busy flag — requires admin:org, so only Infra can call it. Everyone else debugging a slow
queue is left guessing. Check-run state, on the other hand, is readable by anyone who can read the
repository, and a check run maps one-to-one onto a workflow job. That is enough to see which
repositories are consuming capacity and which are stuck behind it.
There is no org-wide REST endpoint for queued jobs at all — the only alternatives are polling every
repository one at a time or running a workflow_job webhook listener. This script batches the
question into a handful of GraphQL requests instead.
- Python 3.11+ and uv (dependencies are declared inline via PEP 723)
gh(GitHub CLI, authenticated viagh auth login) — or pass--github-tokenwith a token that can read the org's repositories and use--no-gh. An authenticatedghalso supplies the token for--no-ghautomatically, viagh auth token
# Whole org: snapshot job state across the stored repository list
uv run utils/actions-queue-status.py
# Discard the stored list, discover the org afresh, and rewrite it
uv run utils/actions-queue-status.py --delete-cached-projects
# Read the repository list from somewhere else instead
uv run utils/actions-queue-status.py --repos-file /tmp/asf-repos.txt --top 40
# Write both orderings to CSV: <path>-by-running.csv and <path>-by-queued.csv
uv run utils/actions-queue-status.py --csv /tmp/asf-ci.csv
# A single project, sampling more of its open PRs
uv run utils/actions-queue-status.py --repos-file <(echo airflow) --prs 25Output is two tables — repositories sorted by running jobs, and by queued jobs — plus a one-line
total. --json prints the same data as JSON.
Each of the three long phases — discovering the org's repositories, sweeping their status, and the
REST re-count — shows a progress bar with the repositories done so far and the GraphQL points left.
The bars are written to stderr and disappear when the phase ends, so --json output and the tables
stay clean when stdout is piped or redirected.
| Flag | Description |
|---|---|
--org ORG |
Organisation to sweep (default: apache). |
--batch-size N |
Repositories per GraphQL query (default: 20). |
--prs N |
Open PRs sampled per repository, most recently updated first (default: 3). |
--suites N |
Check suites read per commit (default: 5). |
--workers N |
Batched queries in flight (default: 3). |
--top N |
Rows shown per table (default: 25). |
--include-archived |
Include archived repositories. |
--repos-file PATH |
Read repository names from this file instead of the stored list. # lines are ignored. |
--save-repos PATH |
Write the discovered repository list to a file as well. |
--delete-cached-projects |
Ignore the stored repository list, discover the organisation afresh, and rewrite the list with what it finds. |
--csv PATH |
Write both orderings as CSV alongside PATH. |
--json |
Print JSON instead of tables. |
--github-token TOKEN |
GitHub token. Defaults to GH_TOKEN or GITHUB_TOKEN, then gh auth token. |
--no-gh |
Use Python requests instead of the gh CLI. Requires a token — an authenticated gh supplies one automatically. |
--no-rest-fallback |
Skip the exact REST re-count for repos with more open PRs than --prs. |
Rather than guessing from pushedAt or probing each repository over REST, the discovery query
reads the workflows directory straight out of the git tree:
workflows: object(expression: "HEAD:.github/workflows") {
... on Tree { entries { name } }
}A repository counts as using Actions only when that tree exists and holds at least one .yml or
.yaml entry. Archived, disabled and empty repositories are skipped.
Discovery is the slowest and most rate-limit-hungry phase of a sweep — it walks every repository in
the organisation before a single job is counted — and its result changes slowly. So the current
answer is stored in this repository at
utils/apache-actions-repos.txt and read by default: a plain
uv run utils/actions-queue-status.py skips discovery entirely and goes straight to the status
sweep.
The file is one repository name per line, sorted, with # comment lines the reader ignores. Its
header records how many repositories it holds and when they were discovered, and that date is
echoed on every run. Sorting is what keeps it reviewable: discovery returns repositories in push
order, which reshuffles on every run, so an unsorted file would diff as a thousand moved lines
instead of the handful that actually joined or left.
The list is named after the organisation it describes, so --org other than apache finds no
stored list of its own and discovers, rather than answering from apache's. --include-archived
also falls through to discovery, because the stored list holds no archived repositories.
It must be refreshed periodically. A stored list only ages in one direction — repositories are created, archived, and adopt Actions after it was written — and a stale list fails silently, because the sweep reports totals across the repositories it was given without any way to know which ones are missing. Past 30 days the run says so in yellow. Refresh it with:
uv run utils/actions-queue-status.py --delete-cached-projectsThat runs a full sweep and rewrites the file, header and all, so the refresh and the snapshot come from the same pass. The list is only rewritten once discovery has succeeded — a sweep that dies partway through costs time, not the list you already had. Commit the result; the diff shows exactly which repositories joined and left.
A full sweep of the apache organisation is not free: GraphQL charges by node count, and the cost
climbs sharply with --prs. Measured over the ~1,250 apache repositories that use Actions, a
sweep costs roughly 250 points at --prs 3, 2,700 at --prs 5 and 21,000 at --prs 10, against
a budget of 5,000 points per hour.
Raising --prs is a worse trade than it looks. It buys coverage of quiet repositories -- the
open-PR distribution is long-tailed, so --prs 3 already covers 46% of them outright and --prs 5
only reaches 57% -- and every repository it fails to cover costs about one cheap REST call instead.
The two budgets are separate pools of 5,000, and a full REST pass uses only ~2,100-2,800 calls, so
GraphQL points are the scarce resource, not REST calls. Keeping the cheap pass genuinely cheap is
therefore also what keeps the sweep accurate. Each run prints the open-PR distribution during
discovery, so the trade-off can be re-checked against the organisation as it is today. Three safeguards keep a
sweep inside the caller's hourly allowance:
- every query asks for
rateLimit { cost remaining resetAt }, and the sweep stops with a warning once fewer than 200 points remain, reporting partial counts rather than dying; - transient failures (502, and rate-limit rejections) are retried with 4s/16s/64s backoff, because GitHub enforces a per-minute points cap as well as the hourly one;
--workersdefaults to 3 — higher concurrency reliably trips that per-minute cap mid-sweep.
Note that the REST /rate_limit endpoint is not a reliable pre-flight check here: it can report
a full GraphQL budget (5000/5000, used=0) while the API is actively rejecting queries with
RATE_LIMIT. The rateLimit block returned inside each query is the trustworthy signal.
GraphQL caps pullRequests(first:) at 100, and a sweep samples far fewer than that, so a
repository with more open PRs than --prs is necessarily under-counted — apache/airflow reported
0 running jobs from a 3-PR sample while REST found 176 in the same repository.
So the sweep uses each API where it is strongest. Every repo's query also returns the two
totalCounts that reveal whether the sample was complete: pullRequests(states: OPEN) and
checkSuites per commit. A repository's GraphQL numbers stand only when both limits held --
no more open PRs than --prs, and no commit carrying more check suites than --suites. Checking
only the PR count is not enough: apache/skywalking-java has a single open PR but thirteen active
runs on its head commit, and reading five of them reported 32 queued jobs where the true figure was
203. Everything else is re-counted over REST, which has no org-wide endpoint but is exact per
repository: list the runs that are still active, then count their jobs. In practice that is a small minority of repositories, so the sweep keeps
GraphQL's batching for the bulk of the org and pays REST's per-repo cost only where it buys
accuracy. The source column records which API produced each row, and --no-rest-fallback turns
the second pass off.
- Neither API attributes a job to a runner -- no labels, no runner name, no self-hosted versus
GitHub-hosted split -- so a job held back by a
concurrencygroup cannot be told apart from one waiting for capacity. Runs blocked on maintainer approval are reported separately in theruns_awaiting_approvalcolumn, since those are not capacity waits either. For true runner state, Infra can useGET /orgs/apache/actions/runners(admin:org), whose objects carrystatusandbusy. - GraphQL reaches workflow runs through check suites, which hang off commits, so the cheap pass
sees only the default branch head and open PR heads. A run started by a push to another branch,
by a tag, or by a schedule on a non-default branch is invisible to it, and no
totalCountreveals that. Such a repository is only counted if something else routes it to REST. In a paired run against a full REST sweep the residual difference was 8 repositories out of ~160, all small, and indistinguishable from ordinary churn over the twenty minutes separating the two passes. - The snapshot is a moment, not an average. A busy organisation moves enough in twenty minutes to change totals by a third, so compare runs taken close together or not at all.