Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
// Commit: write all changes to disk atomically (fast local I/O, no
// spinner label — it finishes before the user could read one).
endCommit := prof.Phase("pin.Commit (disk writes)")
if err := pin.Commit(ctx, record, store, nil); err != nil {
if err := pin.Commit(ctx, record, store, &pin.CommitOptions{SkipNewWorkflowEntries: noOnboardFlag(cmd)}); err != nil {
console.StopProgress()
return fmt.Errorf("committing pins: %w", err)
}
Expand Down
13 changes: 12 additions & 1 deletion internal/pin/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
type CommitOptions struct {
// OnProgress is called at each phase boundary. Nil means no progress.
OnProgress func(phase string)
// SkipNewWorkflowEntries forces the Commit phase to skip workflows with no existing lockfile entry.
SkipNewWorkflowEntries bool
}

// Commit writes a planned Record to disk: rewrites workflow files and
Expand All @@ -29,6 +31,15 @@ func Commit(ctx context.Context, rec *Record, store *lockfile.State, copts *Comm
if copts != nil && copts.OnProgress != nil {
progress = copts.OnProgress
}
if copts != nil && copts.SkipNewWorkflowEntries {
workflows := rec.Workflows[:0]
for _, wp := range rec.Workflows {
if store.HasWorkflow(workflowfile.KeyFromPath(wp.Path)) {
workflows = append(workflows, wp)
}
}
rec.Workflows = workflows
}

// Phase 1: Rewrite workflow files (uses: line changes).
if len(rec.Workflows) > 0 {
Expand Down Expand Up @@ -67,7 +78,7 @@ func Commit(ctx context.Context, rec *Record, store *lockfile.State, copts *Comm
wfPath := wp.Path
wfKey := workflowfile.KeyFromPath(wfPath)
deps := pinnedByWorkflow[wfPath]
if len(deps) == 0 && !store.HasWorkflow(wfKey) {
if len(deps) == 0 && !store.HasWorkflow(wfKey) && wp.ResolveErr != nil {
continue
}
parentMap := buildParentMap(rec, wfPath)
Expand Down
84 changes: 84 additions & 0 deletions internal/pin/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,90 @@ func TestBuildDirectKeys(t *testing.T) {
assert.NotContains(t, keys, "g/h@v4", "investigate should be excluded")
}

func TestCommitDependencyFreeWorkflow(t *testing.T) {
tests := []struct {
name string
resolveErr error
skipNewWorkflowEntries bool
wantEntry bool
}{
{"records empty entry", nil, false, true},
{"skips unresolved workflow", assert.AnError, false, false},
{"skips onboarding", nil, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(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\n"), 0o644))
t.Chdir(dir)

store, err := lockfile.LoadState(dir, fakeMeta{})
require.NoError(t, err)
rec := &Record{Workflows: []WorkflowPlan{{Path: workflowPath, ResolveErr: tt.resolveErr}}}

require.NoError(t, Commit(context.Background(), rec, store, &CommitOptions{SkipNewWorkflowEntries: tt.skipNewWorkflowEntries}))
assert.Equal(t, tt.wantEntry, store.HasWorkflow(workflowPath))
})
}
}

func TestCommitPartialResolution(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\n"), 0o644))
t.Chdir(dir)

store, err := lockfile.LoadState(dir, fakeMeta{})
require.NoError(t, err)
rec := &Record{
Entries: []Entry{{
NWO: "actions/checkout", Ref: "v4", SHA: strings.Repeat("a", 40),
Resolution: Pinned, Workflows: []string{workflowPath}, Direct: true,
}},
Workflows: []WorkflowPlan{{Path: workflowPath, ResolveErr: assert.AnError}},
}

require.NoError(t, Commit(context.Background(), rec, store, nil))
deps, err := store.Get(workflowPath)
require.NoError(t, err)
assert.Len(t, deps, 1)
}

func TestCommitSkipNewWorkflowEntriesPreventsRewrites(t *testing.T) {
dir := t.TempDir()
workflowPath := filepath.Join(".github", "workflows", "ci.yml")
actionPath := filepath.Join(".github", "actions", "local", "action.yml")
oldUses := "actions/checkout@" + strings.Repeat("a", 40)
newUses := "actions/checkout@v4.2.0"
workflowContent := []byte("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: " + oldUses + "\n")
actionContent := []byte("name: local\nruns:\n using: composite\n steps:\n - uses: " + oldUses + "\n")
for path, content := range map[string][]byte{workflowPath: workflowContent, actionPath: actionContent} {
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(path)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, path), content, 0o644))
}
t.Chdir(dir)

store, err := lockfile.LoadState(dir, fakeMeta{})
require.NoError(t, err)
rec := &Record{Workflows: []WorkflowPlan{{
Path: workflowPath,
Rewrites: map[string]string{oldUses: newUses},
SelfActionFiles: []string{actionPath},
}}}

require.NoError(t, Commit(context.Background(), rec, store, &CommitOptions{SkipNewWorkflowEntries: true}))
workflowAfter, err := os.ReadFile(workflowPath)
require.NoError(t, err)
actionAfter, err := os.ReadFile(actionPath)
require.NoError(t, err)
assert.Equal(t, workflowContent, workflowAfter)
assert.Equal(t, actionContent, actionAfter)
assert.False(t, store.HasWorkflow(workflowPath))
}

func TestCommitRemovesDependenciesDroppedFromWorkflow(t *testing.T) {
dir := t.TempDir()
workflowPath := filepath.Join(".github", "workflows", "ci.yml")
Expand Down
7 changes: 4 additions & 3 deletions internal/pin/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ type planResult struct {
func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOptions, status func(string)) (planResult, error) {
var entries []Entry
var wplans []WorkflowPlan
if wr.SkipCommit {
if wr.SkipCommit || wr.BlockingResolverError {
return planResult{entries: verifiedEntries(wr.Inventory, wr.Path)}, nil
}
for _, finding := range wr.Findings {
Expand Down Expand Up @@ -204,7 +204,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption
if resolveErr != nil {
entries = append(entries, unresolvedEntries(wr, unrecordedRefs, deps, resolveErr)...)
if len(deps) == 0 {
wplans = append(wplans, WorkflowPlan{Path: wr.Path, SelfActionFiles: wr.SelfActionFiles})
wplans = append(wplans, WorkflowPlan{Path: wr.Path, SelfActionFiles: wr.SelfActionFiles, ResolveErr: resolveErr})
return planResult{entries: entries, wplans: wplans}, nil
}
// Fall through with partial deps to pin what we can.
Expand Down Expand Up @@ -295,10 +295,11 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption
Path: wr.Path,
Rewrites: rewrites,
SelfActionFiles: wr.SelfActionFiles,
ResolveErr: resolveErr,
})
} else if len(wplans) == 0 {
// Keep the workflow in the plan so its lockfile entry is updated.
wplans = append(wplans, WorkflowPlan{Path: wr.Path, SelfActionFiles: wr.SelfActionFiles})
wplans = append(wplans, WorkflowPlan{Path: wr.Path, SelfActionFiles: wr.SelfActionFiles, ResolveErr: resolveErr})
}

// Build entries for all pinned deps (skip any already emitted from inventory).
Expand Down
19 changes: 17 additions & 2 deletions internal/pin/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

parserlock "github.com/github/actions-lockfile/go/pkg/lockfile"
"github.com/github/gh-actions-lock/internal/ghapi/httpmock"
"github.com/github/gh-actions-lock/internal/lockfile"
"github.com/github/gh-actions-lock/internal/pinpool"
"github.com/github/gh-actions-lock/internal/resolve"
"github.com/github/gh-actions-lock/internal/tag"
Expand Down Expand Up @@ -196,6 +195,8 @@ func TestPlanWorkflow_PartialResolutionFailure(t *testing.T) {
require.Len(t, pinned, 1, "expected exactly one pinned entry")
assert.Equal(t, "good/action", pinned[0].NWO)
assert.Equal(t, goodSHA, pinned[0].SHA)
require.Len(t, result.wplans, 1)
assert.Error(t, result.wplans[0].ResolveErr)
}

// TestPlanWorkflow_AllResolutionsFail verifies that when ALL refs in a
Expand Down Expand Up @@ -249,6 +250,8 @@ func TestPlanWorkflow_AllResolutionsFail(t *testing.T) {
assert.Equal(t, Unresolved, e.Resolution, "expected %s to be Unresolved", e.NWO)
assert.Contains(t, e.Reason, "not found")
}
require.Len(t, result.wplans, 1)
assert.Error(t, result.wplans[0].ResolveErr)
}

func newTransitivePlanFixture(t *testing.T, compSHA, transSHA string) (*resolve.Resolver, *pinpool.Pool, *tag.Lister) {
Expand Down Expand Up @@ -615,6 +618,18 @@ func TestPlanExcludesLoadFailuresFromCommit(t *testing.T) {
assert.Equal(t, blocked.Path, record.Entries[0].Workflows[0])
}

func TestPlanExcludesBlockingResolverErrorsFromCommit(t *testing.T) {
record, err := Plan(context.Background(), &checks.Report{
Workflows: []checks.WorkflowReport{{
Path: ".github/workflows/ci.yml",
BlockingResolverError: true,
}},
}, PlanOptions{Pool: pinpool.New(2, nil)})
require.NoError(t, err)

assert.Empty(t, record.Workflows)
}

func TestPlanWorkflow_SelfRepositoryDependencyIsNotRewrittenOnFastPath(t *testing.T) {
const sha = "abc1230000000000000000000000000000000000"

Expand Down Expand Up @@ -798,7 +813,7 @@ func TestNoNarrow_BareSHA(t *testing.T) {
})

t.Run("partial scan rejects unrecorded shared action rewrite", func(t *testing.T) {
resolver, tagger, wr, _ := newSlowPathFixtures(t, false)
resolver, tagger, wr, _ := newSlowPathFixtures(t)
wr.SelfActionRefs = append([]parserlock.ActionRef(nil), wr.ActionRefs...)

_, err := planWorkflow(context.Background(), wr, PlanOptions{
Expand Down
2 changes: 2 additions & 0 deletions internal/pin/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type Entry struct {
type WorkflowPlan struct {
Path string
Rewrites map[string]string
// ResolveErr preserves the error returned by ResolveAllRecursive.
ResolveErr error
// SelfActionFiles are in-repo action definition files reached from this
// workflow through `$/…`. The same rewrites apply to their `uses:` lines.
SelfActionFiles []string
Expand Down
2 changes: 2 additions & 0 deletions internal/pipeline/checks/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ type WorkflowReport struct {
Findings []Finding
// SkipCommit prevents terminal parse failures from entering the write phase.
SkipCommit bool
// BlockingResolverError indicates that diagnosis classified a resolver error as blocking.
BlockingResolverError bool
// ActionRefs are all remote dependency roots attributed to the workflow,
// including refs found inside in-repo `$/…` actions.
ActionRefs []parserlock.ActionRef
Expand Down
1 change: 1 addition & 0 deletions internal/pipeline/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve
blockingResolverError = true
}
if blockingResolverError {
wr.BlockingResolverError = true
Comment thread
umireon marked this conversation as resolved.
return wr
}
// Low: we're surfacing the resolver failure itself, not a
Expand Down