Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ To check without writing anything, for example in CI, run:
gh actions-lock --verify
```

`--verify` also checks that a commit locked under a branch or partial version
(e.g. `main`, `v4`) is still in that ref's history. `--verify-local` runs
offline and only checks that every ref has a lockfile entry, not that the
entries are trustworthy; use `--verify` in CI for that.

Pins to branches or partial versions (e.g. `main`, `v4`) are not advanced when
upstream moves. To regenerate the lockfile from the current upstream commits,
run:
Expand Down
4 changes: 3 additions & 1 deletion cmd/gh-actions-lock/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n"
func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any {
return map[string]any{
"nameWithOwner": nameWithOwner,
// commit and tag answer the parity query from the same fixture.
// commit, tag and head answer the parity query from the same fixture.
"commit": map[string]any{"oid": oid},
"tag": map[string]any{"oid": oid},
"head": map[string]any{"oid": oid},
"object": map[string]any{
"oid": oid,
"file": map[string]any{
Expand Down Expand Up @@ -248,6 +249,7 @@ func parityOK(req *http.Request) (*http.Response, error) {
"nameWithOwner": owner + "/" + body.Variables[fmt.Sprintf("name%d", i)].(string),
"commit": oid,
"tag": oid,
"head": oid,
}
}
return httpmock.JSONResponse(map[string]any{"data": data})(req)
Expand Down
14 changes: 9 additions & 5 deletions cmd/gh-actions-lock/format/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func renderTermFindingDetail(out *ui.UI, f checks.Finding, dep string) {
}
out.TermDetail("%s %s %s", icon, out.TermDim(label), dep)
out.TermDetail(" %s", f.Detail)
if f.Category == checks.UnreachablePin && f.Dependency != nil {
if f.Category == checks.UnreachablePin && !f.RefMissing && f.Dependency != nil {
owner, repo := f.Dependency.OwnerRepo()
if owner != "" {
out.TermDetail(" ↳ %s", out.TermDim(DepReleaseURL(f.Dependency.Hostname, owner+"/"+repo, nil)))
Expand All @@ -174,10 +174,14 @@ func renderTermFindingDetail(out *ui.UI, f checks.Finding, dep string) {
}
}

// findingLabel is categoryLabel with a terminal-only title for refs that
// don't exist upstream, which share the not-pinned category in JSON.
// findingLabel is categoryLabel with terminal-only titles for refs that
// don't exist upstream, which share the not-pinned and unreachable-pin
// categories in JSON.
func findingLabel(f checks.Finding) string {
if f.RefMissing {
switch {
case f.RefMissing && f.Category == checks.UnreachablePin:
return "Ref no longer exists"
case f.RefMissing:
return "Ref does not exist"
}
return categoryLabel(f.Category)
Expand Down Expand Up @@ -294,7 +298,7 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) {
}
out.Detail("%s %s %s", icon, out.Dim(label), dep)
out.Detail(" %s", f.Detail)
if f.Category == checks.UnreachablePin && f.Dependency != nil {
if f.Category == checks.UnreachablePin && !f.RefMissing && f.Dependency != nil {
owner, repo := f.Dependency.OwnerRepo()
if owner != "" {
out.Detail(" ↳ %s", out.Dim(DepReleaseURL(f.Dependency.Hostname, owner+"/"+repo, nil)))
Expand Down
234 changes: 234 additions & 0 deletions cmd/gh-actions-lock/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,237 @@ func TestParity_DependabotProxyFirstRunKeepsRenamedName(t *testing.T) {
assert.Contains(t, lock, "'krzema12/github-actions-typing@v2.2.2':")
assert.NotContains(t, lock, "typesafegithub")
}

// TestParity_VerifyMutableRefHistory: --verify checks that a commit locked
// under a mutable ref is still in that ref's history, since a fork-network
// commit exists under the upstream name. A legitimately moved ref stays
// sticky and silent. Plain runs make no extra calls.
func TestParity_VerifyMutableRefHistory(t *testing.T) {
const live = "cccccccccccccccccccccccccccccccccccccccc"
compareURL := `repos/actions/checkout/compare/` + parityTestSHA + `\.\.\.` + live
tests := []struct {
name string
compare httpmock.Responder
// want is the finding category, "" for a clean pass.
want string
}{
{"impostor blocks", httpmock.JSONResponse(map[string]any{"status": "diverged", "merge_base_commit": map[string]any{"sha": parityTestMoved}}), "unreachable-pin"},
{"moved forward passes", httpmock.JSONResponse(map[string]any{"status": "ahead", "merge_base_commit": map[string]any{"sha": parityTestSHA}}), ""},
{"compare 503 is inconclusive", httpmock.StatusResponse(http.StatusServiceUnavailable), "reachability-unknown"},
}
for _, tt := range tests {
for _, proxy := range []bool{false, true} {
t.Run(tt.name+"/"+map[bool]string{false: "graphql", true: "dependabot proxy"}[proxy], func(t *testing.T) {
reg := &httpmock.Registry{}
if proxy {
t.Setenv("GH_ACTIONS_LOCK_DEPENDABOT_PROXY", "1")
useDefaultTransport(t, reg)
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout$`), httpmock.JSONResponse(map[string]any{
"full_name": "actions/checkout", "id": 1, "owner": map[string]any{"id": 1},
}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/`+parityTestSHA+`$`), httpmock.JSONResponse(map[string]any{"sha": parityTestSHA}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/v4$`), httpmock.JSONResponse(map[string]any{"sha": live}))
} else {
reg.Register(httpmock.GraphQL(`head: object\(expression`), httpmock.JSONResponse(map[string]any{
"data": map[string]any{"a0": map[string]any{
"nameWithOwner": "actions/checkout",
"commit": map[string]any{"oid": parityTestSHA},
"head": map[string]any{"oid": live},
}},
}))
}
for range 10 {
reg.Register(httpmock.REST("GET", compareURL), tt.compare)
}
path := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
`, "actions/checkout@v4=sha1-"+parityTestSHA)

stdout, stderr, err := runCommandWithHTTP(t, reg, "--verify", "--json=valid,findings", path)
if proxy && tt.want == "reachability-unknown" {
require.Error(t, err, stderr)
assert.NotErrorIs(t, err, errSilent, "inconclusive under the proxy is a retry")
assert.Contains(t, err.Error(), "HTTP 503")
return
}
var p parityPayload
require.NoError(t, json.Unmarshal([]byte(stdout), &p), stdout+stderr)
switch tt.want {
case "":
require.NoError(t, err, stderr)
assert.Empty(t, p.Findings, "a moved mutable ref stays sticky and silent")
case "unreachable-pin":
require.ErrorIs(t, err, errSilent)
require.Len(t, p.Findings, 1)
assert.Equal(t, "unreachable-pin", p.Findings[0].Category)
assert.Equal(t, "error", p.Findings[0].Severity)
assert.Contains(t, p.Findings[0].Detail, "is not in actions/checkout's history")
assert.Contains(t, p.Findings[0].Detail, "v4 currently points at cccccccccccc")
assert.NotEmpty(t, p.Findings[0].DocURL, "the impostor keeps its doc link")
default:
require.NoError(t, err, stderr)
require.Len(t, p.Findings, 1)
assert.Equal(t, tt.want, p.Findings[0].Category)
assert.Equal(t, "warning", p.Findings[0].Severity)
}
})
}
}

for _, proxy := range []bool{false, true} {
t.Run("plain run makes no extra calls/"+map[bool]string{false: "graphql", true: "dependabot proxy"}[proxy], func(t *testing.T) {
reg := &httpmock.Registry{}
if proxy {
t.Setenv("GH_ACTIONS_LOCK_DEPENDABOT_PROXY", "1")
useDefaultTransport(t, reg)
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout$`), httpmock.JSONResponse(map[string]any{
"full_name": "actions/checkout", "id": 1, "owner": map[string]any{"id": 1},
}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/`+parityTestSHA+`$`), httpmock.JSONResponse(map[string]any{"sha": parityTestSHA}))
} else {
reg.Register(httpmock.GraphQL(`head: object\(expression`), func(*http.Request) (*http.Response, error) {
t.Error("a plain run must not resolve mutable refs")
return nil, io.EOF
})
registerParity(reg)
}
path := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
`, "actions/checkout@v4=sha1-"+parityTestSHA)

_, stderr, err := runCommandWithHTTP(t, reg, "--no-onboard", "--no-narrow", "--no-interactive", path)
require.NoError(t, err, stderr)
for _, req := range reg.Requests {
assert.NotContains(t, req.URL.Path, "/compare/")
assert.NotContains(t, req.URL.Path, "/commits/v4")
}
})
}
}

