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’.
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@main
When 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:
/MyNewAction)The Infrastructure team will review each proposed Action based on:
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.
Skills under .claude/skills/ package the review workflows a maintainer repeats, so an agent runs them the same way each time. They are agent-facing and need no setup beyond an authenticated gh CLI; a human can follow the same steps by reading the SKILL.md.
analyze-action-pr: Triage a single PR that adds or bumps an action. Runs verify-action-build, classifies each failing action (pipe-to-shell, unverified download, unverified in-tree binaries, gaps in the verify script itself) and proposes the next step -- recommend approval, open an upstream issue, or fix verify-action-build with a regression test. Its “Recent precedents” table records how earlier PRs in each shape were resolved.sweep-open-prs: Triage the whole open PR queue in one pass. Buckets every open PR into merge-now, approve-and-merge, needs-triage or blocked, and routes anything red to analyze-action-pr. Useful after an overnight dependabot batch.Use sweep-open-prs to find what needs attention and analyze-action-pr to go deep on one PR. For the non-agent equivalent of the sweep, see Batch-Reviewing Dependabot PRs, which drives the same verification from verify-action-build directly.
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 |
website-comment/preview-annotate | apache/infrastructure-actions/website-comment/preview-annotate@<sha> # preview-annotate/v1.0.0 |
website-comment/preview-artifact | apache/infrastructure-actions/website-comment/preview-artifact@<sha> # preview-artifact/v1.0.0 |
website-comment/preview-publish | apache/infrastructure-actions/website-comment/preview-publish@<sha> # preview-publish/v1.0.0 |
website-comment/preview-main-overlay | apache/infrastructure-actions/website-comment/preview-main-overlay@<sha> # preview-main-overlay/v1.0.0 |
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. The major tag is force-moved on each release, so refresh an existing clone with git fetch --tags --force to see where it points now.
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.
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.ymlis enforced as a step inside theupdatejob. It runs after regeneration and before commit/push, so an over-cap state never lands onmain. The push uses theALLOWLIST_WORKFLOW_TOKENPAT becausemainis a protected branch and the defaultGITHUB_TOKENis blocked by branch protection (GH006); the PAT has bypass rights for this workflow's automated commit.
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:
actions.yml using the following format:repo/owner: '<exact-commit-sha>': tag: vX.Y.Z
Create a PR against the main branch
Include in your PR description:
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.
In most cases, new versions are automatically added through Dependabot:
.github/actions/for-dependabot-triggered-reviews/action.yml to update actions to the newest releasesverify job (in verify_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)update job (in update.yml) reflects the new commit SHAs back into actions.yml, regenerates approved_patterns.yml, and enforces the 800/1000-entry cap inline before pushingexpires_at date 3 months out, giving projects a grace period to update their workflows; see Automatic Expiration of Old Versions for how the cleanup runsProjects 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_hash
For example:
uv run utils/verify-action-build.py dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3
The script will:
dist/ files as published in the repositorynpm ci && npm run build, yarn, or pnpm), Dart (dart compile js when a pubspec.yaml is present), or Deno (deno task bundle when a deno.json/deno.jsonc is present)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 — an average line length above 500 chars, or fewer than 10 lines with any line above 500 chars, is treated as minified. A short file with ordinary lines is not: actions without a build step ship hand-written modules only a few lines long, and deleting them would leave nothing to compare.
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.
The Dockerfile analysis check locates the Dockerfile the runner actually builds, by resolving the path named in the action's runs.image: rather than assuming the conventional Dockerfile beside action.yml. A docker action may rename the file and place it anywhere in the repository — google/oss-fuzz's cifuzz actions sit at infra/cifuzz/actions/build_fuzzers and point image: three levels up at infra/build_fuzzers.Dockerfile — and probing only the conventional locations reported “no Dockerfile” for actions that have one, silently skipping every base-image and command check. Paths that climb past the repository root are ignored, and an image: docker://… reference still takes the pre-built-image path, where the check reports whether that image is digest-pinned.
When reviewing an action (new or updated), watch for these potential issues in the source diff between the approved and new versions:
GITHUB_TOKEN, AWS_*, ACTIONS_RUNTIME_TOKEN) and sends them to external endpoints via fetch, http, net, or shell commands (curl, wget).eval(), new Function(), child_process.exec/spawn with unsanitised inputs, or downloading and running scripts from remote URLs at build or runtime.post or cleanup steps that run after the main action.contents: write, id-token: write, packages: write) beyond what their functionality requires.package.json that are unpopular, recently published, or have been involved in known compromises; mismatches between package-lock.json and package.json.dist/).$GITHUB_WORKSPACE), modifying $GITHUB_ENV, $GITHUB_PATH, or $GITHUB_OUTPUT in unexpected ways to influence subsequent workflow steps.dist/ and a clean rebuild — this is the primary check the verification script performs.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-action ships dist/core_bg.wasm this way, copied by ncc out of @1password/sdk-core), then via gh attestation verify --owner <org> (the SLSA attestation transparency log populated by actions/attest-build-provenance), then by SHA256-comparing each binary against the release’s SHA256SUMS asset. 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 or SHA256SUMS so the chain from release to artifact can be verified.tc.downloadTool / curl / fetch and rely on the publishing pipeline (GitHub release immutability + Sigstore attestation) for integrity rather than an inline sha256sum -c / cosign verify-blob. The Binary Download Verification check fails these by default. A per-action escape hatch lives in utils/verify_action_build/security.py as the TRUSTED_DOWNLOAD_PROVENANCE dict — an entry asserts that the configured release_repo publishes immutable releases AND emits Sigstore attestations via actions/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 GETs releases/latest of the configured release_repo, confirms release.immutable is true, downloads one small attested asset (.sbom.json preferred), and runs gh attestation verify against 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 that release_repo, nor that the specific version it fetches is itself immutable (only releases/latest is checked). That binding remains the reviewer‘s call, backed by the entry’s rationale.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-prs
This will:
gh CLIIf 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-prs
If 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.
The workflows below run on PRs that touch the allow list, so the verification and approval status is visible on every PR as a required-candidate status check:
verify job in verify_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.
verify job in verify_manual_action.yml — triggers on human-authored PRs that modify actions.yml or approved_patterns.yml (i.e. manual allow-list additions / version bumps). Dependabot-authored PRs are skipped, since they are already covered by the workflow above.
Both verify jobs start on every PR, because verify is required on main by the Required checks ruleset in .asf.yaml, and a check that never starts would block a PR forever. Their first step asks the API for the PR‘s changed files; a PR that touches none of the three allow-list files (actions.yml, approved_patterns.yml, the Dependabot composite action) passes at once, with no checkout or build. Both jobs watch all three, so whichever runs for the PR’s author covers every allow-list file. The decision is made inside the job, so a failed lookup fails verify rather than skipping it, and a file list the API may have cut short is verified rather than skipped.
check_action_tags job in check_action_tags.yml — triggers when actions.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 a tag is recorded, that the SHA is reachable from that Git tag or branch.
approval job in allowlist_approval.yml — runs on every PR and on every review submitted, edited or dismissed. If the PR touches actions.yml, approved_patterns.yml or the generated Dependabot composite action, it fails until at least one maintainer (a reviewer with write access to this repository) has approved; only each reviewer's latest approve / request-changes / dismissal counts. Other PRs pass immediately. The check is required on main by the Required checks ruleset in .asf.yaml, so an allow-list PR cannot be merged unapproved. People with write access can deliberately merge past it (“bypass rules” on the PR), but cannot push past it; repository admins bypass it outright, which is how update.yml‘s sync commit, pushed as the admin asfgit, still lands. The logic lives in scripts/check_allowlist_approval.py, which the job runs from the PR’s base commit so a PR cannot rewrite its own gate.
rerun job in allowlist_approval_rerun.yml — when a review makes the approval check pass, re-runs the PR's earlier failed approval run from the pull_request event. Without it the ruleset keeps blocking the merge on that stale failure, although the review-triggered run is green. It is a workflow_run workflow because a review on a fork PR only gets a read-only token; it runs from the default branch, takes nothing from the PR but the head commit SHA, and only re-runs the latest pull_request run of the check, through scripts/rerun_allowlist_approval.py.
These workflows use regular pull_request (and pull_request_review) 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 123
The --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:
dockeranduv. When using the default mode (without--no-gh),gh(GitHub CLI, authenticated viagh auth login) is also required. The build runs in anode:20-slimcontainer 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:
/.github/actions/for-dependabot-triggered-reviews). Every bump there is an allow-list change with its own security review and its own verify run, 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.ymlto 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
defaultvalue (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):
actions.yml. Choose its metadata based on why the version is needed.For the newest version:
existing/action: '<exact-commit-sha>': tag: vX.Y.Z
The 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.Z
Use 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.Z
A 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.
main branch[!WARNING] Older versions may contain security vulnerabilities or performance issues. Always evaluate if using the latest version is possible before requesting older versions.
Routine cleanup of superseded versions is automated:
actions.yml with an expires_at: YYYY-MM-DD field is a candidate for removal.expires_at to 3 months out on the previously approved version. For manually added older versions, set expires_at explicitly (see Manual Addition of Specific Versions).remove_expired job (in remove_expired.yml) runs daily at 02:04 UTC. Every entry whose expires_at date has passed is deleted from actions.yml; the job then commits the change and lets the update job in update.yml regenerate approved_patterns.yml and the dependabot composite.expires_at (for example, keep: true wildcards 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:
actions.ymlmain branchThe 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:
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 |
uv self update, pip install --upgrade uv, pipx upgrade uv, or brew upgrade uvgh (GitHub CLI, authenticated via gh auth login) — or provide a --github-token with repo scope and use --no-gh. With --no-gh and no token given, an authenticated gh is still used once to mint one via gh auth tokenzizmor (install instructions) — required for PR creation mode; not needed for --dry-run. If missing, zizmor pre-checks are skipped with a warningAlways 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 100
When 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 the github-actions ecosystem with a 4-day cooldown.github/workflows/codeql-analysis.yml — CodeQL scanning for the actions language.github/workflows/zizmor.yml — Zizmor scanning with SARIF upload.github/workflows/allowlist-check.yml — ASF allowlist verification on workflow changesBefore 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:
zizmor --fix .github/workflows/)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):
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.
gh (GitHub CLI, authenticated via gh auth login) — or pass --github-token with a token that can read the org's repositories and use --no-gh. An authenticated gh also supplies the token for --no-gh automatically, via gh 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 25 # Group the rows by PMC instead of by repository uv run utils/actions-queue-status.py --by-pmc
Output is two tables — repositories sorted by running jobs, and by queued jobs — plus a one-line total. Each table closes with a TOTAL row carrying the org-wide queued and running counts and the number of active repositories; when --top truncates the table, a dim shown (top N) row above it subtotals the visible rows, so what the table leaves out is visible from the table itself rather than only from the summary line printed above it. --json prints the same data as JSON, with the same figures under totals, and each CSV ends with a matching TOTAL row.
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). |
--by-pmc | Group rows by PMC — the repository name's prefix before the first hyphen. The Repos column reads active / total. |
--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. |
-v, --verbose | Add per-batch, per-retry and per-repo diagnostics to the progress output. |
-q, --quiet | Suppress progress and diagnostics; print only the result. Overrides --verbose. |
--no-color | Disable colour and progress bars. NO_COLOR in the environment does the same. |
A full sweep takes minutes, so it reports what it is doing while it does it. Everything below goes to stderr — stdout carries only the --json payload, so piping stays safe.
Each phase is announced with a banner and closed with its elapsed time and the GraphQL points it spent, and while it runs it draws a live progress bar:
▸ Phase 2/3 Status 62 queries of up to 20 repos Status ━━━━━━━━━━━━━╺━━━━━━━━━ 38/62 61% 4102 pts 0:01:12 eta 0:00:45
The points figure is colour-coded — green above 2,000, yellow down to the 200-point floor, red below it — so an approaching budget stop is visible before it happens.
Bars are drawn only on a terminal. When output is piped or redirected, the same progress degrades to one line per step (every page during discovery, every tenth batch afterwards), which keeps log files readable and greppable:
Status 10/62 4102 pts
The run ends with a Run diagnostics table — queries issued, retries, batches split, repositories skipped, REST requests, points spent and wall time — so a slow or partial sweep can be explained after the fact rather than guessed at. Counters that represent failures stay dim while they are zero and turn yellow or red when they are not.
--verbose adds the detail behind those counters: each discovery page and its cursor, every retry with its backoff and the error that caused it, each batch split, and — for every repository re-counted over REST — the exact counts next to what the GraphQL sample had reported:
query attempt 1/4 failed, retrying in 4s: 502 Bad Gateway batch of 20 failed, splitting in two: 502 Bad Gateway airflow: 10 active runs → 37 running, 0 queued (GraphQL sampled 13 running, 0 queued)
--quiet goes the other way and prints only the result tables; --no-color (or NO_COLOR in the environment) drops both colour and bars.
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.
Pages are read 50 repositories at a time. Each node costs a git-tree lookup and an open-PR count, and at 100 the query times out server-side often enough to end a sweep — two consecutive full runs died on HTTP 502, after 200 and 300 repositories, with every retry exhausted. The same paging at 50 walked the whole organisation without a single retry.
--by-pmc changes the unit of the report from repository to PMC. Both orderings, the TOTAL footer, --top, --csv and --json work exactly as before; only the rows change:
Sorted by RUNNING jobs ┏━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ PMC ┃ Queued ┃ Running ┃ Repos ┃ Repositories ┃ ┡━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ airflow │ 13 │ 133 │ 2 / 10 │ airflow, airflow-client-python │ │ fineract │ 0 │ 96 │ 1 / 8 │ fineract │ │ datafusion │ 21 │ 49 │ 2 / 12 │ datafusion, datafusion-comet │ ├────────────┼────────┼─────────┼───────────┼────────────────────────────────┤ │ TOTAL │ 330 │ 881 │ 60 / 1258 │ 49 PMCs │ └────────────┴────────┴─────────┴───────────┴────────────────────────────────┘
The Repos column reads active / total: how many of the PMC's repositories have jobs right now, out of every repository of theirs the sweep covered. Four busy repositories mean something different for a PMC of four than for a PMC of forty-seven, and the denominator is what says which one you are looking at. It counts the repositories the sweep actually ran over, so a --repos-file run measures against that file rather than the whole organisation.
A repository's PMC is the text before the first hyphen in its name, and the whole name when there is no hyphen — so spark, spark-connect-go and spark-docker group under spark. That is the same rule --pmc uses in actions-audit.py, so the two scripts agree on what a PMC covers.
It is a naming convention, not authoritative ownership, and it is not checked against the committee list: an incubator- repository groups under incubator rather than under the podling's eventual PMC. For per-repository detail, drop the flag.
The Source column is spent on the repository count instead, since which API counted a row is a per-repository fact that a PMC of several repositories can only blur. The CSV keeps it, reading mixed where a PMC's repositories were counted different ways.
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-projects
That 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:
rateLimit { cost remaining resetAt }, and the sweep stops with a warning once fewer than 200 points remain, reporting partial counts rather than dying;--workers defaults 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.
concurrency group cannot be told apart from one waiting for capacity. Runs blocked on maintainer approval are reported separately in the runs_awaiting_approval column, since those are not capacity waits either. For true runner state, Infra can use GET /orgs/apache/actions/runners (admin:org), whose objects carry status and busy.totalCount reveals 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.