Skip to content

About

Rebuild the JavaScript GitHub Actions your workflows pin and check that their committed dist matches the source.

Topics

Resources

Stars

69 stars

Watchers

0 watching

Forks

Repository files navigation

action-dist-verify

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.

Why

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/checkout@v4.2.2 (resolves to 11bd71901bbe5b1630ceea73d27597364c9af683):

{
  "uses": "actions/checkout@v4.2.2",
  "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".

Installation

npm install --save-dev action-dist-verify

Requires Node >= 20.6, git on PATH, and whichever of npm, pnpm, or yarn the target action's lockfile specifies.

Usage

Scan a repository's workflows

npx action-dist-verify              # scans ./.github/workflows
npx action-dist-verify path/to/repo

Scan a single workflow or action.yml file

npx action-dist-verify .github/workflows/ci.yml

Machine-readable output

npx action-dist-verify --json . > report.json

As a library

import { verifyPaths } from 'action-dist-verify';

const report = await verifyPaths(['.'], { ignoreScripts: true });
const mismatches = report.results.filter((r) => r.status === 'mismatch');

Reference

CLI

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.

Library

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.

How it works

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:

  1. Parse the reference with the yaml npm package (the only runtime dependency). Workflow YAML uses flow mappings, both quote styles, and block scalars for uses:; a line-oriented regex parses enough of that wrong to be unsafe for a security tool. ./local and docker:// references are not-applicable. A uses: built from an expression or matrix variable (${{ ... }}) is could-not-analyse.
  2. 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.
  3. Fetch that one commit, shallowly where the remote allows it, and read runs.using from action.yml/action.yaml. Only node* actions proceed to rebuild; composite and docker actions are not-applicable.
  4. 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 of build, package, bundle, or all is defined as an npm script. More than one of those defined is could-not-analyse.
  5. Compare the rebuilt files against the committed ones, by content hash, under the directory that runs.main (and pre/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.

Limitations

  • 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 mismatch result 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.nodeVersionUsed vs. .declaredNodeVersion) to do that by hand. Automating it, for example via npx -p node@<v>, needs its own trust boundary and is left out of this version.
  • docker:// and composite actions are out of scope: there is no dist/ 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) skips preinstall/postinstall hooks; 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-remote or fetch; a failed network call is reported as could-not-analyse.

Compatibility

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.

Development

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.

License

MIT. See LICENSE.

About

Rebuild the JavaScript GitHub Actions your workflows pin and check that their committed dist matches the source.

Topics

Resources

Stars

69 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages