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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ 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.
offline, never writes, 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,
Expand Down Expand Up @@ -106,9 +106,11 @@ A trailing `@ref` (e.g. `$/actions/my-action@v1`) is rejected — the ref is alw
the running commit.

Same-repo `./…` composite action references are automatically converted to `$/…`
on fix runs. This rewrites `./…` steps both in your workflows and in your in-repo
composite action definitions (`action.yml`). Only `./…` paths that resolve to an
in-repo action file are rewritten. To leave `./…` refs untouched, opt out with
on fix runs. The migration rewrites `./…` steps both in your workflows and in
your in-repo composite action definitions (`action.yml`). Only `./…` paths that
resolve to an in-repo action file are rewritten. `--verify` and `--verify-local`
never migrate; they report `./…` refs as `local-action` findings instead. To
leave `./…` refs untouched on fix runs, opt out with
`--no-migrate-local-actions`:

```bash
Expand Down
4 changes: 2 additions & 2 deletions cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ 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.\n"+
"No network calls, no authentication, no file writes — 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"+
"Migration runs by default on fix runs; this leaves `./…` refs on disk untouched.")
"Migration runs by default on fix runs (never under --verify or --verify-local); this leaves `./…` refs on disk untouched.")
}

// validateOutputFlags rejects incoherent structured-output flag combinations.
Expand Down
20 changes: 4 additions & 16 deletions cmd/gh-actions-lock/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ package main
// for every locked pin, without writing. Requires auth.
//
// --verify-local Offline static coverage check. Every action ref must have
// a lockfile entry. No network, no auth — ideal for pre-commit.
// a lockfile entry. No network, no auth, no writes — ideal for
// pre-commit. Same-repo `./…` refs are reported, not migrated.

import (
"fmt"
Expand All @@ -30,27 +31,14 @@ func applyVerifyFlags(opts *checkOptions) {

// runVerifyLocal performs the offline lockfile coverage check. It parses all
// workflows and verifies that every action ref has a corresponding lockfile
// entry — no network, no auth required.
// entry — no network, no auth, never writes to disk.
func runVerifyLocal(opts *checkOptions, out io.Writer, console *ui.UI) error {
workflowsDir := os.Getenv("GH_ACTIONS_LOCK_WORKFLOWS_DIR")
paths, err := discoverWorkflowPaths(opts.workflowPaths, workflowsDir, false)
if err != nil {
return err
}

// Rewrite same-repo `./…` refs to `$/…` on disk before the coverage check
// sees them. Runs by default on fix runs even in this otherwise read-only
// mode; opt out with --no-migrate-local-actions, and never under --no-fix.
if !opts.noMigrateLocalActions && !opts.noFix {
migrated, _, mErr := migrateLocalActions(paths)
if mErr != nil {
return mErr
}
if migrated > 0 && opts.jsonFields == "" {
console.TermSuccess("Migrated %d local %s to `$/…`", migrated, ui.Pluralize(migrated, "action", "actions"))
}
}

// Load the lockfile without a resolver (no network).
var store *lockfile.State
if workflowsDir != "" {
Expand Down Expand Up @@ -80,7 +68,7 @@ func runVerifyLocal(opts *checkOptions, out io.Writer, console *ui.UI) error {

if !valid {
if opts.jsonFields == "" {
console.TermDetail("Run without --verify-local to resolve and pin missing actions.")
console.TermDetail("Run without --verify-local to resolve and pin missing actions and migrate `./…` refs to `$/…`.")
}
return errSilent
}
Expand Down
56 changes: 56 additions & 0 deletions cmd/gh-actions-lock/verify_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/github/gh-actions-lock/internal/ghapi/httpmock"
Expand Down Expand Up @@ -103,3 +106,56 @@ func TestValidateOutputFlags_VerifyConflicts(t *testing.T) {
})
}
}

func TestVerifyLocalReportsLocalActionsWithoutWriting(t *testing.T) {
for _, extra := range [][]string{nil, {"--no-migrate-local-actions"}, {"--no-fix"}} {
for _, jsonOut := range []bool{true, false} {
t.Run(strings.Join(append(extra, fmt.Sprintf("json=%v", jsonOut)), " "), func(t *testing.T) {
path := writeTempWorkflow(t, `
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: tenant/internal@v1
- uses: ./my-action
call:
uses: ./.github/workflows/reusable.yml
`, "tenant/internal@v1=sha1-"+tenantSHA)
require.NoError(t, os.Mkdir(".git", 0o755))
require.NoError(t, os.Mkdir("my-action", 0o755))
require.NoError(t, os.WriteFile(filepath.Join("my-action", "action.yml"), []byte("runs:\n using: composite\n steps: []\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(".github", "workflows", "reusable.yml"), []byte("on: workflow_call\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"), 0o644))
before, err := os.ReadFile(path)
require.NoError(t, err)
lockBefore := readTempLockfilePins(t)

args := append([]string{"--verify-local", "--no-interactive"}, extra...)
if jsonOut {
args = append(args, "--json=valid,findings")
}
stdout, stderr, err := runCommandWithHTTP(t, &httpmock.Registry{}, append(args, path)...)

require.ErrorIs(t, err, errSilent)
if jsonOut {
assert.Contains(t, stdout, `"valid": false`)
assert.Contains(t, stdout, `"category": "local-action"`)
assert.Contains(t, stdout, `"severity": "error"`)
} else {
out := stdout + stderr
assert.Contains(t, out, "local path actions")
assert.Contains(t, out, "Run without --verify-local")
assert.NotContains(t, out, "Migrated")
}
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, string(before), string(after))
action, err := os.ReadFile(filepath.Join("my-action", "action.yml"))
require.NoError(t, err)
assert.NotContains(t, string(action), "$/")
assert.Equal(t, lockBefore, readTempLockfilePins(t))
})
}
}
}
45 changes: 25 additions & 20 deletions internal/pipeline/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,26 +253,7 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W
}

if len(pw.LocalPaths) > 0 {
wfKey := workflowfile.KeyFromPath(pw.Path)
if store != nil && store.HasWorkflow(wfKey) {
wr.Findings = append(wr.Findings, checks.Finding{
WorkflowPath: pw.Path,
Category: checks.LocalAction,
Severity: checks.SeverityError,
Confidence: checks.ConfidenceHigh,
Detail: "workflow uses local path actions which are not supported; remove local path actions to continue using the lockfile",
Remediation: "rewrite same-repo `uses: ./…` to `uses: $/…` (automatic on fix runs; disable with --no-migrate-local-actions), or remove the `./…` steps",
})
} else {
wr.Findings = append(wr.Findings, checks.Finding{
WorkflowPath: pw.Path,
Category: checks.LocalAction,
Severity: checks.SeverityWarning,
Confidence: checks.ConfidenceHigh,
Detail: "workflow uses local path actions; lockfile onboarding is not supported",
Remediation: "rewrite same-repo `uses: ./…` to `uses: $/…` (automatic on fix runs; disable with --no-migrate-local-actions)",
})
}
wr.Findings = append(wr.Findings, localActionFinding(pw, store))
hasTerminalFinding = true
}

Expand Down Expand Up @@ -313,6 +294,30 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W
return wr, false
}

// localActionFinding reports same-repo `./…` refs: an error once the workflow
// is onboarded to the lockfile, a warning before. Shared by --verify and
// --verify-local so both modes agree.
func localActionFinding(pw checks.ParsedWorkflow, store *lockfile.State) checks.Finding {
if store != nil && store.HasWorkflow(workflowfile.KeyFromPath(pw.Path)) {
return checks.Finding{
WorkflowPath: pw.Path,
Category: checks.LocalAction,
Severity: checks.SeverityError,
Confidence: checks.ConfidenceHigh,
Detail: "workflow uses local path actions which are not supported; remove local path actions to continue using the lockfile",
Remediation: "rewrite same-repo `uses: ./…` to `uses: $/…` (automatic on fix runs; disable with --no-migrate-local-actions), or remove the `./…` steps",
}
}
return checks.Finding{
WorkflowPath: pw.Path,
Category: checks.LocalAction,
Severity: checks.SeverityWarning,
Confidence: checks.ConfidenceHigh,
Detail: "workflow uses local path actions; lockfile onboarding is not supported",
Remediation: "rewrite same-repo `uses: ./…` to `uses: $/…` (automatic on fix runs; disable with --no-migrate-local-actions)",
}
}

func preserveExistingInventory(wr *checks.WorkflowReport, pw checks.ParsedWorkflow) {
wr.Deps = pw.ExistingDeps
for _, d := range pw.ExistingDeps {
Expand Down
9 changes: 8 additions & 1 deletion internal/pipeline/verify_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ func VerifyLocalCoverage(parsed []checks.ParsedWorkflow, store *lockfile.State)
})
}

if len(pw.Refs) == 0 && len(pw.LocalPaths) == 0 {
// Same-repo `./…` refs are terminal, matching --verify.
if len(pw.LocalPaths) > 0 {
wr.Findings = append(wr.Findings, localActionFinding(pw, store))
Comment thread
nodeselector marked this conversation as resolved.
results = append(results, wr)
continue
}

if len(pw.Refs) == 0 {
results = append(results, wr)
continue
}
Expand Down
26 changes: 26 additions & 0 deletions internal/pipeline/verify_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,29 @@ func TestVerifyLocalCoverage_DepsError(t *testing.T) {
assert.True(t, report.Workflows[0].Findings[0].IsRemediableNotPinned())
assert.Contains(t, report.Workflows[0].Findings[0].Detail, "failed to read dependencies")
}

func TestVerifyLocalCoverage_LocalPathsMatchVerify(t *testing.T) {
store := writeTestLockfile(t, t.TempDir(), testLockfileContent)

for _, tc := range []struct {
path string
severity checks.Severity
}{
{".github/workflows/ci.yml", checks.SeverityError},
{".github/workflows/new.yml", checks.SeverityWarning},
} {
t.Run(tc.path, func(t *testing.T) {
pw := checks.ParsedWorkflow{
Path: tc.path,
Refs: []parserlock.ActionRef{{Owner: "actions", Repo: "checkout", Ref: "v4"}},
LocalPaths: []string{"./my-action"},
}
report := VerifyLocalCoverage([]checks.ParsedWorkflow{pw}, store)
require.Len(t, report.Workflows[0].Findings, 1)
f := report.Workflows[0].Findings[0]
assert.Equal(t, checks.LocalAction, f.Category)
assert.Equal(t, tc.severity, f.Severity)
assert.Equal(t, localActionFinding(pw, store), f)
})
}
}
12 changes: 6 additions & 6 deletions test/scenarios/catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1208,11 +1208,11 @@ scenarios:

- name: self_repository_action_missing
category: workflow_parsing
description: "A missing in-repo $/ action is an unfixable invalid self repository ref, not a fixable not-pinned dependency. --no-migrate-local-actions opts out of the default migration pass so the real binary's verify-local diagnosis is what reports it."
description: "A missing in-repo $/ action is an unfixable invalid self repository ref, not a fixable not-pinned dependency."
needs_token: false
needs_stub: false
tags: [stub]
flags: ["--verify-local", "--no-migrate-local-actions"]
flags: ["--verify-local"]
fixtures:
workflows:
ci.yml:
Expand All @@ -1230,11 +1230,11 @@ scenarios:

- name: self_repository_ref_rejected
category: workflow_parsing
description: "$/…@ref is rejected — a self repository reference always resolves to the running commit, so an explicit @ref is invalid. --no-migrate-local-actions opts out of the default migration pass so the real binary's verify-local diagnosis reports it."
description: "$/…@ref is rejected — a self repository reference always resolves to the running commit, so an explicit @ref is invalid."
needs_token: false
needs_stub: false
tags: [stub]
flags: ["--verify-local", "--no-migrate-local-actions"]
flags: ["--verify-local"]
fixtures:
workflows:
ci.yml:
Expand All @@ -1252,11 +1252,11 @@ scenarios:

- name: self_repository_nested_ref_rejected
category: workflow_parsing
description: "A $/…@ref nested inside an in-repo composite is rejected during local closure discovery, before any network resolution. --no-migrate-local-actions opts out of the default migration pass so the real binary's verify-local diagnosis reports it."
description: "A $/…@ref nested inside an in-repo composite is rejected during local closure discovery, before any network resolution."
needs_token: false
needs_stub: false
tags: [stub]
flags: ["--verify-local", "--no-migrate-local-actions"]
flags: ["--verify-local"]
fixtures:
workflows:
ci.yml:
Expand Down
Loading