Rebuilds a GitHub Action's dist/ from the commit its uses: line pins to, and diffs the rebuild against what is committed.
import { verifyPaths } from 'action-dist-verify';
const report = await verifyPaths(['.']);
for (const r of report.results) console.log(r.status, r.uses, r.reason);
process.exit(report.exitCode);Read "How it works" and "Limitations" before running this against anything you don't control: it executes the target action's install and build scripts on this machine.
SHA-pinning a uses: reference verifies you get the commit you asked for. It does not verify that the code which actually runs, almost always the committed dist/index.js named by runs.main in action.yml, corresponds to the src/ a reviewer looked at. The compromise of tj-actions/changed-files (CVE-2025-30066, March 2025) put its payload directly in the committed dist/; src/ stayed clean, and every consumer pinned by SHA still ran the payload.
Checked before writing this: zizmor, actionlint, and OpenSSF Scorecard lint workflow syntax and usage patterns; none of them rebuild an action and diff the result. The closest prior art, ericcornelissen/reproducing-actions, is a curated dashboard that rebuilds 56 chosen actions on a schedule and publishes pass/fail; it does not take an arbitrary repository's workflows as input.
Measured: ADV_NETWORK_TESTS=1 node --test test/network.test.ts, against actions/[email protected] (resolves to 11bd71901bbe5b1630ceea73d27597364c9af683):
{
"uses": "actions/[email protected]",
"owner": "actions",
"repo": "checkout",
"resolvedSha": "11bd71901bbe5b1630ceea73d27597364c9af683",
"runsUsing": "node20",
"packageManager": "npm",
"buildScript": "build",
"status": "reproducible",
"reason": "rebuilt dist is byte-identical to the committed dist"
}npm ci --ignore-scripts && npm run build on Node 24 reproduces the committed dist/index.js exactly, matching the SHA1 (148b23ec6b39c2f4df4ebc18b69d0227dcc28fb2) established by hand before writing this tool. Full results from the same run, including two of the dashboard's currently-failing actions tried for real, are in "Development".
npm install --save-dev action-dist-verifyRequires Node >= 20.6, git on PATH, and whichever of npm, pnpm, or yarn the target action's lockfile specifies.
npx action-dist-verify # scans ./.github/workflows
npx action-dist-verify path/to/reponpx action-dist-verify .github/workflows/ci.ymlnpx action-dist-verify --json . > report.jsonimport { verifyPaths } from 'action-dist-verify';
const report = await verifyPaths(['.'], { ignoreScripts: true });
const mismatches = report.results.filter((r) => r.status === 'mismatch');action-dist-verify [path-to-repo-or-workflow-files...]
| Flag | Description |
|---|---|
--json |
Print the VerifyReport as JSON instead of the text report. |
--help, -h |
Print usage and exit 0. |
Exit codes:
| Code | Meaning |
|---|---|
0 |
Every reference is reproducible or not-applicable. |
1 |
At least one mismatch. |
2 |
No mismatch, but at least one could-not-build or could-not-analyse. |
verifyPaths(paths: string[], options?: VerifyOptions): Promise<VerifyReport>
Verifies every uses: reference reachable from paths (directories, or individual workflow/action.yml files). paths defaults to ['.'] when empty.
| Option | Type | Default | Description |
|---|---|---|---|
repoUrl |
(owner: string, repo: string) => string |
`https://github.com/${owner}/${repo}.git` |
Where to fetch an action from. Overriding this is how the test suite points at local fixture repos instead of the network. |
ignoreScripts |
boolean |
true |
Install with --ignore-scripts before running the declared build script. |
keepTempDirs |
boolean |
false |
Keep the temporary checkout/build directories instead of deleting them. |
VerifyReport: { results: ActionResult[], exitCode: 0 | 1 | 2 }.
ActionResult.status:
| Status | Meaning |
|---|---|
reproducible |
Rebuild is byte-identical to the committed dist. |
mismatch |
Dist does not match a rebuild from source. See "Limitations" for what this does and does not imply. |
could-not-build |
Install or build failed; errorTail holds the tail of the error output. |
not-applicable |
Local (./), docker://, or composite action. Out of scope by design. |
could-not-analyse |
Ambiguous or unresolvable: an expression in uses:, more than one lockfile, more than one candidate build script, an unresolvable ref, and similar cases. Reported rather than guessed. |
Other exports: classifyUses(raw: string), parseGithubUses(raw: string), findWorkflowFiles(root: string), findAllUsesRefs(root: string), parseUsesFromFile(file: string), isFullSha(ref: string), lsRemoteSha(repoUrl: string, ref: string), shallowCheckout(repoUrl: string, sha: string, destDir: string), detectPackageManager(dir: string), findBuildScript(dir: string). Types: UsesRef, UsesKind, ActionStatus, MismatchFile, ToolchainInfo, PackageManager, ActionResult, VerifyOptions, VerifyReport.
For every uses: in .github/workflows/*.y{a,}ml, and in a root action.yml/action.yaml when the scanned path is itself a composite action:
- Parse the reference with the
yamlnpm package (the only runtime dependency). Workflow YAML uses flow mappings, both quote styles, and block scalars foruses:; a line-oriented regex parses enough of that wrong to be unsafe for a security tool../localanddocker://references arenot-applicable. Auses:built from an expression or matrix variable (${{ ... }}) iscould-not-analyse. - Resolve the ref to a commit with
git ls-remote, and record whether the workflow already pinned a full 40-hex SHA or a mutable tag/branch. - Fetch that one commit, shallowly where the remote allows it, and read
runs.usingfromaction.yml/action.yaml. Onlynode*actions proceed to rebuild;compositeanddockeractions arenot-applicable. - Detect the package manager from whichever lockfile is present (ambiguous or missing is
could-not-analyse, never a guess), install with--ignore-scripts, then run whichever ofbuild,package,bundle, orallis defined as an npm script. More than one of those defined iscould-not-analyse. - Compare the rebuilt files against the committed ones, by content hash, under the directory that
runs.main(andpre/post) point into.
Every result records toolchain.nodeVersionUsed next to toolchain.declaredNodeVersion (from package.json#engines.node, .nvmrc, or .node-version, in that order), when the action declares one. A mismatch where these disagree is worth checking before assuming tampering: some bundlers produce version-dependent output.
- Does not verify dependency integrity beyond the lockfile. If a transitive dependency's own published
dist/is malicious, and the lockfile pins to that version, this tool rebuilds the malicious code and reports it reproducible, because it is reproducible from that source. - A
mismatchresult never means "malicious." It cannot distinguish tampering from an unpinned build-time dependency, a nondeterministic bundler, or toolchain drift. It is reported as "dist does not match a rebuild from source," and nothing stronger. - Does not automatically re-run a build under the declared Node version to separate toolchain drift from a real mismatch, even though it records enough (
toolchain.nodeVersionUsedvs..declaredNodeVersion) to do that by hand. Automating it, for example vianpx -p node@<v>, needs its own trust boundary and is left out of this version. docker://andcompositeactions are out of scope: there is nodist/to rebuild for either.- Runs the target action's install and build scripts on the machine invoking this tool. Run it in a disposable CI job or container.
--ignore-scripts(the default) skipspreinstall/postinstallhooks; it does not sandbox the build script itself, so a malicious build script in the target repository runs with this process's permissions. - No retry or backoff around
git ls-remoteor fetch; a failed network call is reported ascould-not-analyse.
Tested on Node 24.20.0 (node --test "test/*.test.ts", type-stripped, no build step) and against the built dist/ on the same Node version (test/smoke.mjs). CI (.github/workflows/test.yml) runs the smoke suite against Node 20.6, 22, and 24, but that matrix has not been run outside CI as part of this work.
pnpm and yarn support is implemented and was exercised for real against live actions (see "Development"), but is not covered by the offline fixture suite, which uses npm only: a pnpm/yarn fixture needs those binaries (via corepack) available in every environment the test suite runs in, which is not guaranteed.
node --test "test/*.test.ts" # offline: parser + fixture git repos
ADV_NETWORK_TESTS=1 node --test test/network.test.ts # real GitHub + real installs, opt in
npm run build && node --test test/smoke.mjs # against the built dist/The offline suite builds small local git repositories as fixtures (a real git init/commit, cloned by the tool via a local filesystem path): a reproducible node action, the same action with one line injected only into the committed dist/ (mismatch, exit 1), a composite action (not-applicable), and a build script that always fails (could-not-build, exit 2), plus workflow-parsing edge cases: subpaths, docker://, ./local, single- and double-quoted refs, and ${{ matrix.x }} expressions in uses:.
The network suite hits real GitHub and real npm/pnpm/yarn registries; it is skipped by default with an explicit message unless ADV_NETWORK_TESTS=1 is set. Real results from one run:
| Action | Ref | Result | Why |
|---|---|---|---|
actions/checkout |
v4.2.2 |
reproducible |
Matches the committed dist exactly; see "Why". |
ncipollo/release-action |
main |
could-not-analyse |
package.json defines both a build and a package script; this tool declines to guess which produces dist/. |
SonarSource/sonarqube-scan-action |
master |
could-not-build |
npm ci --ignore-scripts fails with E401 Incorrect or missing password: the lockfile references a package that needs authenticated registry access this environment does not have. |
peter-evans/create-pull-request |
main |
reproducible |
Matches on Node 24 (its declared engines.node is >=24.4.0). Listed as a dashboard failure at the time this was written; this tool's run against current main reproduced cleanly. |
actions-ecosystem/action-add-labels |
main |
reproducible |
Uses yarn; reproduced once a yarn binary was available via corepack enable yarn. Its runs.using is node12 while this ran on Node 24, recorded as toolchain info, and reproduced anyway. |
ncipollo/release-action and SonarSource/sonarqube-scan-action are both listed as currently failing by ericcornelissen/reproducing-actions. Both fail this tool's check too, for two different and identifiable reasons, neither of which is a dist/source mismatch.
MIT. See LICENSE.