Repository navigation
feat(unpinned-uses): accept a ref which the Actions lock file pins to a commit - #228
Merged
Merged
Conversation
… a commit A repository which locks its workflows with gh actions-lock keeps tags in uses: and lists the locked commit in .github/workflows/actions.lock. unpinned-uses no longer reports a step of a listed workflow when the file pins owner/repo@ref to a full commit digest, and the online fix does not write a SHA into such a workflow, because a SHA is a ref the lock file has no entry for. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lock file lists the actions of a local action under the workflows which run it, so the steps of an action.yml are accepted when every such workflow pins them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 25 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 13, 2026
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
jdx
added a commit
that referenced
this pull request
Oct 13, 2026
…nline (#251) ## What `unpinned-uses` gets a new policy value, `immutable-release`, for the existing `policies` option: ```yaml rules: unpinned-uses: policies: '*': immutable-release ``` It is `hash-pin`, and with `--online` a `uses: owner/repo@v1.2.3` whose ref is the tag of an **immutable GitHub release** is accepted as well (GitHub locks the tag and the assets of such a release). Without `--online` it is `hash-pin` (nothing is asked) and the message says an immutable release would do. The default policy is unchanged. Part of #167 (section 5 "Later": immutable releases, zizmor [#766](zizmorcore/zizmor#766), CodeQL github/codeql#22674). I removed "immutable releases under --online" from the "not built" list in `docs/zizmor-parity.md`. ## The API signal, verified first `GET /repos/{owner}/{repo}/releases/tags/{tag}` returns `"immutable": true|false` on the release object (documented REST field; the comments of zizmor#766 point to it too). Verified against the real API: - immutable: `astral-sh/setup-uv v10.3.0`, `actions/setup-go v7.0.0`, `docker/login-action v4.6.0`, `github/codeql-action v4.38.2`, `pypa/gh-action-pypi-publish v1.14.1` - not immutable (released before the repository turned it on): `pypa/gh-action-pypi-publish v1.14.0`, `actions/checkout v4.2.2` - a moving tag has no release: the lookup for `v4` is a 404 (`actions/checkout`, and `astral-sh/setup-uv v10`). ## Rules of acceptance (all must hold, else the finding stays) 1. the tag has a published release with `immutable: true`; 2. no **branch** has the name of the tag (the ref would be ambiguous and a branch can be pushed by whoever can write; the lookup is shared with `ref-confusion` and `--fix`); 3. the value is an action or reusable workflow of a GitHub repository with a plain tag name (a ref with `/`, a commit SHA and a Docker image are not looked up). A failed lookup (403, rate limit, offline cache miss) keeps the finding and counts as skipped. ## Design - The decision is a filter on the `unpinned-uses` findings of a file (`acceptImmutableReleases`, one call in `finishCheck` before the ignores, the baseline and the pin fixes), so the call sites of `unpinnedUsesMessage` in `rule_action.go` are untouched (the pending #228 edits them). A finding accepted that way is gone before ignore comments, baseline and `--fix` see it: no pin fix is offered for it. - `GitHubClient` is unchanged: `GitHubReleaseClient` is a new optional interface (built-in client, fixtures, recorder). `fixtureRepo.Releases` and one memo in the session are the only shared-file edits besides `testdata/online/github.json` (new entries at the top). ## Requests and cache Only for refs matched by an `immutable-release` policy that the offline rule flagged: one lookup of the release per distinct tag and run, and one lookup of the branch of the same name per tag that is immutable. Zero requests for other policies, for commits, and without the policy. A second run made 0 requests (the 404 "no release" is cached too; a 403 is not), tested. ## Tests `online_immutable_test.go` (accepted, not immutable, no release, branch of the same name, failing lookup, other policies untouched, request counts per path, second run from the cache, offline = hash-pin, HTTP client incl. invalid tags, record/replay, `usesRefAt`), golden `testdata/err/immutable_release_online` (real recorded answers of setup-uv and gh-action-pypi-publish plus a made-up repository with a branch). `go test -race ./...`, `go vet`, `hk check --all` pass. ## Corpus (real API, token, `'*': immutable-release` against the default) 506 workflows of 13 repositories (actions/runner, apache/airflow, astral-sh/uv, denoland/deno, grafana/grafana, hashicorp/terraform, home-assistant/core, microsoft/vscode, nodejs/node, pallets/flask, pytorch/pytorch, rust-lang/rust, vercel/next.js): 585 `unpinned-uses` findings with `hash-pin`, **580** with `immutable-release`. The 5 accepted: `actions/setup-go@v7.0.0`, `docker/login-action@v4.6.0` (2), `github/codeql-action/init` and `/analyze@v4.38.2`; all re-checked by hand: immutable release, no branch of that name. 0 skipped lookups. Most refs in the wild are moving majors (`@v4`), which stay reported, as they should. ## Not measured - "Immutable" protects the tag, not the author: an immutable release of a taken-over repository still runs what its author published (see #248, `repojacking`). - Enterprise Server: the field exists on github.com; whether GHES serves it is not verified (a missing field reads as not immutable). - Attestations of releases (`gh release verify`) are not checked: the REST flag is the only signal used. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes supply-chain pinning policy and adds GitHub API lookups during lint; misclassification or API/cache failures only leave stricter findings, but policy choice affects which refs are allowed in CI. > > **Overview** > Adds **`immutable-release`** as a new `unpinned-uses` `policies` value: behavior matches **`hash-pin`**, but with **`--online`** a plain tag on a GitHub action or reusable workflow (e.g. `uses: astral-sh/setup-uv@v10.3.0`) can satisfy the rule when GitHub reports an **immutable** published release for that tag and **no branch** shares the tag name (avoids ref ambiguity). Offline runs still require a full SHA; error text notes that immutable release tags work with `--online`. > > Implementation adds optional **`GitHubReleaseClient`** (`GET .../releases/tags/{tag}`), session memoization, fixture/recording support, and **`acceptImmutableReleases`** in **`finishCheck`** to drop qualifying `unpinned-uses` findings after the initial check. Failed lookups keep the finding and count as skipped. Docs and zizmor parity are updated; zizmor [#766](zizmorcore/zizmor#766) is no longer listed as unbuilt. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit db10bcd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The option switched the lock file off for the findings but the online pin fix was still withheld for a workflow which the lock file lists. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…global The lock files of the projects were cached in a package level map that was never cleared, and a change of the file between two runs of one process was not seen. The Linter holds them now, like the call graphs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The official parser rejects a repeated workflow, dependency or key, so the file was read with the first entry winning where the runner has none. The file is not trusted now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a3f8f92. Configure here.
Two spellings of a dependency were compared after the reader dropped invalid ids and invalid uses items, so entries which disagree could both be read as the same. They are compared as YAML now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
jdx
added a commit
that referenced
this pull request
Oct 13, 2026
#228 was squash-merged: its content comes from main. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
jdx
added a commit
that referenced
this pull request
Oct 13, 2026
…lows (#237) > **Based on #228** (`feat/actions-lock-unpinned-uses`), which adds the lock file reader (`actions_lock.go`). Merge #228 first; this branch then rebases onto `main` with only its own commit. Part of #167 (section 2, `actions.lock`: report missing and stale entries, and with `--online` check the locked commits). ## What Two new rules for repositories that lock their workflows with GitHub's Actions lock file (`.github/workflows/actions.lock`, technical preview). Neither reports anything when the repository has no lock file or when the file's `version` is not `v0.0.2`/`v0.0.3`. **`actions-lock`** (security, `default` profile, `error`; option `pedantic`). It reads the file like `gh actions-lock --verify-local`, offline: | Where | Finding | | --- | --- | | a workflow, or a local action that a listed workflow runs | an action the lock file does not list for the workflow, or lists at another ref (a SHA in `uses:` matches the entry resolved to that commit, as `gh actions-lock` does) | | the lock file | not YAML or wrong shape; a pin that is not `owner/repo@ref`; no `ref`; `commit` that is not `sha1-<40 hex>`/`sha256-<64 hex>`; no positive `owner_id`/`repo_id`; `ref` different from the ref of the key (except when the key is a SHA); key SHA with another `commit`; bad `hostname`; workflow path leaving the repository; a listed pin without a `dependencies` entry; `uses` pointing nowhere; cycles | | the lock file | stale entries: a workflow that does not exist, a pin its workflow no longer uses, a dependency no workflow reaches (skipped when something the workflow runs cannot be read: an expression in `uses:`, a missing local action) | | `pedantic` only | a workflow the lock file does not list (workflows are onboarded one by one, so this can be on purpose); a commit SHA whose version comment disagrees with the `ref` the lock file resolved it from (`# v4` is accepted for `v4.3.1`) | **`actions-lock-drift`** (security, online, `error`). Under `--online`, for each pin: the repository is not the locked one (GitHub's `id`/`owner.id` differ from `repo_id`/`owner_id`), the repository was renamed or transferred (`full_name` differs; the runner refuses it), a full-version tag (`v4.2.1`) now points to another commit than `commit`, or `commit` is on no branch or tag of the repository (the existing `impostor-commit` machinery). This extends `GitHubRepo` with optional `id`, `full_name` and `owner.id` (fixtures in `testdata/online/github.json` got them for `actions/checkout`, `actions/setup-node` and a made-up `example/renamed-action`; the recorded answers of the other repositories are unchanged). The lock file is linted with the other files of the repository mode (`jactionlint` with no arguments, `--fix` and so on find it through `Project.actionsLockFiles()`, next to `dependabot.yml`) and when named explicitly. The usage of each workflow is computed once through the existing call graph, so `$/path` actions count. ## The format Researched from primary sources and written up in #228 (README and source of [github/gh-actions-lock](https://github.com/github/gh-actions-lock), schema and parser of [github/actions-lockfile](https://github.com/github/actions-lockfile), CodeQL [#22487](github/codeql#22487) and [#22155](github/codeql#22155)). The checks above mirror what `gh actions-lock` reports (`structural.go`: `not-pinned`, `ref-changed`, `stale`, `stale-workflow`) and what the official parser rejects (`compat.go`, `lockfile.go`), except that unknown keys are ignored here. The docs say the format may change; a version whose shape is not known is silent. ## Sub-items Done: malformed entries, missing entry, stale entry, malformed digest, ref against version comment (pedantic), and online: tag still points at the commit, repository not renamed/replaced (IDs), commit not on a branch or tag. Limits, not skipped for lack of evidence but because the API cannot say: - "The locked commit exists": GitHub's answers for a deleted commit and for a commit it never knew look the same, so a commit that is gone is reported only when the comparison API still knows it and no branch or tag has it. A repository that returns 404 (deleted, or private for the token) gets no verdict. The docs say so. - Mutable refs (`v4`, branches) legitimately move (`--relock`), so only the history of their commit is checked, like `gh actions-lock --verify`. - Pins with a `hostname` other than `github.com` are not looked up. ## How it was tested - Goldens in `testdata/actions_lock/` for both rules and both profiles: `check_ok` (a consistent lock file with a local action, a hand-pinned SHA, transitive `uses`, an empty workflow, an unknown key, `sha256`, a `hostname`; no finding, also with `pedantic`), `check_err` and `check_err_pedantic` (23 and 25 findings), `check_gone` (a missing workflow and an unreadable one: no stale-entry cascade), `check_online` (four drift findings and a 404 that is left alone; offline run reports nothing), plus a test that a repository without a lock file is never reported, one that the repository mode finds the file only when it exists, and the docs example (`TestActionsLockDocsExample` compares the output in `docs/checks.md` with the real one). - `go build ./...`, `go vet ./...`, `go test -race ./...` and `hk check --all` pass; `mise run rule-ids actions-lock`, the profile snapshot and `docs/rules.md` are regenerated. - **Corpus** (same 82 repositories as #228, the ones with a `.github/workflows/actions.lock`, found by code search): offline, **95 findings in 11 repositories**, none of them about the format of the file (no lock file of the 82 has a finding of that kind): 35 actions whose ref changed after the lock was written, 31 pins their workflow no longer uses, 25 dependencies no workflow reaches, 4 actions with no entry at all. I read the lock files and workflows behind the repositories (`atomic-works/github-actions-reusable-workflows` locks `checkout@v3.7.0` and uses `@v7.0.1`; `meechain-hall/board.v1` still lists `trivy-action`, `upload-artifact` and `codeql-action` that its workflow no longer uses; `hyperpolymath/academic-workflow-suite` keeps `gitleaks-action` which nothing mentions; `hyperpolymath/action-trust-layers` uses `codeql-action` by SHA in a workflow that lists only `checkout`). All 95 are real disagreements between the file and the workflows; I found no false positive. The pedantic option adds 5: 3 version comments (`# v3` for a commit the lock resolved from `v4.34.0`), 2 unlisted workflows. Findings that are consequences of one staleness are many, which is why the stale checks are one message per entry. - **Online**: a real run (token) over the lock files of `casey/just`, `casey/filepack`, `github/gh-actions-lock`, `actix/actix-web` and `b-editor/beutl` (about 60 pins) found nothing; the same file with a wrong `repo_id` and a changed commit produces the replaced-repository and moved-tag findings against the live API. Only these 5 repositories were checked online because the shared token hit the rate limit during the larger run. ## Why the `default` profile The rule needs a lock file, which only a repository that adopted GitHub's preview has, and the findings are failures of `gh actions-lock --verify-local`. The measured false-positive rate on 82 repositories is zero, but that is a small corpus of one preview feature (242 of the 363 repositories with a lock file belong to one account and I capped the sample at 4 per owner); the risk is a change of the format, which the version gate covers. The two noisier checks are `pedantic`. ## Not measured - Repositories on GHE.com (data residency) and GHES: not supported by `gh actions-lock`/not tested; `hostname` is only validated. - Lock files of version `v0.0.1` and future versions are skipped silently. - Behaviour on a repository where the lock file is generated in CI and not checked in (nothing to read, nothing reported). - `--online` on the 77 other repositories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New default-profile security rules affect CI for repos using the lock preview; online drift relies on GitHub API identity fields and shares impostor-commit limits. > > **Overview** > Adds **`actions-lock`** and **`actions-lock-drift`** so jactionlint can validate GitHub’s `.github/workflows/actions.lock` the same way offline `gh actions-lock --verify-local` does, plus optional online drift checks. > > **`actions-lock`** (default profile) compares listed workflows and local actions they run against the lock file: missing pins, wrong refs (including SHA matched to locked commits), malformed lock entries, stale workflows/pins/dependencies, and optional **pedantic** checks (unlisted workflows, version-comment mismatches). **`actions-lock-drift`** runs with **`--online`** and flags pins whose `repo_id`/`owner_id` or `full_name` no longer match GitHub, moved full-version tags, or commits not on any branch/tag. > > The linter now includes `actions.lock` in repository scans and can lint lock content passed in memory. Lock parsing gains **`parseActionsLock`**, **`lookup`**, and **`lockScope`** (replacing the narrower `workflowLock` path for rule wiring). **`GitHubRepo`** grows **`id`** and **`owner`** for drift detection. Docs, config schema, fixtures, and tests (including a docs example sync test) cover the new behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dcf97bf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
jdx
added a commit
that referenced
this pull request
Oct 14, 2026
… (--suggest-timeouts) (#275) Closes #121 (PR 4 of 4, so this is the one that closes it once the earlier ones are merged: #269, #272, #274). It is independent of the others, except that all four edit `docs/usage.md`, `man/jactionlint.1.ronn` and `command_options.go`, so expect trivial merge conflicts there. This is the "Later" item "historical timeout recommendations, report-only" ([ghatm](https://github.com/suzuki-shunsuke/ghatm) is the precedent). The issue asks for: sample count, date range, job/matrix matching limitations and a suggested buffer; review before applying a value; historical maxima are not guarantees. All of them are in the output. ## What it does `jactionlint --suggest-timeouts` (implies `--online`; `--timeout-history-runs N`, default 20, 1 to 100; `--timeout-buffer FACTOR`, default 2, at least 1) reads, for each workflow, the latest successful runs and the jobs of each run, and prints per job: declared `timeout-minutes`, samples, min, median, max (minutes, from the start on a runner to the end, so the wait for a runner is not in it) and a suggestion. `--format json` prints the same as a versioned document; other formats are refused. - **Report only.** It writes no file and no rule or fix uses its numbers: `missing-timeout` still never chooses a value (the CONTRIBUTING rule), and `default-minutes` stays the person's decision. The output ends by saying so, and tells the person the largest suggestion, which is the least that one `default-minutes` can be without cancelling the slowest job on record. - **The number** is `ceil(max x buffer)` for a job with at least 3 samples. Below that, no number, and the note says why. - **What it states it cannot know:** how many runs and which dates (per workflow) and how many samples (per job); runs that failed, were cancelled or were skipped are left out; a **matrix** is one job whose legs share the timeout (the suggestion is for the slowest leg, the legs are counted); a **call of a reusable workflow** has no numbers (the timeouts belong to the jobs of the called workflow); job names of the runs that fit no job of the file, or fit two, are listed as "not matched" and are in no number; runs with more than 100 jobs are read in part and the report says so; a lookup that failed (rate limit, 403) makes the report `complete: false` with the count, and `--online=strict` fails on it like the other online checks; "runs may predate the current file". - Matching is by name: the `name:` (an expression in it matches any text), or the ID, with the matrix values in parentheses that GitHub adds (`Unit tests (macos-latest)` for `name: Unit tests` with a matrix; I found that rule by running it against the real API, the first version missed it). ## Plumbing A new optional `GitHubRunsClient` interface (`WorkflowRuns`, `RunJobs`) on the HTTP client (so the cache, ETags, rate limit handling, redaction and the token rules apply as for every other lookup), the fixture client (`workflow_runs` in the fixtures JSON), and the GitHub Enterprise fallback wrapper (which asks the Server only, like environments: the repository of the workflow is on the Server). The recording client does not record runs. Names are validated before they are put in a path (`validWorkflowFile`). The repository is `GITHUB_REPOSITORY` or the `origin` remote on github.com; `githubRepositoryOf` now also asks `git config` when `.git` is a file (linked work trees and submodules, where it returned nothing; the environment rule uses the same function). ## Testing `go vet`, `go test -race ./...`, `hk check --all` pass locally; the WebAssembly build still compiles. Tests: the name matching (matrix, named, expression names, reusable calls), a fake API with the exact paths and query parameters (`status=success`, `per_page`), the numbers (min/median/max, the 8-sample matrix, a failed and a skipped execution that are not samples, 2 samples that get no suggestion, a job that never ran, a call of a workflow, an unmatched name, the buffer), the text, the JSON, an unknown workflow (404), an incomplete run (403 for one run, and `--online=strict`), the option errors, the fixture client, and that the workflow file is not touched. **Real API:** I ran it against the history of jdx/jactionlint itself (the 13 workflows of the repository, 6 runs each and then 20 for two of them, about 100 requests with a token) and read the output: the numbers are plausible, a workflow that never succeeded recently says "no successful run on record", the reusable-workflow call has no numbers, and the matrix of `ci.yaml` is one job with 3 legs and 60 samples. ## Not done / not measured - It does not follow a reusable workflow into the workflow it calls (it would need the callee's runs, which GitHub lists under the caller's jobs as `caller / job`); the note says so. - Pull-request runs from forks, re-runs and runs of other branches are not told apart; "successful runs of any event and branch" is what is read. - No rate-limit planning beyond what the client has: 1 + N requests per workflow, cached for the hour. Without a token GitHub allows 60 an hour (the docs say to give one). - The JSON document has no JSON Schema file (the online report has none either); its fields are described in `docs/usage.md`. ## What the series did for #121 (so it can close) Read against the issue body and the maintainer's comment ("items 1 to 4 done; open: item 5 and the Later list"): | Item | State | | --- | --- | | 1 caller-aware facts | #113 to #118 done earlier; the facts are now exported in one place (#272) with the audit of where they are computed more than once; I did not merge the four token-permission readers (see #272) | | 2 no-fix reasons | JSON/SARIF done earlier; #269 closes the gap (11 rules had findings with neither a fix nor a reason), adds `--show-fix-status` and the prerequisites in `--explain` | | 3 `workflow-secret-scope` | done (#136) | | 4 source invariants | done (#137, #146, #157); #272 and #269 add their commands to the zero-write test | | 5 online coverage | done by #166, merged after the maintainer's comment: `--online-report` (any format) and SARIF carry status, skipped lookups by kind, excluded repositories, and the freshness of cached answers (`oldestFetched`, `cacheTtlSeconds`, counts of network, cache, stale and immutable answers) with the timestamp source documented | | Later: `actions.lock` compatibility | done (#237, #228) | | Later: incremental reporting | #274 | | Later: installed-version explanations and generated config schema | done (`--explain` #204, `--print-config-schema` #195) | | Later: versioned semantic export | #272 | | Later: historical timeout recommendations | this PR | Not done: per-finding freshness (which finding used a cached answer). The cache layer does not know the findings and the rules share memoized answers, so it would be a new tracking path through every online rule for a number the run-level report already bounds; the run-level `answers` and `oldestFetched` are what consumers get. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New GitHub Actions API traffic and token permissions (Actions read), plus non-trivial job-name matching logic; behavior is report-only and does not modify workflows or lint results. > > **Overview** > Adds **`--suggest-timeouts`**, a read-only report mode that turns on online checks, pulls each workflow’s latest successful runs from GitHub, and prints per-job duration stats (min/median/max) plus optional **`timeout-minutes`** suggestions. Suggestions use `ceil(max × --timeout-buffer)` with at least three samples; **`--timeout-history-runs`** (1–100, default 20) controls how many runs are fetched. Output is **text** or **json** only; nothing is written to workflow files and lint rules are unchanged. > > New **`GitHubRunsClient`** (`WorkflowRuns`, `RunJobs`) extends the existing HTTP client, fixtures, and GHE fallback with the same caching and rate-limit behavior as other online lookups. **`Linter.SuggestTimeouts()`** builds the **`TimeoutReport`**, matches run job names to workflow jobs (matrix legs, expression names, reusable-workflow calls), and documents gaps (unmatched names, truncated large matrices, incomplete API failures). **`githubRepositoryOf`** now resolves **`origin`** via `git config` when `.git` is a file (worktrees/submodules). > > CLI guards reject combining this mode with `--fix`, `--export-facts`, `--no-online`, `--changed-since`, stdin, and incompatible formats. Docs, man page, and tests cover matching, API queries, JSON/text output, and error cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fd8189c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `--suggest-timeouts` to report job durations from recent successful GitHub Actions runs and suggest `timeout-minutes` values for jobs with enough samples. * Added options to choose the number of runs reviewed and set the suggestion buffer. Reports are available in text or JSON and do not modify workflow files. * Improved GitHub repository detection for Git worktrees. * **Documentation** * Documented timeout suggestions, options, report details, and limitations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
jdx
added a commit
that referenced
this pull request
Oct 14, 2026
🤖 I have created a release *beep* *boop* --- ## [2.2.0](v2.1.0...v2.2.0) (2026-10-14) ### Features * **action-input-dependencies:** report inputs of well-known actions that need each other ([#207](#207)) ([ef17270](ef17270)) * **action-release-cooldown:** report an action at a version younger than N days (online, opt-in) ([#235](#235)) ([7dbd838](7dbd838)) * **action:** check the inputs of any remote action against its action.yml with --online ([#231](#231)) ([ac30cbe](ac30cbe)) * **actions-lock:** lint GitHub's Actions lock file against the workflows ([#237](#237)) ([c9d8944](c9d8944)) * **argument-injection:** report free text in the program or the options of sed, awk, find and tar ([#221](#221)) ([ecf46dc](ecf46dc)) * **aube:** understand aube in the rules that know npm, pnpm, yarn and bun ([#254](#254)) ([f6d9319](f6d9319)) * **cache-poisoning:** add pnpm/setup, graalvm and composer, and report caches of credential files ([#216](#216)) ([e3c29f0](e3c29f0)) * **cache-poisoning:** report more zizmor findings and add a pedantic tier ([#160](#160)) ([c725459](c725459)) * **cli:** add --ruff, --show-doc-urls, --finding-context, platform forgejo and jujutsu roots ([#215](#215)) ([8e31c5c](8e31c5c)) * **cli:** add shell completions and --explain RULE ([#204](#204)) ([b3b09d3](b3b09d3)) * **cli:** add the options select, no-ignores and list-rules ([#200](#200)) ([dca5092](dca5092)) * **cli:** show only the findings on what changed since a revision (--changed-since) ([#274](#274)) ([ea0ac90](ea0ac90)) * **command-injection:** report input that a script runs as code after it moved to env ([#202](#202)) ([ebeeb6f](ebeeb6f)) * **comment-trigger-authorization:** report comment-triggered jobs that do not check who commented (pedantic) ([#217](#217)) ([60fa75e](60fa75e)) * **concurrency-deadlock:** report a group that a workflow shares with the workflow it calls ([#210](#210)) ([c41e2ad](c41e2ad)) * **concurrency-limits:** report schedule-only and manual-only workflows (pedantic) ([#266](#266)) ([c1ec820](c1ec820)) * **config:** JSON Schemas for the config file and for --format json ([#195](#195)) ([5f83de6](5f83de6)) * **dangerous-triggers:** say what the token and the secrets can do, and make a workflow without privileges pedantic ([#189](#189)) ([97e79e7](97e79e7)) * **deprecated-node-runtime:** report a Node.js runtime that GitHub removed in the metadata of an action (pedantic) ([#225](#225)) ([84dd922](84dd922)) * **deprecated-node-runtime:** report popular actions at majors that declare node20 (pedantic) ([#230](#230)) ([8a9fc95](8a9fc95)) * **deprecated-node-runtime:** report the environment variables that chose the Node.js runtime (pedantic) ([#234](#234)) ([6531d87](6531d87)) * **environment-file-redirect:** report writes to GITHUB_OUTPUT that lose the data ([#184](#184)) ([79cc5ef](79cc5ef)) * **event-name-never-received:** report github.event_name compared with an event the workflow never receives ([#214](#214)) ([b3bfcc4](b3bfcc4)) * **external-tools:** say when shellcheck or pyflakes did not run ([#188](#188)) ([f7280e5](f7280e5)) * **facts:** export the events, permissions, concurrency and calls of a repository as versioned JSON (--export-facts) ([#272](#272)) ([881dde9](881dde9)) * **fail-fast-action-inputs:** report actions that pass when nothing was produced ([#196](#196)) ([f856144](f856144)) * **fix:** say why more findings have no fix, add --show-fix-status and the fix prerequisites to --explain ([#269](#269)) ([7aa3187](7aa3187)) * **gh-without-token:** report the gh command without GH_TOKEN or GITHUB_TOKEN ([#177](#177)) ([1d997da](1d997da)) * **github-app:** check two more token actions and cover local composite actions in dependabot-missing-actions-update ([#229](#229)) ([e5b620e](e5b620e)) * **github-env:** follow text that a script reads with gh and git ([#218](#218)) ([9d0a4bb](9d0a4bb)) * **github-script-syntax:** check the JavaScript of actions/github-script with node --check ([#242](#242)) ([f106562](f106562)) * **hardcoded-registry-credentials:** report credentials in plain text in the registries of dependabot.yml ([#265](#265)) ([765521c](765521c)) * **macos-gnu-flags:** report GNU-only options in run scripts of macOS jobs (pedantic) ([#241](#241)) ([871c07d](871c07d)) * **malformed-expression-delimiters:** report ${ github.ref }, $ {{ x }} and {{ github.sha }} ([#180](#180)) ([e452449](e452449)) * **missing-version-comment:** report a hash pin without a version comment and add it with --online --fix ([#232](#232)) ([97767b4](97767b4)) * **multiline-plain-run:** report a run folded into one line ([#205](#205)) ([f4e76c4](f4e76c4)) * **online:** look up the actions of a GitHub Enterprise Server on github.com ([#250](#250)) ([d8a1280](d8a1280)) * **online:** report coverage and cache freshness outside SARIF ([#166](#166)) ([5fd779e](5fd779e)) * **online:** report the jobs whose environment has no required reviewers ([#247](#247)) ([6c13781](6c13781)) * **output-clobbering:** report input with line breaks written to GITHUB_OUTPUT as a record ([#212](#212)) ([c992eef](c992eef)) * **permissions:** report a job whose permissions are too narrow for its actions ([#192](#192)) ([07d78b7](07d78b7)) * **ref-version-mismatch:** fix the version comment (unsafe) and read ratchet comments ([#227](#227)) ([60842f7](60842f7)) * **release:** publish wheels to PyPI with go-to-wheel ([#277](#277)) ([0440a3a](0440a3a)) * **repojacking:** report an action whose owner name anyone can register ([#248](#248)) ([3b62077](3b62077)) * **runner-label:** accept the labels of hosted-runner providers ([#199](#199)) ([27e453c](27e453c)) * **runner-label:** check runner labels built from a matrix value and text ([#233](#233)) ([b67bbd4](b67bbd4)) * **runner-label:** warn ahead of the retirement of a runner label and name it afterwards ([#182](#182)) ([0c3756b](0c3756b)) * **sarif:** say why a finding with a fix has none in the log ([#164](#164)) ([7a2a509](7a2a509)) * **self-hosted-runner:** count the configured labels and runner groups, and say when a pull request reaches the runner ([#194](#194)) ([80c43f8](80c43f8)) * **shell-syntax:** report syntax errors in run scripts without shellcheck ([#273](#273)) ([31ee66e](31ee66e)) * **shellcheck:** read a shellcheckrc, extra arguments and optional checks from the config ([#191](#191)) ([4d02a46](4d02a46)) * **superfluous-actions:** resolve runs-on expressions the repository tells ([#271](#271)) ([22a3f8e](22a3f8e)) * **template-injection:** report more free text of outsiders in the default profile ([#173](#173)) ([08afe4a](08afe4a)) * **timeouts:** report how long jobs really took in their latest runs (--suggest-timeouts) ([#275](#275)) ([033f615](033f615)) * **ubuntu-slim:** report what the single-CPU runner cannot run ([#190](#190)) ([51a5e7f](51a5e7f)) * **undefined-property:** check the properties of github.event against the webhook payloads ([#249](#249)) ([81a1038](81a1038)) * **unlocked-install:** model the lifecycle scripts and the release age of aube (pedantic) ([#255](#255)) ([f5dfab7](f5dfab7)) * **unlocked-install:** report installs without a release-age cooldown (pedantic) ([#201](#201)) ([13779f6](13779f6)) * **unlocked-install:** report npm, yarn and bun installs that run lifecycle scripts (pedantic) ([#187](#187)) ([ee74c67](ee74c67)) * **unmatched-paths-filter:** report paths filters that match no file of the repository (pedantic) ([#238](#238)) ([2baf2cb](2baf2cb)) * **unpinnable-action:** report a hash-pinned action whose own dependencies are not pinned ([#240](#240)) ([3cc0ddb](3cc0ddb)) * **unpinned-images:** check docker run images in scripts, add per-image policies and image allow/deny lists ([#211](#211)) ([376b065](376b065)) * **unpinned-images:** pin container images to a digest with --online --fix=unsafe ([#239](#239)) ([2f63474](2f63474)) * **unpinned-uses:** accept a ref which the Actions lock file pins to a commit ([#228](#228)) ([c702b7c](c702b7c)) * **unpinned-uses:** accept the tag of an immutable release under --online ([#251](#251)) ([c112760](c112760)) * **unpinned-uses:** name the most specific version tag in the comment of --online --fix ([#224](#224)) ([84dd5fc](84dd5fc)) * **unredacted-secrets:** report values cut from a secret and printed by a script (pedantic) ([#222](#222)) ([c45a29f](c45a29f)) * **untrusted-artifact:** know more download actions and report a download of every artifact with the option pedantic ([#219](#219)) ([bc216f9](bc216f9)) * **untrusted-checkout:** count the actions that run the workspace, with the input that makes them do it ([#213](#213)) ([29fea2c](29fea2c)) * **untrusted-checkout:** cover issue_comment workflows that build a pull request ([#206](#206)) ([6b73b42](6b73b42)) * **untrusted-checkout:** follow a pull request revision through variables and script files ([#159](#159)) ([78813f4](78813f4)) * **untrusted-checkout:** follow the head of a pull request into local reusable workflows and composite actions ([#208](#208)) ([29843f2](29843f2)) * **untrusted-checkout:** honor the fork pull request protection of actions/checkout ([#203](#203)) ([1acb258](1acb258)) * **untrusted-checkout:** report gates that hold at one moment in front of code that can change, and labels that last ([#220](#220)) ([09fc91e](09fc91e)) * **use-trusted-publishing:** report jobs that build, lack an environment or drop attestations (pedantic) ([#209](#209)) ([1c26e5f](1c26e5f)) * **workflow-call:** check the calls of reusable workflows of other repositories with --online ([#236](#236)) ([89f2c75](89f2c75)) * **workflow-secret-scope:** job-level secrets, secrets passed to reusable workflows, and unused workflow_call secrets ([#223](#223)) ([e7a53ef](e7a53ef)) ### Bug Fixes * **action:** check popular actions pinned to a full version or to a commit with a version comment ([#174](#174)) ([d324b7c](d324b7c)) * **api:** accept nil LinterOptions, print the rule ID in Error and PrettyPrint, correct API comments ([#283](#283)) ([e2d3304](e2d3304)) * **changed-since:** count shared findings once, keep ..foo inside the repository, refuse --export-facts ([#279](#279)) ([341ccb5](341ccb5)) * **config:** a missing file in "extends" of a discovered config file is an error ([#285](#285)) ([1a848ff](1a848ff)) * **download-script:** report a missing release, accept amd64, fix the help text ([#282](#282)) ([3165939](3165939)) * **if-always-true:** do not report the newlines at the end of a block scalar ([#226](#226)) ([871eb35](871eb35)) * **ignore:** read own-line ignore directives from real YAML comments only ([#284](#284)) ([3f9e648](3f9e648)) * **missing-timeout:** add accept-step-timeouts for jobs whose steps all set timeout-minutes ([#252](#252)) ([10cfa99](10cfa99)) * **multiline-plain-run:** rename commandWords so the package builds again ([#256](#256)) ([24d8900](24d8900)) * **parser:** accept a whole mapping or list written as one expression where the schema allows it ([#178](#178)) ([5689451](5689451)) * **permissions:** report the pedantic persona's permission findings ([#176](#176)) ([016f588](016f588)) * **runner-label:** follow the images behind the -latest labels ([#171](#171)) ([8e6159e](8e6159e)) * **syntax:** accept keys and values the workflow schema allows but the docs do not list ([#170](#170)) ([66df2e0](66df2e0)) * **syntax:** report what the runner rejects in schedules, merge_group, self-repository uses and action.yml ([#186](#186)) ([f498457](f498457)) * **untrusted-checkout:** follow pull/N/head fetches, skip merged pull requests, count sed -f, awk -f, tar -P and unzip -: ([#175](#175)) ([5fd975f](5fd975f)) ### Performance Improvements * analyze each run script once and parse the callers of an action once ([#270](#270)) ([ac37b69](ac37b69)) * bound concurrent workflow checks to cut peak memory on huge repositories ([#158](#158)) ([bbe49da](bbe49da)) * **online:** read the advisories of all actions with one request ([#268](#268)) ([a04bcd2](a04bcd2)) * share the parsed expressions of a string with the rules that still parsed them again ([#165](#165)) ([9bcdbb0](9bcdbb0)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Mechanical version and documentation updates only; no runtime or rule logic changes in the diff. > > **Overview** > **Release Please** bumps the published version from **2.1.0** to **2.2.0** and keeps every `x-release-please-version` marker aligned: `.release-please-manifest.json`, the GitHub Action’s default `version` input in `action.yml`, the `jactionlint-docker` pre-commit image tag, docs site version (`docs/.vitepress/version.ts`), and install examples in `docs/ci.md` and `docs/hooks.md`. > > `CHANGELOG.md` gains the **2.2.0** section (2026-10-14) summarizing what ships in this release—many new lint rules and CLI options (`--changed-since`, `--export-facts`, `--explain`, online action/workflow validation, Actions lock file linting), security-focused checks (untrusted checkout, cache poisoning, repojacking, etc.), bug fixes, and performance work. This PR does not change linter implementation; it is the version and changelog cut for code already on `main`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e0e7f9d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Part of #167 (section 2,
actions.lock:unpinned-usesaccepts what the lock pins, and the pin fix does not inline a SHA there).What
unpinned-usesno longer reportsuses: owner/repo@v1in a workflow when the repository's.github/workflows/actions.lockpins that ref to a full commit for that workflow. The--onlinepin fix (tag to SHA) is not offered for a step of such a workflow, because a SHA is a ref the lock file has no entry for. A follow-up PR (actions-lockrule that lints the lock file) is based on this branch.The reader is in its own file,
actions_lock.go. It is loaded from the project (Project.actionsLock(), read once per project like the config) and has no effect when the file is absent or cannot be trusted.The real format (primary sources)
I did not guess the format. It is defined and implemented in public repositories:
internal/lockfile/state.go,internal/pipeline/checks/structural.go,internal/workflowfile/workflowfile.go). "Public preview. gh-actions-lock is pre-1.0 ... The lockfile format, command flags, and behavior may change without notice."schema/lockfile-v0.0.3.json,go/pkg/lockfile/{lockfile,compat,pin,uses}.go).actions/ql/lib/codeql/actions/Lock.qll, theactions/unpinned-tagquery). Their test fixtureCWE-829-Lockfileis where the edge cases below come from.What the file is (v0.0.2 and v0.0.3 have this shape; v0.0.3 adds an optional
hostname):Facts that decide the behaviour, with where I found them:
owner/repo@ref; a sub action (actions/cache/save) is pinned by its repository (pin.go, and CodeQL'sLock.qllcoversnwo + "/"prefixes).Pin.Canonical); CodeQL's fixture hasdtolnay/rust-toolchain@V1reported while@v1is accepted.gh actions-lockrecords neither (ParseActionRefreturns nil for a workflow file; zizmor#2371: "This currently doesn't replace pinning callable workflow refs"; CodeQL leaves reusable workflows unchanged), so they are judged as before. Some lock files in the wild do list the pin of a reusable workflow (17 of the 47 remote reusable calls of listed workflows in the corpus below), so the next PR counts a listed one as used, but nothing relies on it../actions are not onboardable;$/pathis the in-repo form, "inherently pinned", and the actions that an in-repo action uses are attributed to the workflows that run it (ParsedWorkflow.Refs: "includes refs found inside in-repo$/…actions"). Real lock files confirm it:shun-shobon/littlexmllistsjdx/mise-actionunderci.yml, which only the composite.github/actions/setupuses.uses:is a different ref.checkNotPinnedin gh-actions-lock reports a bare SHA with no matching lock entry asnot-pinned(it "was never history-checked"). So rewriting a tag to an inline SHA in a listed workflow can make it fail the lock check, which is why the fix is not offered there.tag/branchinstead ofref) is treated as no lock file, so a format change can only bring findings back, never hide one.Behaviour
A step
uses: owner/repo[/path]@refof a workflow is accepted when the lock file:workflowswith the pinowner/repo@ref(same ref), anddependenciesentry for it with the samerefand a full commit digest (sha1-+ 40 hex orsha256-+ 64 hex).For a step of a local action (
action.yml): every workflow that runs the action (directly or through other local actions, using the existing call graph) must satisfy 1 and 2; an action no workflow runs is not locked.Unchanged when: no lock file; the file is not YAML or not a mapping; no or an unknown
version;workflows/dependenciesof the wrong shape; the workflow is not listed; the entry is missing or malformed. Reusable workflow calls and Docker images are always judged as before. New optionunpinned-uses.actions-lock(defaulttrue) switches the lock file off.How it was tested
actions_lock_test.go(goldentestdata/actions_lock/covered.out: sub action, owner case, ref case, other workflow's pin, ref mismatch, non-digest commit, sha256, entry without dependency, unlisted workflow, docker, reusable workflow; plus 18 table cases for unusable files),actions_lock_action_test.go(local actions with one or two callers),actions_lock_fix_test.go(the online fix is withheld in a listed workflow and offered elsewhere).go build ./...,go vet ./...,go test -race ./...andhk check --allpass..github/workflows/actions.lock(GitHub code searchfilename:actions.lock path:.github/workflowsfinds 363; I cloned 82, at most 4 per owner, because 242 are one account's near-identical repositories). With the lock file ignored (actions-lock: false)unpinned-usesreports 1,307 findings in these repositories, with it 57 (71 of the 82 repositories change). I classified all 57: 7 are Docker images and reusable workflow calls (not recorded by the tool, judged as before); 18 are inaction.ymlfiles that no workflow of the repository runs (published actions and test fixtures); 32 are steps of workflows for which the lock file has no exact pin (the lock lists the action at another ref, or not at all). I read the lock files of two of those repositories to confirm that it is staleness and not a reader bug (atomic-works/github-actions-reusable-workflowslocksactions/checkout@v3.7.0and uses@v7.0.1;metadatastician/burblebumpedupload-artifactto v7.0.2 and kept v7.0.1 in the lock), and the independent script agrees. I also checked the other side with an independent script (a regexp read of theuses:lines and a YAML read of the lock, no jactionlint code): of 1,258 tag-pinned stepuses:lines in these workflows it finds 1,225 pinned and 33 not, and jactionlint reports 32 of the 33 (the 33rd is in a file thatgenerated-filesskips by default). No false negative was found.Not measured
🤖 Generated with Claude Code
Note
Medium Risk
Changes default pinning policy for repos using actions.lock and alters online fix behavior for locked workflows; malformed lock files are conservatively ignored rather than partially trusted.
Overview
Adds support for GitHub’s technical-preview Actions lock file (
.github/workflows/actions.lock) sounpinned-usestreats tag-pinneduses:as satisfied when the lock lists the workflow and pins thatowner/repo@refto a full commit digest.A new
actions_lock.gomodule parses lockfile v0.0.2/v0.0.3 (unknown versions are ignored so findings never disappear on format changes), caches the result per linter run, and uses the existing call graph so composite local actions are covered only when every workflow that runs them lists the same pins. Reusable workflows and Docker images are unchanged.unpinned-uses.actions-lock(defaulttrue) disables this behavior. For workflows listed in the lock,--onlinetag→SHA pin fixes are withheld (inline SHAs are a different ref and can failgh actions-lockverification), with guidance to usegh actions-lockinstead.Docs, JSON schema, zizmor parity notes, and broad tests (golden fixtures, unusable lock cases, cache scope, fix behavior) accompany the linter wiring in
RuleEnv,RuleAction, andattachPinFixes.Reviewed by Cursor Bugbot for commit 09c2db5. Bugbot is set up for automated code reviews on this repo. Configure here.