{{ message }}
Repository navigation
Expand file tree
/
Copy pathroot.go
More file actions
335 lines (291 loc) · 11.3 KB
/
Copy pathroot.go
File metadata and controls
335 lines (291 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"github.com/MakeNowJust/heredoc"
"github.com/cli/go-gh/v2/pkg/repository"
parserlock "github.com/github/actions-lockfile/go/pkg/lockfile"
"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/ui"
"github.com/github/gh-actions-lock/internal/workflowfile"
"github.com/spf13/cobra"
)
// execute runs the root command and returns an exit code.
//
// - 0: nothing to fix, or every finding was auto-fixed.
// - 1: blocking findings remain (errSilent sentinel) — any invalid finding
// under --no-fix, otherwise findings needing manual review; stdout JSON
// is well-formed when --json was requested.
// - 2: tool failure (bad flag, IO error, network failure, malformed
// lockfile, future-version refusal, panic, etc.). stdout may be empty
// or partial; consumers should rely on stderr for diagnosis.
func execute() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer ui.CloseProgressTrace()
err := newRootCmd(nil).ExecuteContext(ctx)
switch {
case err == nil:
return 0
case errors.Is(err, errSilent):
// Blocking findings were already reported via well-formed output; exit
// 1 quietly so a second error line doesn't clobber the JSON/summary.
return 1
case errors.As(err, new(printedError)):
return 2
case errors.Is(err, context.Canceled):
// CTRL+C / SIGTERM: exit silently. The signal is intentional, not a
// tool failure. Print a newline so the shell prompt starts clean.
fmt.Fprintln(os.Stderr)
return 2
default:
// Every other non-nil error — including lockfile.ErrFutureVersion —
// is a tool failure and maps to 2. Print it on a fresh UI bound to
// stderr so it's visible regardless of how the run configured its own.
ui.New().Error("%s", err)
return 2
}
}
// printedError is a tool failure the run already printed; it still exits 2.
type printedError struct{ error }
func (e printedError) Unwrap() error { return e.error }
type resolverFunc func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error)
// newRootCmd returns the cobra command for the root `actions-lock` invocation.
// newResolver supplies the resolver builder; pass nil for production wiring.
func newRootCmd(newResolver resolverFunc) *cobra.Command {
opts := &checkOptions{}
cmd := &cobra.Command{
Use: "actions-lock [<workflow-path>...]",
Args: cobra.ArbitraryArgs,
Short: "Lock and verify GitHub Actions dependencies",
SilenceErrors: true,
SilenceUsage: true,
Long: heredoc.Doc(`
Lock and verify GitHub Actions dependencies to protect your workflows
from supply chain attacks.
Actions are resolved by mutable tags and branches at runtime. This
extension pins every direct and transitive dependency to an immutable
commit SHA in a per-repo lockfile at .github/workflows/actions.lock,
so changes are visible in pull request diffs and tampered or hijacked
actions are caught before they run.
Scans all workflows under .github/workflows/ by default and fixes
what it can — pinning every resolvable action and updating the
lockfile. Pass --no-fix for a read-only check that writes nothing.
HOST AND AUTHENTICATION
Supported targets are github.com and GitHub Enterprise Cloud with data
residency (*.ghe.com). GitHub Enterprise Server (GHES) is not supported.
In a tenant repository checkout, authenticate with
gh auth login --hostname TENANT.ghe.com, then run gh actions-lock.
Host selection: --hostname, then GH_HOST, then the current repository
(GH_REPO or a remote on a host known to gh), then github.com.
For github.com and *.ghe.com, GH_TOKEN takes precedence over GITHUB_TOKEN
and stored per-host credentials.
--hostname selects the host; it does not override token variables.
If an unintended token override causes authentication to fail, check
stored tenant credentials without exposing tokens:
env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname TENANT.ghe.com
For setup and troubleshooting:
https://github.com/github/gh-actions-lock#github-enterprise-cloud-with-data-residency
REF NARROWING
When a new workflow is first pinned and uses a partial version ref
like actions/checkout@v4, the lockfile narrows it to the highest
matching full semver tag (e.g. v4.2.1). Full semver tags are
effectively immutable — unlike major/minor splats, they almost never
move once published, so the pinned SHA stays valid longer between
lockfile refreshes.
Only freshly pinned refs are narrowed — already-locked deps are not
touched. Only refs that parse as semantic versions (v4, v3.2) are
candidates; branch names (main, canary) and non-version tags are
never narrowed because the latest semver release in a repo may have
nothing to do with the ref the workflow intended.
Pass --no-narrow to disable this behavior entirely.
--json selects the output format only (independent of --no-fix);
structured results go to stdout and progress to stderr:
gh actions-lock --no-fix --json 2>/dev/null | jq .valid
ENVIRONMENT
GH_ACTIONS_LOCK_REPO_ROOT names the repository root instead of
searching parent directories for .git. Set it when relocking a
partial checkout without .git. $/ actions resolve against it, and
the directory must exist and contain the workflows.
RUN LOGS
Every run except --verify-local saves its full --json output, plus the
repository and whether the run succeeded (with its error, if any), to
<user cache dir>/gh-actions-lock/logs/ (on macOS,
~/Library/Caches/gh-actions-lock/logs). Failed runs print the path.
Logs are kept for 14 days, up to 50. Attach the log to bug reports.
`),
Example: heredoc.Doc(`
# Verify all workflows and fix what's fixable
$ gh actions-lock
# Verify a specific workflow
$ gh actions-lock .github/workflows/ci.yml
# Read-only check for CI integration (writes nothing, exits 1 if invalid)
$ gh actions-lock --no-fix --json=valid,findings
# All fields as JSON
$ gh actions-lock --json
`),
Version: cliVersion(),
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.workflowPaths = args
}
return opts.validateOutputFlags()
},
RunE: func(cmd *cobra.Command, args []string) error {
return runCheck(cmd, opts, newResolver)
},
}
bindCheckFlags(cmd, opts)
// --no-onboard and --no-interactive are persistent so they apply to the
// root check invocation. --no-onboard refuses to onboard new workflows or
// actions (re-pinning already-tracked entries still happens); used by
// Dependabot so a relock never silently adds an entry it didn't ask for.
cmd.PersistentFlags().Bool("no-onboard", false, "Refuse to onboard new workflows or actions; only re-pin already-tracked entries")
cmd.PersistentFlags().Bool("no-interactive", false, "Run without interactive prompts")
return cmd
}
// newRun performs the per-invocation wiring shared by every command: expand the
// requested workflow paths (or discover them), build a resolver for the
// resolved hostname, and open the lockfile store against it.
// newResolver is the DI seam; pass nil for production wiring.
func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newResolver resolverFunc, onCorrupt lockRecovery) ([]string, *resolve.Resolver, *lockfile.State, error) {
workflowsDir := os.Getenv("GH_ACTIONS_LOCK_WORKFLOWS_DIR")
// A full-directory scan (no explicit paths) may legitimately find zero
// workflows when the last one was deleted; it must still load the
// lockfile so the now-stale entry can be pruned. Explicit-path runs keep
// erroring on a missing file.
fullScan := len(workflowPaths) == 0
paths, err := discoverWorkflowPaths(workflowPaths, workflowsDir, fullScan)
if err != nil {
return nil, nil, nil, err
}
// Load lockfile before auth so version/parse errors surface before
// "token not found" — a future-version lockfile should tell the user
// to upgrade, not complain about missing auth.
loadStore := func(meta lockfile.MetadataResolver) (*lockfile.State, error) {
if workflowsDir != "" {
return lockfile.LoadStateAt(filepath.Join(workflowsDir, "actions.lock"), meta)
}
return lockfile.LoadState(".", meta)
}
store, err := loadStore(nil)
if err != nil {
if errors.Is(err, lockfile.ErrCorruptLockfile) && onCorrupt != nil {
lockPath := filepath.Join(".", parserlock.Path)
if workflowsDir != "" {
lockPath = filepath.Join(workflowsDir, "actions.lock")
}
recovered, rerr := onCorrupt(lockPath, err)
if rerr != nil {
return nil, nil, nil, rerr
}
if recovered {
store, err = loadStore(nil)
}
}
if err != nil {
return nil, nil, nil, fmt.Errorf("opening lockfile: %w", err)
}
}
if newResolver == nil {
newResolver = func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error) {
return resolve.New(hostname, pool)
}
}
// An empty full scan is only meaningful when the lockfile still records
// workflows to prune. With nothing on disk and nothing recorded, there's
// genuinely no work — keep the original "no workflow files" error, and
// emit it before auth so an empty repo doesn't complain about a missing
// token.
if len(paths) == 0 && len(store.WorkflowKeys()) == 0 {
return nil, nil, nil, noWorkflowsError(workflowsDir)
}
r, err := newResolver(resolveHostname(hostname), pool)
if err != nil {
return nil, nil, nil, err
}
store.SetMetadataResolver(r)
return paths, r, store, nil
}
func discoverWorkflowPaths(existing []string, workflowsDir string, allowEmpty bool) ([]string, error) {
if len(existing) > 0 {
return expandWorkflowPaths(existing)
}
if workflowsDir != "" {
paths, err := workflowfile.DiscoverWorkflowsIn(workflowsDir)
if err != nil {
return nil, err
}
if len(paths) == 0 && !allowEmpty {
return nil, noWorkflowsError(workflowsDir)
}
return paths, nil
}
paths, err := workflowfile.DiscoverWorkflows()
if err != nil {
return nil, err
}
if len(paths) == 0 && !allowEmpty {
return nil, noWorkflowsError(workflowsDir)
}
return paths, nil
}
// noWorkflowsError returns the error emitted when a scan finds no workflow
// files, matching the location it searched.
func noWorkflowsError(workflowsDir string) error {
if workflowsDir != "" {
return fmt.Errorf("no workflow files found in %s", workflowsDir)
}
return fmt.Errorf("no workflow files found in .github/workflows/")
}
func expandWorkflowPaths(paths []string) ([]string, error) {
var expanded []string
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
expanded = append(expanded, path)
continue
}
if !info.IsDir() {
expanded = append(expanded, path)
continue
}
entries, err := os.ReadDir(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext == ".yml" || ext == ".yaml" {
expanded = append(expanded, filepath.Join(path, entry.Name()))
}
}
}
sort.Strings(expanded)
return expanded, nil
}
func resolveHostname(override string) string {
if override != "" {
return strings.ToLower(override)
}
if host := os.Getenv("GH_HOST"); host != "" {
return strings.ToLower(host)
}
repo, err := repository.Current()
if err == nil && repo.Host != "" {
return strings.ToLower(repo.Host)
}
return "github.com"
}
You can’t perform that action at this time.
