From 39780eb44fdc43511157a171a99fc680569bbca5 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 19:48:06 -0500 Subject: [PATCH 1/4] workflows: add reusable lockfile lint --- .github/workflows/actions.lock | 3 +++ .github/workflows/lint.yml | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 63c8a8d..94ba7da 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -6,6 +6,9 @@ workflows: '.github/workflows/dependency-review.yml': - 'actions/checkout@v5.0.1' - 'actions/dependency-review-action@v5.0.0' + '.github/workflows/lint.yml': + - 'actions/checkout@v5.0.1' + - 'actions/setup-go@v5' '.github/workflows/release.yml': - 'actions/checkout@v6.0.2' - 'cli/gh-extension-precompile@v2.1.0' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8cde963 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +# This workflow is managed by gh actions-lock. + +name: Lint actions.lock + +on: + workflow_call: + inputs: + extra-flags: + description: Extra flags passed to gh actions-lock + required: false + type: string + default: "" + ignore-categories: + description: Comma-separated finding categories to treat as warnings instead of errors + required: false + type: string + default: "" + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v5.0.1 + - uses: $/actions/lint + with: + extra-flags: ${{ inputs.extra-flags }} + ignore-categories: ${{ inputs.ignore-categories }} From f5779c122c0ed41d20a138e6a8c115db3cba3766 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 19:53:39 -0500 Subject: [PATCH 2/4] lockfile: prune dependencies removed by scoped runs --- internal/pin/commit.go | 32 +++++-------------- internal/pin/commit_test.go | 63 +++++++++++++++++++++++++------------ 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/internal/pin/commit.go b/internal/pin/commit.go index 6d946b4..6dcc3ee 100644 --- a/internal/pin/commit.go +++ b/internal/pin/commit.go @@ -58,18 +58,18 @@ func Commit(ctx context.Context, rec *Record, store *lockfile.State, copts *Comm return err } - // Phase 2: Update lockfile entries for each pinned workflow. - // Only write workflows that have at least one genuinely new pin. + // Phase 2: Update lockfile entries for each scanned workflow. pinnedByWorkflow := groupPinnedByWorkflow(rec) - hasNewPin := workflowsWithNewPins(rec) - if len(pinnedByWorkflow) > 0 { + if len(rec.Workflows) > 0 { progress("Updating lockfile") } - for wfPath, deps := range pinnedByWorkflow { - if !hasNewPin[wfPath] { - continue // all entries verified — no write needed - } + for _, wp := range rec.Workflows { + wfPath := wp.Path wfKey := workflowfile.KeyFromPath(wfPath) + deps := pinnedByWorkflow[wfPath] + if len(deps) == 0 && !store.HasWorkflow(wfKey) { + continue + } parentMap := buildParentMap(rec, wfPath) directKeys := buildDirectKeys(rec, wfPath) deps = retainUnresolvablePins(rec, store, wfPath, deps, directKeys) @@ -238,19 +238,3 @@ func buildDirectKeys(rec *Record, wfPath string) map[string]bool { } return keys } - -// workflowsWithNewPins returns the set of workflow paths that contain at -// least one entry with Resolution == Pinned (i.e. genuinely new or changed) -// or a narrowed ref (Verified with AutoFixedRef set, meaning the dep key changed). -func workflowsWithNewPins(rec *Record) map[string]bool { - m := make(map[string]bool) - for _, e := range rec.Entries { - if e.Resolution != Pinned && !(e.Resolution == Verified && e.AutoFixedRef != "") { - continue - } - for _, wf := range e.Workflows { - m[wf] = true - } - } - return m -} diff --git a/internal/pin/commit_test.go b/internal/pin/commit_test.go index 29ad0e8..9abe31b 100644 --- a/internal/pin/commit_test.go +++ b/internal/pin/commit_test.go @@ -1,8 +1,14 @@ package pin import ( + "context" + "os" + "path/filepath" + "strings" "testing" + "github.com/github/gh-actions-lock/internal/dep" + "github.com/github/gh-actions-lock/internal/lockfile" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -103,27 +109,44 @@ func TestBuildDirectKeys(t *testing.T) { assert.NotContains(t, keys, "g/h@v4", "investigate should be excluded") } -func TestWorkflowsWithNewPins(t *testing.T) { - rec := &Record{ - Entries: []Entry{ - {NWO: "a/b", Ref: "v1", Resolution: Pinned, Workflows: []string{"ci.yml", "release.yml"}}, - {NWO: "c/d", Ref: "v2", Resolution: Verified, Workflows: []string{"ci.yml"}}, - {NWO: "e/f", Ref: "v3", Resolution: Investigate, Workflows: []string{"test.yml"}}, - }, - } +func TestCommitRemovesDependenciesDroppedFromWorkflow(t *testing.T) { + dir := t.TempDir() + workflowPath := filepath.Join(".github", "workflows", "ci.yml") + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(workflowPath)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, workflowPath), []byte(`on: push +jobs: + lint: + uses: owner/reusable/.github/workflows/lint.yml@main +`), 0o644)) + t.Chdir(dir) + + store, err := lockfile.LoadState(dir, fakeMeta{}) + require.NoError(t, err) + oldParent := dep.Dependency{NWO: "owner/action", Ref: "main", SHA: strings.Repeat("1", 40), HashAlgo: "sha1"} + oldChild := dep.Dependency{NWO: "actions/setup-go", Ref: "v5", SHA: strings.Repeat("2", 40), HashAlgo: "sha1"} + keep := dep.Dependency{NWO: "actions/checkout", Ref: "v7", SHA: strings.Repeat("3", 40), HashAlgo: "sha1"} + require.NoError(t, store.Set(context.Background(), workflowPath, + []dep.Dependency{oldParent, oldChild, keep}, + map[string][]string{oldChild.Key(): {oldParent.Key()}}, + map[string]bool{oldParent.Key(): true, keep.Key(): true})) + require.NoError(t, store.Save()) - got := workflowsWithNewPins(rec) - assert.True(t, got["ci.yml"]) - assert.True(t, got["release.yml"]) - assert.NotContains(t, got, "test.yml", "non-pinned entries should not contribute") -} - -func TestWorkflowsWithNewPins_empty(t *testing.T) { rec := &Record{ - Entries: []Entry{ - {Resolution: Verified, Workflows: []string{"ci.yml"}}, - }, + Entries: []Entry{{ + NWO: keep.NWO, + Ref: keep.Ref, + SHA: keep.SHA, + Resolution: Verified, + Direct: true, + Workflows: []string{workflowPath}, + }}, + Workflows: []WorkflowPlan{{Path: workflowPath}}, } - got := workflowsWithNewPins(rec) - assert.Empty(t, got) + require.NoError(t, Commit(context.Background(), rec, store, nil)) + + got, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "actions.lock")) + require.NoError(t, err) + assert.Contains(t, string(got), "actions/checkout@v7") + assert.NotContains(t, string(got), "owner/action@main") + assert.NotContains(t, string(got), "actions/setup-go@v5") } From 7ffbed4489c419826411855ae7b8e87a100364a4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 20:13:31 -0500 Subject: [PATCH 3/4] lint: remove misleading lockfile checks --- .github/workflows/actions.lock | 4 -- .github/workflows/lint.yml | 30 ------------ .github/workflows/test.yml | 10 ---- actions/lint/action.yml | 31 ------------ script/lockfile-lint.sh | 87 ---------------------------------- 5 files changed, 162 deletions(-) delete mode 100644 .github/workflows/lint.yml delete mode 100644 actions/lint/action.yml delete mode 100755 script/lockfile-lint.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 94ba7da..413e9c7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -6,15 +6,11 @@ workflows: '.github/workflows/dependency-review.yml': - 'actions/checkout@v5.0.1' - 'actions/dependency-review-action@v5.0.0' - '.github/workflows/lint.yml': - - 'actions/checkout@v5.0.1' - - 'actions/setup-go@v5' '.github/workflows/release.yml': - 'actions/checkout@v6.0.2' - 'cli/gh-extension-precompile@v2.1.0' '.github/workflows/test.yml': - 'actions/checkout@v5.0.1' - - 'actions/setup-go@v5' - 'actions/setup-go@v6.4.0' dependencies: 'actions/attest-build-provenance@36fa7d009e22618ca7cd599486979b8150596c74': diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 8cde963..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This workflow is managed by gh actions-lock. - -name: Lint actions.lock - -on: - workflow_call: - inputs: - extra-flags: - description: Extra flags passed to gh actions-lock - required: false - type: string - default: "" - ignore-categories: - description: Comma-separated finding categories to treat as warnings instead of errors - required: false - type: string - default: "" - -jobs: - lint: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v5.0.1 - - uses: $/actions/lint - with: - extra-flags: ${{ inputs.extra-flags }} - ignore-categories: ${{ inputs.ignore-categories }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ba65e2..183f365 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,13 +66,3 @@ jobs: ruby --version - run: make build - run: make test-integration - - # lockfile-lint: - # runs-on: ubuntu-slim - # permissions: - # contents: read - # steps: - # - uses: actions/checkout@v5.0.1 - # - uses: $/actions/lint - # with: - # ignore-categories: sha-as-ref diff --git a/actions/lint/action.yml b/actions/lint/action.yml deleted file mode 100644 index 4da15f1..0000000 --- a/actions/lint/action.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Lint actions.lock -description: Check that actions.lock is in sync with workflows and every pin still resolves upstream - -inputs: - extra-flags: - description: Extra flags passed to gh actions-lock - required: false - default: "" - ignore-categories: - description: Comma-separated finding categories to treat as warnings instead of errors (e.g. "local-action") - required: false - default: "" - -runs: - using: composite - steps: - - uses: actions/setup-go@v5 - with: - go-version-file: ${{ github.action_path }}/../../go.mod - - - name: Build gh-actions-lock - shell: bash - run: | - cd "${{ github.action_path }}/../.." - go build -o "$RUNNER_TEMP/gh-actions-lock" ./cmd/gh-actions-lock - - - name: Check lockfile is in sync - shell: bash - run: ${{ github.action_path }}/../../script/lockfile-lint.sh "$RUNNER_TEMP/gh-actions-lock" "${{ inputs.ignore-categories }}" ${{ inputs.extra-flags }} - env: - GH_TOKEN: ${{ github.token }} diff --git a/script/lockfile-lint.sh b/script/lockfile-lint.sh deleted file mode 100755 index 18d0fe6..0000000 --- a/script/lockfile-lint.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# Lockfile lint check with rich output for CI. -# Runs gh-actions-lock in read-only mode and produces annotations, -# grouped log output, a lockfile diff, and a job summary. -set -o pipefail - -binary="${1:?usage: lockfile-lint.sh [ignore-categories] [extra-flags...]}" -ignore_categories="${2:-}" -shift 2 2>/dev/null || shift $# - -# Build a jq filter from the comma-separated ignore list. -# Categories in this list become warnings instead of errors. -if [ -n "$ignore_categories" ]; then - jq_ignore_filter=$(echo "$ignore_categories" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R -s 'split("\n") | map(select(length > 0))') -else - jq_ignore_filter='[]' -fi - -# Run the check, capture JSON stdout separately from stderr. -# --rescan re-verifies every recorded pin against upstream (bypasses the -# lockfile fast path) so CI catches stale/unreachable pins, not just -# structural drift. GH_TOKEN (wired by the action) covers resolution. -exit_code=0 -json_output=$($binary --no-fix --rescan --no-interactive --json=valid,findings,workflows "$@" 2>/dev/null) || exit_code=$? - -# If exit code 2, the tool itself failed — re-run to show stderr -if [ "$exit_code" -eq 2 ]; then - echo "::error::gh-actions-lock failed:" - $binary --no-fix --rescan --no-interactive "$@" 2>&1 || true - exit 2 -fi - -valid=$(echo "$json_output" | jq -r '.valid // empty') -num_findings=$(echo "$json_output" | jq -r '.findings // [] | length') - -ignored="$jq_ignore_filter" -blocking_findings=$(echo "$json_output" | jq --argjson ignored "$ignored" '[.findings[] | select(.category as $c | ($ignored | index($c)) | not)] | length') - -# Log findings as annotations -if [ "$num_findings" != "0" ] && [ -n "$num_findings" ]; then - echo "" - echo "::group::Findings ($num_findings)" - echo "$json_output" | jq -r --argjson ignored "$ignored" '.findings[] | select(.category as $c | ($ignored | index($c)) | not) | "::error file=\(.workflow)::[\(.category)] \(.dependency // "n/a"): \(.detail)"' - echo "$json_output" | jq -r --argjson ignored "$ignored" '.findings[] | select(.category as $c | ($ignored | index($c)) | not | not) | "::warning file=\(.workflow)::[\(.category)] \(.detail) (ignored)"' - echo "::endgroup::" -fi - -# Log per-workflow status -echo "" -echo "::group::Workflow status" -echo "$json_output" | jq -r '.workflows[] | (if .valid then "pass" else "FAIL" end) + " " + .path + " (" + (.findings | length | tostring) + " finding(s))"' -echo "::endgroup::" - -# Show what a fix would change -if [ "$blocking_findings" -gt 0 ]; then - echo "" - echo "::group::Lockfile diff (what gh actions-lock would change)" - cp .github/workflows/actions.lock .github/workflows/actions.lock.bak 2>/dev/null || true - $binary --no-interactive "$@" 2>/dev/null || true - diff -u .github/workflows/actions.lock.bak .github/workflows/actions.lock || true - git checkout -- . 2>/dev/null - echo "::endgroup::" -fi - -# Job summary -if [ -n "$GITHUB_STEP_SUMMARY" ]; then - { - echo "## Lockfile lint" - echo "" - if [ "$blocking_findings" -eq 0 ]; then - echo "Lockfile is in sync." - else - echo "Lockfile is **out of sync**. Run \`gh actions-lock\` to fix." - echo "" - echo "### Findings" - echo "" - echo "| Workflow | Category | Dependency | Detail |" - echo "|----------|----------|------------|--------|" - echo "$json_output" | jq -r --argjson ignored "$ignored" '.findings[] | select(.category as $c | ($ignored | index($c)) | not) | "| `\(.workflow)` | \(.category) | `\(.dependency // "n/a")` | \(.detail) |"' - fi - } >> "$GITHUB_STEP_SUMMARY" -fi - -# Exit non-zero only if there are blocking findings -if [ "$blocking_findings" -gt 0 ]; then - exit 1 -fi From 90294321242bf4dd94a5f2f72d60505a806d9f44 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 20:18:50 -0500 Subject: [PATCH 4/4] Revert "lockfile: prune dependencies removed by scoped runs" This reverts commit f5779c122c0ed41d20a138e6a8c115db3cba3766. --- internal/pin/commit.go | 32 ++++++++++++++----- internal/pin/commit_test.go | 63 ++++++++++++------------------------- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/internal/pin/commit.go b/internal/pin/commit.go index 6dcc3ee..6d946b4 100644 --- a/internal/pin/commit.go +++ b/internal/pin/commit.go @@ -58,18 +58,18 @@ func Commit(ctx context.Context, rec *Record, store *lockfile.State, copts *Comm return err } - // Phase 2: Update lockfile entries for each scanned workflow. + // Phase 2: Update lockfile entries for each pinned workflow. + // Only write workflows that have at least one genuinely new pin. pinnedByWorkflow := groupPinnedByWorkflow(rec) - if len(rec.Workflows) > 0 { + hasNewPin := workflowsWithNewPins(rec) + if len(pinnedByWorkflow) > 0 { progress("Updating lockfile") } - for _, wp := range rec.Workflows { - wfPath := wp.Path - wfKey := workflowfile.KeyFromPath(wfPath) - deps := pinnedByWorkflow[wfPath] - if len(deps) == 0 && !store.HasWorkflow(wfKey) { - continue + for wfPath, deps := range pinnedByWorkflow { + if !hasNewPin[wfPath] { + continue // all entries verified — no write needed } + wfKey := workflowfile.KeyFromPath(wfPath) parentMap := buildParentMap(rec, wfPath) directKeys := buildDirectKeys(rec, wfPath) deps = retainUnresolvablePins(rec, store, wfPath, deps, directKeys) @@ -238,3 +238,19 @@ func buildDirectKeys(rec *Record, wfPath string) map[string]bool { } return keys } + +// workflowsWithNewPins returns the set of workflow paths that contain at +// least one entry with Resolution == Pinned (i.e. genuinely new or changed) +// or a narrowed ref (Verified with AutoFixedRef set, meaning the dep key changed). +func workflowsWithNewPins(rec *Record) map[string]bool { + m := make(map[string]bool) + for _, e := range rec.Entries { + if e.Resolution != Pinned && !(e.Resolution == Verified && e.AutoFixedRef != "") { + continue + } + for _, wf := range e.Workflows { + m[wf] = true + } + } + return m +} diff --git a/internal/pin/commit_test.go b/internal/pin/commit_test.go index 9abe31b..29ad0e8 100644 --- a/internal/pin/commit_test.go +++ b/internal/pin/commit_test.go @@ -1,14 +1,8 @@ package pin import ( - "context" - "os" - "path/filepath" - "strings" "testing" - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/lockfile" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -109,44 +103,27 @@ func TestBuildDirectKeys(t *testing.T) { assert.NotContains(t, keys, "g/h@v4", "investigate should be excluded") } -func TestCommitRemovesDependenciesDroppedFromWorkflow(t *testing.T) { - dir := t.TempDir() - workflowPath := filepath.Join(".github", "workflows", "ci.yml") - require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(workflowPath)), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, workflowPath), []byte(`on: push -jobs: - lint: - uses: owner/reusable/.github/workflows/lint.yml@main -`), 0o644)) - t.Chdir(dir) - - store, err := lockfile.LoadState(dir, fakeMeta{}) - require.NoError(t, err) - oldParent := dep.Dependency{NWO: "owner/action", Ref: "main", SHA: strings.Repeat("1", 40), HashAlgo: "sha1"} - oldChild := dep.Dependency{NWO: "actions/setup-go", Ref: "v5", SHA: strings.Repeat("2", 40), HashAlgo: "sha1"} - keep := dep.Dependency{NWO: "actions/checkout", Ref: "v7", SHA: strings.Repeat("3", 40), HashAlgo: "sha1"} - require.NoError(t, store.Set(context.Background(), workflowPath, - []dep.Dependency{oldParent, oldChild, keep}, - map[string][]string{oldChild.Key(): {oldParent.Key()}}, - map[string]bool{oldParent.Key(): true, keep.Key(): true})) - require.NoError(t, store.Save()) - +func TestWorkflowsWithNewPins(t *testing.T) { rec := &Record{ - Entries: []Entry{{ - NWO: keep.NWO, - Ref: keep.Ref, - SHA: keep.SHA, - Resolution: Verified, - Direct: true, - Workflows: []string{workflowPath}, - }}, - Workflows: []WorkflowPlan{{Path: workflowPath}}, + Entries: []Entry{ + {NWO: "a/b", Ref: "v1", Resolution: Pinned, Workflows: []string{"ci.yml", "release.yml"}}, + {NWO: "c/d", Ref: "v2", Resolution: Verified, Workflows: []string{"ci.yml"}}, + {NWO: "e/f", Ref: "v3", Resolution: Investigate, Workflows: []string{"test.yml"}}, + }, } - require.NoError(t, Commit(context.Background(), rec, store, nil)) - got, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "actions.lock")) - require.NoError(t, err) - assert.Contains(t, string(got), "actions/checkout@v7") - assert.NotContains(t, string(got), "owner/action@main") - assert.NotContains(t, string(got), "actions/setup-go@v5") + got := workflowsWithNewPins(rec) + assert.True(t, got["ci.yml"]) + assert.True(t, got["release.yml"]) + assert.NotContains(t, got, "test.yml", "non-pinned entries should not contribute") +} + +func TestWorkflowsWithNewPins_empty(t *testing.T) { + rec := &Record{ + Entries: []Entry{ + {Resolution: Verified, Workflows: []string{"ci.yml"}}, + }, + } + got := workflowsWithNewPins(rec) + assert.Empty(t, got) }