// TestParity_VerifyMissingMutableRefBlocks: under --verify a mutable ref
// that no longer exists is a definitive answer the runner rejects, so it
// blocks rather than passing on the commit alone.
func TestParity_VerifyMissingMutableRefBlocks(t *testing.T) {
for _, proxy := range []bool{false, true} {
t.Run(map[bool]string{false: "graphql", true: "dependabot proxy"}[proxy], func(t *testing.T) {
reg := &httpmock.Registry{}
if proxy {
t.Setenv("GH_ACTIONS_LOCK_DEPENDABOT_PROXY", "1")
useDefaultTransport(t, reg)
}
// One pass for --json, one for the terminal.
for range 2 {
if proxy {
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout$`), httpmock.JSONResponse(map[string]any{
"full_name": "actions/checkout", "id": 1, "owner": map[string]any{"id": 1},
}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/`+parityTestSHA+`$`), httpmock.JSONResponse(map[string]any{"sha": parityTestSHA}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/v4$`), httpmock.StatusResponse(http.StatusNotFound))
} else {
reg.Register(httpmock.GraphQL(`head: object\(expression`), httpmock.JSONResponse(map[string]any{
"data": map[string]any{"a0": map[string]any{
"nameWithOwner": "actions/checkout",
"commit": map[string]any{"oid": parityTestSHA},
"head": nil,
}},
}))
}
}
path := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
`, "actions/checkout@v4=sha1-"+parityTestSHA)

stdout, stderr, err := runCommandWithHTTP(t, reg, "--verify", "--json=valid,findings", path)
require.ErrorIs(t, err, errSilent, stderr)
var p parityPayload
require.NoError(t, json.Unmarshal([]byte(stdout), &p), stdout+stderr)
require.Len(t, p.Findings, 1)
assert.Equal(t, "unreachable-pin", p.Findings[0].Category)
assert.Equal(t, "error", p.Findings[0].Severity)
assert.Equal(t, "ref v4 no longer exists in actions/checkout; the runner will reject this pin", p.Findings[0].Detail)
assert.Equal(t, "point `uses:` at a tag or branch that exists, then run `gh actions-lock`", p.Findings[0].Remediation)
assert.Empty(t, p.Findings[0].DocURL, "the hardening guide is unrelated to a missing ref")

stdout, stderr, err = runCommandWithHTTP(t, reg, "--verify", path)
require.ErrorIs(t, err, errSilent)
term := stdout + stderr
assert.Contains(t, term, "Ref no longer exists actions/checkout@v4")
assert.Contains(t, term, "ref v4 no longer exists in actions/checkout; the runner will reject this pin")
assert.Contains(t, term, "point `uses:` at a tag or branch that exists, then run `gh actions-lock`")
assert.NotContains(t, term, "not in upstream history")
assert.NotContains(t, term, "/releases")
assert.NotContains(t, term, "see:")
})
}
}

// TestParity_VerifyRefCaseSensitive: refs differing only in case are
// distinct refs to the runner, so each gets its own history check.
func TestParity_VerifyRefCaseSensitive(t *testing.T) {
for _, proxy := range []bool{false, true} {
t.Run(map[bool]string{false: "graphql", true: "dependabot proxy"}[proxy], func(t *testing.T) {
reg := &httpmock.Registry{}
var heads []string
if proxy {
t.Setenv("GH_ACTIONS_LOCK_DEPENDABOT_PROXY", "1")
useDefaultTransport(t, reg)
for range 2 {
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout$`), httpmock.JSONResponse(map[string]any{
"full_name": "actions/checkout", "id": 1, "owner": map[string]any{"id": 1},
}))
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/`+parityTestSHA+`$`), httpmock.JSONResponse(map[string]any{"sha": parityTestSHA}))
}
for _, ref := range []string{"Main", "main"} {
reg.Register(httpmock.REST("GET", `^/repos/actions/checkout/commits/`+ref+`$`), func(req *http.Request) (*http.Response, error) {
heads = append(heads, ref)
return httpmock.JSONResponse(map[string]any{"sha": parityTestSHA})(req)
})
}
} else {
reg.Register(httpmock.GraphQL(`head: object\(expression`), func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
for _, ref := range []string{"Main", "main"} {
if strings.Contains(string(body), `"`+ref+`^{commit}"`) {
heads = append(heads, ref)
}
}
repo := map[string]any{"nameWithOwner": "actions/checkout", "commit": map[string]any{"oid": parityTestSHA}, "head": map[string]any{"oid": parityTestSHA}}
return httpmock.JSONResponse(map[string]any{"data": map[string]any{"a0": repo, "a1": repo}})(req)
})
}
path := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@Main
- uses: actions/checkout@main
`, "actions/checkout@Main=sha1-"+parityTestSHA, "actions/checkout@main=sha1-"+parityTestSHA)

_, stderr, err := runCommandWithHTTP(t, reg, "--verify", path)
require.NoError(t, err, stderr)
assert.ElementsMatch(t, []string{"Main", "main"}, heads)
})
}
}
4 changes: 3 additions & 1 deletion cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) {
cmd.Flags().BoolVar(&opts.verify, "verify", false, "Frozen check: run the same checks as the default without writing; fails if the lockfile is incomplete or the runner would reject a pin")
cmd.Flags().BoolVar(&opts.verifyLocal, "verify-local", false,
"Offline lockfile coverage check: verify every action ref has a lockfile entry.\n"+
"No network calls, no authentication required — ideal for pre-commit hooks.")
"No network calls, no authentication required — ideal for pre-commit hooks.\n"+
"It does not check that the entries are trustworthy; use --verify in CI for that.")
cmd.Flags().StringVar(&opts.profileDir, "profile", "", "Enable profiling: write trace, CPU profile, and HTTP log to `dir`")
cmd.Flags().BoolVar(&opts.noMigrateLocalActions, "no-migrate-local-actions", false,
"Do not rewrite same-repo `uses: ./…` actions to the inherently-pinned `uses: $/…` form.\n"+
Expand Down Expand Up @@ -295,6 +296,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
Store: store,
Pool: pool,
Relock: relock,
Verify: opts.verify,
Profile: prof,
}
// Defer spinner start until actual network work begins. The fast path
Expand Down
Loading
Loading