Add --relock to bump moved branch and version refs by nodeselector · Pull Request #88 · github/gh-actions-lock · GitHub
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
13 changes: 13 additions & 0 deletions README.md
90 changes: 90 additions & 0 deletions cmd/gh-actions-lock/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,3 +1067,93 @@ func TestCheckCommand_JSONLoadErrorIsInvalid(t *testing.T) {
require.Len(t, payload.Findings, 1)
assert.Equal(t, "error", payload.Findings[0].Severity)
}

// TestCheck_Relock_BumpsMovedBranchRef covers github/actions-dispatch#751:
// a branch ref (main) whose upstream head advanced is trusted as-is on a
// normal run and merely flagged ref-moved under --rescan. --relock must
// re-resolve it and rewrite the lockfile to the new live SHA.
func TestCheck_Relock_BumpsMovedBranchRef(t *testing.T) {
reg := &httpmock.Registry{}

staleSHA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
liveSHA := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

reg.Register(
httpmock.GraphQLForRepo("example", "action"),
httpmock.JSONResponse(map[string]any{
"data": map[string]any{
"a0": testRepoResponse("example/action", liveSHA, nodeActionYAML),
},
}),
)
// staleSHA is a legitimate ancestor of liveSHA (branch advanced) → ref-moved.
reg.Register(
httpmock.REST("GET", "repos/example/action/compare/"),
httpmock.JSONResponse(map[string]any{
"status": "ahead",
"merge_base_commit": map[string]any{
"sha": staleSHA,
},
}),
)
// Reverse-lookup stubs: main resolves to the live head; no tags.
reg.Register(
httpmock.REST("GET", "repos/example/action/branches"),
httpmock.JSONResponse([]any{
map[string]any{"name": "main", "commit": map[string]any{"sha": liveSHA}},
}),
)
reg.Register(
httpmock.REST("GET", "repos/example/action/tags"),
httpmock.JSONResponse([]any{}),
)

workflowPath := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: example/action@main
`,
"example/action@main=sha1-"+staleSHA,
)

stdout, _, err := runCommandWithHTTP(t, reg,
"--relock", "--no-narrow", workflowPath,
)
require.NoError(t, err, "stdout:\n%s", stdout)

pins := readTempLockfilePins(t)
assert.Contains(t, pins, liveSHA, "--relock should rewrite the lockfile to the live SHA")
assert.NotContains(t, pins, staleSHA, "--relock should drop the stale SHA")
}

// TestCheck_DefaultRun_DoesNotBumpBranchRef is the counterpart to the relock
// test: without --relock a mutable branch ref is trusted from the lockfile
// (fast path, no network) and its recorded SHA is left as-is.
func TestCheck_DefaultRun_DoesNotBumpBranchRef(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t) // no stubs should be hit on the fast path

staleSHA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

workflowPath := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: example/action@main
`,
"example/action@main=sha1-"+staleSHA,
)

_, _, err := runCommandWithHTTP(t, reg, workflowPath)
require.NoError(t, err)

assert.Contains(t, readTempLockfilePins(t), staleSHA,
"a default run must not bump a trusted branch ref")
}
19 changes: 12 additions & 7 deletions cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ type checkOptions struct {
// ref-moved: prunes the stale lockfile entry and re-pins to the
// current live SHA.
acceptMoved bool
// relock re-resolves deps whose branch or partial-version ref has
// legitimately advanced (ref-moved) to the current upstream SHA.
// Unlike acceptMoved it does NOT accept unreachable-pin findings
// (possible tampering), which stay hard errors.
relock bool
// verify is a convenience alias for --rescan --no-fix: full
// re-verification of every pin with a non-zero exit on any finding.
verify bool
Expand Down Expand Up @@ -84,6 +89,7 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) {
cmd.Flags().StringSliceVar(&opts.allowRunners, "allow-runners", nil, "Deprecated no-op: runner restrictions have been removed")
cmd.Flags().BoolVarP(&opts.allowAllRunners, "allow-all-runners", "A", false, "Deprecated no-op: runner restrictions have been removed")
cmd.Flags().BoolVar(&opts.acceptMoved, "accept-moved", false, "Re-resolve deps flagged as ref-moved or unreachable-pin to their current live SHA")
cmd.Flags().BoolVar(&opts.relock, "relock", false, "Bump moved branch/version refs (e.g. main, v4) to their current upstream SHA; leaves unreachable pins as errors")
cmd.Flags().BoolVar(&opts.verify, "verify", false, "Full re-verification of every pin (equivalent to --rescan --no-fix)")
cmd.Flags().BoolVar(&opts.verifyLocal, "verify-local", false,
"Offline lockfile coverage check: verify every action ref has a lockfile entry.\n"+
Expand All @@ -103,11 +109,8 @@ func (opts *checkOptions) validateOutputFlags() error {
if opts.verify && opts.verifyLocal {
return fmt.Errorf("--verify and --verify-local are mutually exclusive")
}
if opts.verifyLocal && opts.rescan {
return fmt.Errorf("--verify-local is offline and cannot be combined with --rescan")
}
if opts.verifyLocal && opts.acceptMoved {
return fmt.Errorf("--verify-local is offline and cannot be combined with --accept-moved")
if opts.verifyLocal && (opts.rescan || opts.acceptMoved || opts.relock) {
return fmt.Errorf("--verify-local cannot be combined with --rescan, --accept-moved, or --relock because it runs offline")
}
return nil
}
Expand Down Expand Up @@ -171,8 +174,9 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
// Pre-warm resolver caches from the lockfile so repeat runs skip
// redundant GraphQL and REST calls. Skipped when --rescan is set:
// a full re-verification must hit the network to detect ref movement.
// --accept-moved implies --rescan (must detect what moved).
if opts.acceptMoved {
// --accept-moved and --relock both imply --rescan (must detect what
// moved before re-pinning).
if opts.acceptMoved || opts.relock {
opts.rescan = true
}
trustLockfileCaches := !opts.rescan
Expand Down Expand Up @@ -337,6 +341,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
Version: cliVersion(),
NoNarrow: opts.noNarrow,
AcceptMoved: opts.acceptMoved,
Relock: opts.relock,
})
endPlan()
if planErr != nil {
Expand Down
5 changes: 5 additions & 0 deletions cmd/gh-actions-lock/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ func TestValidateOutputFlags_VerifyConflicts(t *testing.T) {
opts: checkOptions{verifyLocal: true, acceptMoved: true},
wantErr: "offline",
},
{
name: "verify-local and relock conflict",
opts: checkOptions{verifyLocal: true, relock: true},
wantErr: "offline",
},
{
name: "verify alone is valid",
opts: checkOptions{verify: true},
Expand Down
107 changes: 80 additions & 27 deletions internal/pin/plan.go
Loading