Skip to content

feat(unpinned-uses): accept a ref which the Actions lock file pins to a commit - #228

Merged
jdx merged 18 commits into
mainfrom
feat/actions-lock-unpinned-uses
Oct 13, 2026
Merged

jdx merged 18 commits into
mainfrom
feat/actions-lock-unpinned-uses

Conversation

@jdx

@jdx jdx commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Part of #167 (section 2, actions.lock: unpinned-uses accepts what the lock pins, and the pin fix does not inline a SHA there).

What

unpinned-uses no longer reports uses: owner/repo@v1 in a workflow when the repository's .github/workflows/actions.lock pins that ref to a full commit for that workflow. The --online pin 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-lock rule 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:

  • Producer, behaviour: github/gh-actions-lock (README, 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."
  • Format and parser: github/actions-lockfile (schema/lockfile-v0.0.3.json, go/pkg/lockfile/{lockfile,compat,pin,uses}.go).
  • How CodeQL reads it: github/codeql#22487 (extraction) and #22155 (actions/ql/lib/codeql/actions/Lock.qll, the actions/unpinned-tag query). Their test fixture CWE-829-Lockfile is where the edge cases below come from.
  • Roadmap: github/roadmap#1253 (preview) and #1268 (GA); the 2026 security roadmap post describes the idea (it still says the lock lives in the workflow YAML; what shipped is the file). Runner: actions/runner#4546 logs "Running with locked dependencies". zizmor tracks it in zizmor#2371.

What the file is (v0.0.2 and v0.0.3 have this shape; v0.0.3 adds an optional hostname):

version: 'v0.0.3'
workflows:                                   # a workflow path -> the actions its file uses (direct uses only)
    '.github/workflows/ci.yml':
        - 'actions/checkout@v6.0.2'          # "owner/repo@ref", owner and repo lower case, ref as written
dependencies:                                # the dependency graph, one entry per "owner/repo@ref"
    'actions/checkout@v6.0.2':
        ref: 'v6.0.2'                        # equal to the ref of the key (unless the key's ref is a full SHA)
        commit: 'sha1-de0fac2e...'           # sha1-<40 hex> or sha256-<64 hex>
        owner_id: 44036562                   # positive integers; they detect a transferred or re-created repository
        repo_id: 197814629
        uses:                                # only composite actions: their own direct uses (transitive graph)
            - 'actions/cache@v4'

Facts that decide the behaviour, with where I found them:

  • Per workflow. "workflows are onboarded to the lockfile on a per-workflow basis" (gh-actions-lock README). The runner holds only listed workflows to it.
  • Repository granularity. A pin is owner/repo@ref; a sub action (actions/cache/save) is pinned by its repository (pin.go, and CodeQL's Lock.qll covers nwo + "/" prefixes).
  • Case. owner/repo are canonicalised to lower case, the ref is case sensitive (Pin.Canonical); CodeQL's fixture has dtolnay/rust-toolchain@V1 reported while @v1 is accepted.
  • Reusable workflows and Docker images. The current gh actions-lock records neither (ParseActionRef returns 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.
  • Local actions. ./ actions are not onboardable; $/path is 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/littlexml lists jdx/mise-action under ci.yml, which only the composite .github/actions/setup uses.
  • A SHA written in uses: is a different ref. checkNotPinned in gh-actions-lock reports a bare SHA with no matching lock entry as not-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.
  • Versions. The official parser accepts v0.0.1-v0.0.3 and rejects unknown top-level keys. Mine is more tolerant on purpose (unknown keys are ignored) and stricter on versions: only v0.0.2 and v0.0.3 are trusted; any other version (a future one, v0.0.1 whose entries have tag/branch instead of ref) 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]@ref of a workflow is accepted when the lock file:

  1. lists the workflow under workflows with the pin owner/repo@ref (same ref), and
  2. has a dependencies entry for it with the same ref and a full commit digest (sha1- + 40 hex or sha256- + 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/dependencies of 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 option unpinned-uses.actions-lock (default true) switches the lock file off.

How it was tested

  • Unit and fixture tests: actions_lock_test.go (golden testdata/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 ./... and hk check --all pass.
  • Corpus: the repositories which have a .github/workflows/actions.lock (GitHub code search filename:actions.lock path:.github/workflows finds 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-uses reports 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 in action.yml files 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-workflows locks actions/checkout@v3.7.0 and uses @v7.0.1; metadatastician/burble bumped upload-artifact to 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 the uses: lines and a YAML read of the lock, no jactionlint code): of 1,258 tag-pinned step uses: 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 that generated-files skips by default). No false negative was found.

Not measured

  • Whether the runner really enforces the file on a given plan (the sources say the preview is public and describe enforcement for listed workflows, I could not verify which plans enforce it), so the docs say jactionlint trusts the checked-in file.
  • The false-positive rate of workflows that are not listed (they are reported as before, which is what the previous behaviour was).
  • Lock files of v0.0.1 are not read.

🤖 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) so unpinned-uses treats tag-pinned uses: as satisfied when the lock lists the workflow and pins that owner/repo@ref to a full commit digest.

A new actions_lock.go module 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 (default true) disables this behavior. For workflows listed in the lock, --online tag→SHA pin fixes are withheld (inline SHAs are a different ref and can fail gh actions-lock verification), with guidance to use gh actions-lock instead.

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, and attachPinFixes.

Reviewed by Cursor Bugbot for commit 09c2db5. Bugbot is set up for automated code reviews on this repo. Configure here.

jdx and others added 2 commits October 13, 2026 03:19
… 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>
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7abda621-67d0-4d84-b1e8-c1fa18ee585a
📥 Commits

Reviewing files that changed from the base of the PR and between 6d59dcf and 09c2db5.

⛔ Files ignored due to path filters: 3
  • testdata/actions_lock/covered.out is excluded by !**/*.out
  • testdata/actions_lock/covered/.github/workflows/actions.lock is excluded by !**/*.lock
  • testdata/actions_lock/covered_option_off.out is excluded by !**/*.out
📒 Files selected for processing: 18
  • actions_lock.go
  • actions_lock_action_test.go
  • actions_lock_cache_test.go
  • actions_lock_fix_test.go
  • actions_lock_test.go
  • docs/checks.md
  • docs/rules.md
  • docs/zizmor-parity.md
  • linter.go
  • online_pin.go
  • online_pin_test.go
  • rule_action.go
  • rule_registry.go
  • rule_unpinned_uses.go
  • schema/jactionlint-config.json
  • testdata/actions_lock/covered/.github/workflows/ci.yml
  • testdata/actions_lock/covered/.github/workflows/other.yml
  • testdata/actions_lock/covered/.github/workflows/unlisted.yml
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread linter.go Resolved
Comment thread actions_lock.go Resolved
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>
jdx and others added 7 commits October 13, 2026 17:13
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread actions_lock.go Resolved
jdx and others added 3 commits October 13, 2026 19:08
…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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread actions_lock.go Resolved
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread actions_lock.go Resolved
jdx and others added 2 commits October 13, 2026 19:42
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
jdx merged commit c702b7c into main Oct 13, 2026
16 of 17 checks passed
@jdx
jdx deleted the feat/actions-lock-unpinned-uses branch October 13, 2026 19:59
@jdx jdx mentioned this pull request Oct 13, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant