Evaluate GitHub Actions workflows for CI patterns that break on forks, then rewrite the fixable ones.
Open source contributors should be able to fork a repository, enable Actions on their fork, and get useful feedback before asking maintainers to run upstream CI. This action checks workflow files for common blockers:
- private or self-hosted runner labels without a public fallback
- runner groups that require private runner access
- dynamic
runs-onexpressions that do not clearly fall back on forks - repository or organization secrets used without an owner gate
- publish, release, deployment, or cloud-auth steps used without an owner gate
The CLI is intentionally conservative. It rewrites mechanical cases and leaves ambiguous workflow logic as manual findings.
The set of public GitHub-hosted runner labels is committed in
data/public-github-hosted-runners.txt. Runtime checks use that file instead of
regex heuristics so the behavior is deterministic and reviewable.
- Usage
- CLI Usage
- What It Changes
- Fix Patterns
- Customizing
- Publishing To GitHub Marketplace
- Maintaining The Public Runner List
Add this action to a workflow that runs when workflow files change:
name: Fork friendly workflow audit
on:
pull_request:
paths:
- ".github/workflows/**"
push:
branches:
- main
paths:
- ".github/workflows/**"
jobs:
audit:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: wallentx/fork-friendly-actions@v1The action checks by default. Use fail-on: warning when you want every finding
to block the PR:
- uses: wallentx/fork-friendly-actions@v1
with:
fail-on: warningTo let the action rewrite workflow files in the checkout:
- uses: wallentx/fork-friendly-actions@v1
with:
mode: fixAllow repository-specific runner labels when they are available to forks:
- uses: wallentx/fork-friendly-actions@v1
with:
allow-runners: larger-ubuntu-4core,public-arm-runnerBuild the standalone executable from this repo:
npm i
npm run build
mkdir -p ~/.local/bin
ln -sf "$(pwd)/dist/ffactions" ~/.local/bin/ffactionsAfter that, run ffactions from any project checkout. By default, it evaluates
.github/workflows and reports findings without changing files. For fork
workflows, it prefers the upstream git remote and falls back to origin to
determine the upstream repository scope. That repo slug is the default source
of truth for gating and fixes.
Check workflows:
ffactionsShow the installed version:
ffactions --versionApply fixable changes:
ffactions --fixPreview changes without writing files:
ffactions --fix --dry-runReview each proposed fix interactively and accept or skip it with y/n:
ffactions --interactivePass the full upstream repository explicitly when the checkout has no usable git remotes or when you want to override detection:
ffactions --fix --upstream-repo ExampleOrg/example-repoPass only the owner when no repo slug is available:
ffactions --fix --upstream-owner ExampleOrgUse a different public fallback runner:
ffactions --fix --runner-fallback ubuntu-22.04-armRun against a specific directory (defaults to current directory):
ffactions path/to/projectRefresh the committed public-runner list from GitHub Docs:
npm run update:public-runnersPrivate scalar runners get a public fork fallback:
runs-on: benchmarkbecomes:
runs-on: ${{ github.repository == 'ExampleOrg/example-repo' && 'benchmark' || 'ubuntu-latest' }}Recognized scalar runner labels use the same OS/version/architecture mappings
as matrix labels below, so blacksmith-32vcpu-ubuntu-2404 falls back to
ubuntu-24.04 rather than a floating ubuntu-latest. Unrecognized scalar labels
retain the configured fallback or existing platform heuristic.
Matrix runner expressions get per-value fallbacks that preserve the runner's OS
and CPU architecture. Existing public and --allow-runners labels stay unchanged;
the matrix's other fields and build steps are preserved. For example:
| Matrix runner | Fork fallback |
|---|---|
blacksmith-32vcpu-ubuntu-2404 |
ubuntu-24.04 |
blacksmith-32vcpu-ubuntu-2404-arm |
ubuntu-24.04-arm |
blacksmith-12vcpu-macos-26 |
macos-26 |
blacksmith-32vcpu-windows-2025 |
windows-2025 |
macos-15-intel / windows-11-arm |
unchanged |
Mappings follow Blacksmith's documented runner families
and GitHub's macOS larger runner architectures,
and use the committed public-runner list. Both matrix axes and include entries
are supported, along with resolvable nested runner properties. A cross-build's
target architecture does not override the runner's architecture.
Generated mapping clauses have a stable label order regardless of matrix row
order. Previously generated expressions remain accepted.
If any runner value cannot be resolved or mapped to a compatible public runner,
ffactions leaves the matrix runner expression unchanged and reports FF002 for
manual review, including during --dry-run. --runner-fallback does not replace
unresolved matrices with one runner. Self-hosted matrices still get upstream-only
job guards.
These repository guards select fallbacks for workflows running inside a fork. A fork PR targeting the upstream repository still runs in the upstream repository and retains its original runner selection.
Guard detection checks the complete Boolean condition, including parentheses,
!, &&, and ||. A repository or owner equality must restrict every path
that could run outside the configured upstream scope. Event filters and PR-origin
checks alone do not restrict workflows running inside forks.
| Condition | Suppresses runner findings? |
|---|---|
github.repository == 'ExampleOrg/example-repo' |
Yes, for that upstream repository |
github.event_name != 'pull_request' |
No |
| `github.repository == 'ExampleOrg/example-repo' | |
github.repository != 'ExampleOrg/example-repo' |
No |
github.repository == 'ExampleOrg/example-repo' && success() |
Yes, for that upstream repository |
Unknown conditions remain reportable; quoted text and function arguments are not treated as repository guards. Guarded runner expressions must also provide a recognized public fallback. The analysis follows GitHub's expression quoting and Boolean operators.
Self-hosted runner arrays are treated as upstream-only and get a job guard instead of a fork fallback:
runs-on:
- self-hosted
- Linux
- ARM64becomes:
if: github.repository == 'ExampleOrg/example-repo'
runs-on:
- self-hosted
- Linux
- ARM64Runner groups are treated as upstream-only and get a job guard instead of a fork fallback:
runs-on:
group: ubuntu-runners
labels: ubuntu-24.04-16corebecomes:
if: github.repository == 'ExampleOrg/example-repo'
runs-on:
group: ubuntu-runners
labels: ubuntu-24.04-16corePublish, deploy, cloud-auth, release, and secret-backed steps get a step-level owner guard:
- run: twine upload dist/*
env:
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}becomes:
- run: twine upload dist/*
if: github.repository == 'ExampleOrg/example-repo'
env:
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}Known publishing commands include npm publish, pnpm publish, and
pkg-pr-new publish, including versioned invocations such as
pnpm dlx pkg-pr-new@0.0.75 publish. Output guards propagate through subsequent
steps that transform a publisher's outputs. If a job exports those outputs, the
job is also guarded so dependent jobs skip on forks. Jobs using always() that
read a skipped job's outputs receive an explicit guard. Other always() jobs
are reported for manual review without changing them or propagating a skip
through them: status checks and cleanup may intentionally run after a skip.
Use a public fallback for private runners:
runs-on: ${{ github.repository_owner == 'ExampleOrg' && 'benchmark' || 'ubuntu-latest' }}Skip upstream-only jobs on forks:
jobs:
publish:
if: github.repository_owner == 'ExampleOrg'
runs-on: ubuntu-latest
steps:
- run: npm publishAvoid requiring repository or organization secrets from fork pull requests:
steps:
- name: Publish package
if: github.repository_owner == 'ExampleOrg'
run: twine upload dist/*
env:
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}| Input | Default | Description |
|---|---|---|
workflows |
.github/workflows |
Directory or workflow file to audit. |
mode |
check |
Use check to report findings or fix to rewrite files. |
fail-on |
dynamic | Minimum severity that fails the action. Defaults to error in check mode and none in fix mode. |
allow-runners |
empty | Comma-separated runner labels to treat as fork-friendly. |
upstream-repo |
detected from git remote get-url upstream, then origin |
Repository slug used for strict fork gating and fix suggestions. |
upstream-owner |
derived from upstream-repo when possible |
Owner name used as a fallback when upstream-repo is not set. |
runner-fallback |
ubuntu-latest |
Public runner used when adding fork fallbacks. |
| Output | Description |
|---|---|
findings-count |
Total number of findings. |
error-count |
Number of error findings. |
warning-count |
Number of warning findings. |
changes-count |
Number of changes applied in fix mode. |
markdown-summary |
Markdown table of findings. |
Publish by creating a release and selecting "Publish this Action to the GitHub
Marketplace". The repository must be public, and the Marketplace listing is
driven by the root action.yml metadata file. See
Publishing actions in GitHub Marketplace.
The action runtime is the committed bundle at dist/action/index.js. Rebuild it
with npm run build before cutting a release so consumers can run
wallentx/fork-friendly-actions@v1 without installing dependencies.
This repository uses its own .github/workflows for CI, fork-friendliness
checks, dependency updates, release candidates, full releases, and release
checkpoint PRs.
data/public-github-hosted-runners.txt is generated from GitHub Docs and used
at runtime by the CLI and action wrapper.
The updater script lives at:
scripts/update-public-github-hosted-runners.sh
The ready-to-use GitHub Actions workflow template lives at:
contrib/update-public-github-hosted-runners.workflow.yml
Copy that template into .github/workflows/ when you want scheduled runner-list
refresh PRs. Set TARGET_REPOSITORY in the workflow template to the repository
you want to update, and provide an UPDATER_TOKEN secret that can push branches
and open pull requests there.