Trace every locked commit to its recorded ref (#186) · github/gh-actions-lock@8bb4bb6 · GitHub
Skip to content

Commit 8bb4bb6

Browse files
Trace every locked commit to its recorded ref (#186)
* Trace every locked commit to its recorded ref (#174) Recover local fix fcdd43df96c77d5a376a3c27de8d03f07f6317c5 and retain both community normalization safeguards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align pin fixtures with recorded-ref proof Recorded tags now re-peel before repair (#174) and branch normalization needs a head proof (#165), so same-run conflicting repairs can no longer be planned. Fixtures ported from candidate commit 3eb0100. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align stub scenarios with recorded-ref proof Parity now resolves recorded branch heads and fails closed when the recorded ref is inconclusive (#174). Stub fixtures ported from candidate commits 3eb0100 and 22e894c. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Describe runner ref lookup without naming internal services Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Lock live checkout fixtures to a commit their v4 ref contains CHECKOUT_SHA was the actions/checkout v6.0.2 commit while the fixtures record it under v4 and main. Recorded-ref tracing (#174) correctly rejects that; use the real v4.2.2 commit the stub tag list already names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Test that only the SHA a recorded tag peels to is repaired Restores the #167 same-run scenario with a recorded tag instead of swapping it for a branch, which only exercised the missing-head exit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Trim parity command tests to what unit tests can't prove Keep the original --verify bodies, adjusted for ref-qualified lookups and fail-closed inconclusive checks; replace the mode cross-products with one default/--no-fix test, and drop full-SHA and entry-ref cases that TestParityFinding_FullSHAKey and TestValidEntryRef already cover. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Attribute ref lookup rules to the Actions backend resolver The backend resolver, not the runner, looks up and rejects lock entry refs; say so in comments, docs and the missing-ref finding detail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Check exact tags by history like any other ref Tags move, and the Actions backend accepts a pin whose commit is still in its tag's history. Every lock entry ref is now checked by reachability: a tag that lost the commit still blocks and an inconclusive check still fails closed, but a tag that moved ahead only warns under --verify. Dropping the separate tag-equality lookup also saves the extra REST commit call when a ref still points at its locked commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Render warnings by severity and show moved exact tags An error-severity finding blocks the run, so it never renders as a warning. Under --verify the terminal now shows an exact tag that moved ahead of its locked commit, matching the JSON warning, and the inconclusive-check remediation names the Actions backend. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Reject revision syntax in recorded and resolved refs A ref like main~1 or v1@{1} is not a tag or branch name, but a commit-ish lookup evaluates it as a revision. Validate names with git check-ref-format rules, matching actions-lockfile's RefName: an entry ref that fails makes the lockfile unreadable, and parity never builds a lookup expression from one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Keep fresh-pin parity failures under --relock --relock drops findings for recorded pins because it replaces them, but it also dropped them for pins it had just resolved, so it could write a lock that the next --verify rejects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Let --accept-moved re-record a full-SHA pin whose ref moved A full-SHA entry whose recorded tag or branch no longer vouches for it recommended --accept-moved, which then refused it. --accept-moved now accepts it as a warning and re-records a tag or branch that contains the commit, chosen by reverse lookup; a commit no branch contains still fails. Fresh full-SHA pins are no longer checked against a pre-planning ref. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Never record a branch that a same-named tag shadows The Actions backend resolver looks a recorded ref up as a tag before a branch, so reverse lookup now skips a containing branch whose name is also a tag. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Look up recorded refs by exact name and reject describe names A ref counts only if a tag or branch exists by exactly that name: GraphQL also asks ref(qualifiedName:), and REST reads git/ref/{tags|heads}/<name>. Like the Actions backend, names ending in -g plus four or more hex digits are not accepted as recorded or uses: refs, so an entry with one makes the lockfile unreadable and a uses: ref with one is never locked. Adapted from 2134d14 and 93b2f4a. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Name re-recorded refs and never-recorded refs in the terminal --accept-moved now prints the old and new recorded ref when it keeps a full-SHA pin's commit but records a new tag or branch for it. A ref this run resolved that isn't a tag or branch is titled "Not a tag or branch"; "Ref no longer exists" is kept for a ref the lockfile recorded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Print tool failures before the run log, without request URLs A tool failure now prints before the run log path instead of after it. Inconclusive checks and the Dependabot proxy abort show a short cause on the terminal, such as "could not check v4 history: GitHub returned 403 Forbidden"; --json and the run log keep the full error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Title a gone recorded ref as missing, not as lost history A full-SHA pin whose recorded tag or branch no longer exists was titled "Pinned commit not in upstream history". It is now "Ref no longer exists", like any recorded ref that is gone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Print schema upgrades and pruning before the pin summary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Show a moved recorded tag beside a full-SHA pin's other warnings Terminal warnings were grouped by dependency alone, so a full-SHA pin's sha-as-ref notice hid the --verify warning that its recorded exact tag moved ahead. Group by dependency and category. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Check a reverse-looked-up branch for a same-named tag by exact name The shadow check used the tag listing, which reads only its first page, so a tag beyond it could shadow the branch reverse lookup recorded. Look the chosen branch's name up as an exact tag instead (through the same REST paths as parity, including the anonymous and Dependabot proxy fallbacks) and search again without it if one exists; a lookup that can't finish fails the run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fall back to anonymous compare for a public repo behind SSO CompareRefs had no anonymous fallback for an SSO-blocked token, unlike CompareCommits, so a public action reached through the fallback blocked once its recorded ref moved and its history needed checking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Never plan a ref the lockfile would refuse, and refuse before writing Reverse lookup could pick a tag or branch whose name the Actions backend rejects, and Commit rewrote workflows before the lockfile refused the entry, leaving them changed with no lock. Reverse lookup now skips such names, and Commit updates the lockfile in memory before rewriting any file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Name a ref-only --accept-moved repair in the summary When --accept-moved keeps a full-SHA key and its commit (for example under --no-narrow), the entry is Verified, so the summary never said its recorded ref changed. Consider verified entries too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Keep a transitive full-SHA pin blocking under --accept-moved --accept-moved accepted any full-SHA pin whose recorded ref no longer vouched for it, but planning doesn't rebuild an unchanged composite's dependencies, so a transitive one passed with its entry unchanged. Accept only direct pins; a transitive one stays blocking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Title describe-name refusals as refused, not missing A ref shaped like `git describe` output is refused by name and never looked up, so it may well exist. Say so instead of claiming it is not a tag or branch, and point the remediation at the name. Wording matches fc8e493. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fail on remote uses: values the parser rejects instead of skipping The lockfile parser returns no ref for a remote-looking uses: it can't accept, such as owner/repo@main:action.yml, and the workflow scan, the $/ action scan, local composite scan and remote composite discovery all skipped it, so a run could lock without it. Those are now errors; local paths, docker images, expressions, $/ references and reusable workflows are still not repository actions and are unchanged. Regression tests adapted from the release-branch reproduction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Name the lockfile problem instead of calling it unreadable (#166) An invalid lockfile was reported as "unreadable" even when the file parsed fine and one entry's contents were wrong, and a fix run offered to delete and recreate it. Errors now name the file, line, entry and what is wrong, and suggest restoring the file from version control if it was edited by hand. The lockfile is never deleted or rewritten; the run still exits 2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Search every page of tags and branches before calling a commit off-history Reverse lookup read one page of tags and three of branches, so a commit reachable only through a tag or branch past those was reported as a fork or impostor commit. With no containing branch and no tag on the first page, it now reads every page of tags, and the branch listing reads every page. Both go through the same REST paths, including the anonymous and Dependabot proxy fallbacks; a commit no ref contains still fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fail on reusable workflows referenced from steps A step-level uses: naming a remote .github/workflows/ file was skipped, so the run passed with nothing locked for it. Like the Actions backend, reusable workflows are accepted only at jobs.<job_id>.uses; in a workflow step or a composite step they now fail with the backend's wording. Job-level reusable workflows are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Classify uses: values in one place ParseActionRef returns nil both for values that aren't repository actions by design and for references the backend rejects, and each scan decided separately which nil to skip; that is how malformed references and step-level reusable workflows slipped through. internal/uses now returns a typed kind (or an Invalid kind with its error) and the workflow scan, $/ action scan, local composite scan and remote composite discovery all use it. Behavior is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Gather reverse-lookup ref candidates behind one type Which tag or branch can be recorded for a commit was decided by three separate filters added one fix at a time: backend name policy, tag shadowing of branches, and later pages of the tag listing. refCandidates now owns that question for reverse lookup. Behavior is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Treat a pin the workflow uses directly as direct in its closure Closure walked depth-first from the first root, so a pin a composite listed earlier also used was reported as that composite's child even though the workflow uses it directly. Directness feeds --accept-moved scope and remediation, so --accept-moved could refuse a direct pin. Claim every root before walking children. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Point a composite's broken full-SHA pin at its parent, not --accept-moved --accept-moved doesn't repair a composite's full-SHA child, yet its missing-ref and lost-history findings recommended it. Recommend upgrading, replacing or removing the parent composite instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Put exact ref lookups in one layer Exact tag and branch lookups were spread across parity (restRef, restRefCommit), the shadow check (TagExists) and the tag-page scan (TagsAtCommit), each restating the same rules. internal/ghapi/refs.go now owns them: backend name policy, tag-then-branch order, exact-name match, fail-closed errors, every page of listings, and the Dependabot proxy and anonymous SSO fallback through restGet. GraphQL parity uses the same names via refNames. No behavior or request-count change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Read named branch heads through the shared ref layer GetBranchHead called git/ref directly, so a public repository behind SSO never got the anonymous fallback the other ref lookups have, and the named-branch phase of reverse lookup fell through to a full branch scan. It now uses the shared exact lookup; its cache and its ok=false-on-error contract are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Make ref name policy, ref existence and recordable refs one owner each The ref-candidates refactor centralized HTTP calls but not decisions, so Copilot's review found the same cluster again. This commit adds the owners without changing behavior: - ghapi.RecordableRefName: the one name predicate; ValidEntryRef uses it. - ghapi.RefTarget / LookupRefName: whether a tag or branch exists and the commit it peels to are separate facts. - resolve.BranchAtCommit: the planner's SHA repair asks reverse lookup's candidate type instead of reading branch heads itself. The following commits fix the behaviors on top of these. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Let an existing tag shadow a same-named branch even if it doesn't peel Tag precedence keyed off the tag peeling to a commit, so a tag that targets a tree or blob fell through to a same-named branch and parity checked the branch's history. Precedence now keys off the tag existing, on both the GraphQL and REST paths; such a tag resolves to no commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Apply the full recorded-ref name rules to every candidate, before paging Reverse lookup filtered tag names with the backend rule only, and after deciding whether to read more pages. An unrecordable tag on the first page (release-g1234) then stopped the search for a recordable tag on a later page, and SHA-shaped or refs/ tag and branch names could be chosen and rejected later by the lockfile. Candidates now use RecordableRefName, the same predicate as ValidEntryRef, before any paging or selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Never repair a full-SHA uses: to a branch a same-named tag shadows The planner's SHA repair picked the recorded branch when its head was the locked commit, without the tag-shadow check reverse lookup does, so rewriting to @main could name a same-named tag's commit instead. The repair asks refCandidates, which now also applies the name policy and the shadow check to that branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Keep --accept-moved from downgrading failures in read-only runs --verify and --no-fix return before planning, so --accept-moved could pass a full-SHA pin whose recorded ref is gone without ever finding or validating a replacement. Only runs that write accept it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Point invalid-entry diagnostics at the dependency entry's line The line search matched the first occurrence of the key, which is usually the workflow's list item, or the prefix of a longer key. Match the whole quoted mapping key instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Look each exact ref up once per run Repairing a full-SHA pin and reverse lookup can both look up the same recorded ref by exact name. Cache found and missing answers for the invocation and coalesce concurrent lookups; failed lookups are retried. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Repair SHA pins to any recorded ref at the commit v0.2.0 rewrote a full-SHA `uses:` to its recorded ref without proof. Since #174 the repair requires proof, but only checked semver tags and branches: a non-semver tag (recorded like a branch) was never normalized. Repair now looks the recorded ref up by exact name, as the backend resolves it (tag first), and rewrites only when it names the locked commit. A moved tag, a branch a tag shadows, or a containing branch is left as a SHA. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Name the re-recorded pin, not another pin of its commit The --accept-moved re-record line matched pinned entries by repository and commit, so another workflow's pin of the same commit under a different ref could be reported as the new recorded ref. Match the finding's own lock key, kept or rewritten from it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Attribute moved-tag warnings and advise a step that follows the tag A --verify warning for a tag that moved ahead of a composite's pin did not name the composite (no required_by), and a full-SHA pin was told to run --relock, which never changes a full-SHA `uses:`, so the warning stayed. Name the parent like other parity findings. Advise a full-SHA pin to point `uses:` at the tag, or, for a composite's pin, to upgrade the composite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Select the repository's host before exact ref lookups LookupRefName, and ResolveRefName through it, read refs on the routing client instead of the repository's selected host, so on a tenant host SHA-pin repair crashed. Route it like every other repository method, and test every exported repository method on a tenant client so a new one can't skip host selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Name the host-routing test for tenant hosts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Keep ref case when locating an invalid entry's line The diagnostic line lookup lowercased the whole key, so with entries for owner/repo@V1 and owner/repo@v1 it could cite the wrong one. Compare canonical pin keys instead (parserlock.ParsePin): owner and repo fold, refs stay case-sensitive, as everywhere else the lockfile is keyed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 582fee1 commit 8bb4bb6

66 files changed

Lines changed: 3917 additions & 759 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 16 additions & 4 deletions

‎cmd/gh-actions-lock/command_test.go‎

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"bytes"
5+
"cmp"
56
"context"
67
"encoding/json"
78
"fmt"
@@ -95,6 +96,8 @@ func TestCheck_BareSHAUsesExactMajorTagAndVerifiesLocally(t *testing.T) {
9596
)
9697

9798
reg := &httpmock.Registry{}
99+
100+
httpmock.NoTagsNamed(reg, "dawidd6", "action-download-artifact")
98101
reg.Register(
99102
httpmock.GraphQLForRepo("dawidd6", "action-download-artifact"),
100103
httpmock.JSONResponse(map[string]any{
@@ -211,9 +214,11 @@ func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any {
211214
return map[string]any{
212215
"nameWithOwner": nameWithOwner,
213216
// commit, tag and head answer the parity query from the same fixture.
214-
"commit": map[string]any{"oid": oid},
215-
"tag": map[string]any{"oid": oid},
216-
"head": map[string]any{"oid": oid},
217+
"commit": map[string]any{"oid": oid},
218+
"tag": map[string]any{"oid": oid},
219+
"tagRef": map[string]any{"oid": oid},
220+
"head": map[string]any{"oid": oid},
221+
"headRef": map[string]any{"oid": oid},
217222
"object": map[string]any{
218223
"oid": oid,
219224
"file": map[string]any{
@@ -250,7 +255,9 @@ func parityOK(req *http.Request) (*http.Response, error) {
250255
"nameWithOwner": owner + "/" + body.Variables[fmt.Sprintf("name%d", i)].(string),
251256
"commit": oid,
252257
"tag": oid,
258+
"tagRef": oid,
253259
"head": oid,
260+
"headRef": oid,
254261
}
255262
}
256263
return httpmock.JSONResponse(map[string]any{"data": data})(req)
@@ -314,26 +321,26 @@ func writeTempWorkflow(t *testing.T, body string, pins ...string) string {
314321
// pinString has the form "owner/repo@ref" (the v0.0.2 pin key format).
315322
// A synthetic commit hash is generated from the pin key for each entry.
316323
// The ref field in each action body matches the pin key's ref. Overrides
317-
// can pass "owner/repo@ref=sha1-hex" to supply an explicit commit.
324+
// can pass "owner/repo@ref=sha1-hex" to supply an explicit commit, and
325+
// "owner/repo@ref=sha1-hex=ref" to record a different ref (a full-SHA key
326+
// must record the tag or branch its commit came from).
318327
func writeTempLockfile(t *testing.T, repoDir, wfName string, pinStrings []string) {
319328
t.Helper()
320329
var sb strings.Builder
321330
sb.WriteString("version: '" + parserlock.Version + "'\ndependencies:\n")
322331
for _, raw := range pinStrings {
323-
key, commit := raw, ""
324-
if idx := strings.Index(raw, "="); idx >= 0 {
325-
key = raw[:idx]
326-
commit = raw[idx+1:]
327-
}
332+
key, commit, _ := strings.Cut(raw, "=")
333+
commit, ref, _ := strings.Cut(commit, "=")
328334
pin, ok := parserlock.ParsePin(key)
329335
if !ok {
330336
t.Fatalf("writeTempLockfile: invalid pin %q", key)
331337
}
338+
ref = cmp.Or(ref, pin.Ref)
332339
if commit == "" {
333340
// Generate deterministic fake commit from pin key.
334341
commit = "sha1-" + strings.Repeat(string("abcdef0123456789"[len(key)%16]), 40)
335342
}
336-
sb.WriteString(" '" + key + "':\n ref: '" + pin.Ref + "'\n commit: '" + commit + "'\n owner_id: 1\n repo_id: 1\n")
343+
sb.WriteString(" '" + key + "':\n ref: '" + ref + "'\n commit: '" + commit + "'\n owner_id: 1\n repo_id: 1\n")
337344
}
338345
sb.WriteString("workflows:\n '.github/workflows/" + wfName + "':\n")
339346
for _, raw := range pinStrings {
@@ -1477,3 +1484,98 @@ jobs:
14771484
_, _, err = runCommandWithHTTP(t, &httpmock.Registry{}, "--verify-local", workflowPath)
14781485
require.ErrorIs(t, err, errSilent, "an unlocked bare SHA must not pass the offline gate")
14791486
}
1487+
1488+
// TestFreshMalformedRemoteRefCannotOnboard: a remote-looking uses: value the
1489+
// parser rejects is never skipped, in a workflow or a $/ composite, in
1490+
// any mode or transport. Nothing is fetched or written.
1491+
func TestFreshMalformedRemoteRefCannotOnboard(t *testing.T) {
1492+
for _, rejected := range []string{"actions/checkout@main:action.yml", "actions/checkout/.github/workflows/ci.yml@v4"} {
1493+
t.Run(rejected, func(t *testing.T) { testMalformedRemoteRefCannotOnboard(t, rejected) })
1494+
}
1495+
}
1496+
1497+
func testMalformedRemoteRefCannotOnboard(t *testing.T, rejected string) {
1498+
for _, surface := range []string{"workflow", "self composite"} {
1499+
for _, proxy := range []bool{false, true} {
1500+
for _, refresh := range []bool{false, true} {
1501+
t.Run(fmt.Sprintf("%s/proxy=%t/refresh=%t", surface, proxy, refresh), func(t *testing.T) {
1502+
if proxy {
1503+
t.Setenv("GH_ACTIONS_LOCK_DEPENDABOT_PROXY", "1")
1504+
}
1505+
dir := t.TempDir()
1506+
require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o755))
1507+
workflowPath := filepath.Join(dir, ".github", "workflows", "ci.yml")
1508+
require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755))
1509+
use := rejected
1510+
var actionPath string
1511+
var action []byte
1512+
if surface == "self composite" {
1513+
use = "$/outer"
1514+
actionPath = filepath.Join(dir, "outer", "action.yml")
1515+
action = []byte("runs:\n using: composite\n steps:\n - uses: " + rejected + "\n")
1516+
require.NoError(t, os.MkdirAll(filepath.Dir(actionPath), 0o755))
1517+
require.NoError(t, os.WriteFile(actionPath, action, 0o600))
1518+
}
1519+
workflow := []byte("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: " + use + "\n")
1520+
require.NoError(t, os.WriteFile(workflowPath, workflow, 0o600))
1521+
t.Chdir(dir)
1522+
transport := &requestCountingTransport{}
1523+
args := []string{"--no-interactive", "--no-narrow", workflowPath}
1524+
if refresh {
1525+
args = append(args, "--relock", "--accept-moved")
1526+
}
1527+
1528+
_, stderr, err := runCommandWithHTTP(t, transport, args...)
1529+
1530+
assert.Error(t, err, stderr)
1531+
assert.Contains(t, stderr, rejected)
1532+
assert.Zero(t, transport.calls.Load())
1533+
assert.NoFileExists(t, filepath.Join(filepath.Dir(workflowPath), "actions.lock"))
1534+
got, readErr := os.ReadFile(workflowPath)
1535+
require.NoError(t, readErr)
1536+
assert.Equal(t, workflow, got)
1537+
if actionPath != "" {
1538+
got, readErr := os.ReadFile(actionPath)
1539+
require.NoError(t, readErr)
1540+
assert.Equal(t, action, got)
1541+
}
1542+
})
1543+
}
1544+
}
1545+
}
1546+
}
1547+
1548+
// TestRemoteCompositeMalformedRefBlocks: a fetched composite whose uses:
1549+
// can't be parsed blocks the run instead of locking without that child.
1550+
func TestRemoteCompositeMalformedRefBlocks(t *testing.T) {
1551+
tests := map[string]string{
1552+
"actions/checkout@main:action.yml": "uses: actions/checkout@main:action.yml is not an action reference the Actions backend accepts",
1553+
"actions/checkout/.github/workflows/ci.yml@v4": "reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
1554+
}
1555+
for use, want := range tests {
1556+
t.Run(use, func(t *testing.T) { testRemoteCompositeMalformedRef(t, use, want) })
1557+
}
1558+
}
1559+
1560+
func testRemoteCompositeMalformedRef(t *testing.T, use, want string) {
1561+
const sha = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1562+
composite := "name: c\nruns:\n using: composite\n steps:\n - uses: " + use + "\n"
1563+
reg := &httpmock.Registry{}
1564+
reg.Register(httpmock.GraphQLForRepo("octo", "composite"), httpmock.JSONResponse(map[string]any{
1565+
"data": map[string]any{"a0": testRepoResponse("octo/composite", sha, composite)},
1566+
}))
1567+
path := writeTempWorkflow(t, `
1568+
name: ci
1569+
on: push
1570+
jobs:
1571+
test:
1572+
runs-on: ubuntu-latest
1573+
steps:
1574+
- uses: octo/composite@v1
1575+
`)
1576+
1577+
_, stderr, err := runCommandWithHTTP(t, reg, "--no-narrow", "--no-interactive", path)
1578+
require.ErrorIs(t, err, errSilent, stderr)
1579+
assert.Contains(t, stderr, want)
1580+
assert.NoFileExists(t, filepath.Join(filepath.Dir(path), "actions.lock"))
1581+
}

‎cmd/gh-actions-lock/format/terminal.go‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package format
22

33
import (
4+
"cmp"
45
"fmt"
56
"sort"
67
"strings"
@@ -147,7 +148,7 @@ func renderTermFindingDetail(out *ui.UI, f checks.Finding, dep string) {
147148
icon = out.TermRed(ui.IconError)
148149
}
149150
out.TermDetail("%s %s %s", icon, out.TermDim(label), dep)
150-
out.TermDetail(" %s", f.Detail)
151+
out.TermDetail(" %s", cmp.Or(f.TermDetail, f.Detail))
151152
if f.Category == checks.UnreachablePin && !f.RefMissing && f.Dependency != nil {
152153
owner, repo := f.Dependency.OwnerRepo()
153154
if owner != "" {
@@ -176,10 +177,14 @@ func renderTermFindingDetail(out *ui.UI, f checks.Finding, dep string) {
176177

177178
// findingLabel is categoryLabel with terminal-only titles for refs that
178179
// don't exist upstream, which share the not-pinned and unreachable-pin
179-
// categories in JSON.
180+
// categories in JSON. Only a ref the lockfile recorded can no longer exist.
180181
func findingLabel(f checks.Finding) string {
181182
switch {
182-
case f.RefMissing && f.Category == checks.UnreachablePin:
183+
case f.RefRefused && f.Category == checks.UnreachablePin:
184+
return "Ref name refused"
185+
case (f.RefMissing || f.RefGone) && f.Category == checks.UnreachablePin && f.FreshRef:
186+
return "Not a tag or branch"
187+
case (f.RefMissing || f.RefGone) && f.Category == checks.UnreachablePin:
183188
return "Ref no longer exists"
184189
case f.RefMissing:
185190
return "Ref does not exist"
@@ -297,7 +302,7 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) {
297302
icon = out.Red(ui.IconError)
298303
}
299304
out.Detail("%s %s %s", icon, out.Dim(label), dep)
300-
out.Detail(" %s", f.Detail)
305+
out.Detail(" %s", cmp.Or(f.TermDetail, f.Detail))
301306
if f.Category == checks.UnreachablePin && !f.RefMissing && f.Dependency != nil {
302307
owner, repo := f.Dependency.OwnerRepo()
303308
if owner != "" {
@@ -354,6 +359,9 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) {
354359
if key == "" {
355360
key = f.WorkflowPath
356361
}
362+
// One dependency can draw warnings of different kinds (a
363+
// full-SHA `uses:` and its moved recorded tag); keep each.
364+
key += "\x00" + string(f.Category)
357365
if wg, ok := warnMap[key]; ok {
358366
wg.count++
359367
wg.workflows = append(wg.workflows, f.WorkflowPath)
@@ -383,7 +391,7 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) {
383391
if !isTransitive {
384392
bareSHADeps = append(bareSHADeps, key)
385393
}
386-
case f.Category == checks.RefMoved:
394+
case f.Category == checks.RefMoved && !f.TagMoved:
387395
// Moved mutable refs are sticky; --relock advances them, so
388396
// there is nothing actionable to print here.
389397
case f.Category.IsInconclusive() &&
@@ -425,12 +433,12 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) {
425433
wg := warnMap[key]
426434
f := wg.finding
427435
depKey := f.DepKey()
428-
if f.Category.IsInconclusive() && f.Severity == checks.SeverityWarning {
436+
if f.Category.IsInconclusive() || f.TagMoved {
429437
label := depKey
430438
if label == "" {
431439
label = f.WorkflowPath
432440
}
433-
out.TermWarn("%s: %s", label, f.Detail)
441+
out.TermWarn("%s: %s", label, cmp.Or(f.TermDetail, f.Detail))
434442
if f.Remediation != "" {
435443
out.TermDetail("↳ %s", f.Remediation)
436444
}

‎cmd/gh-actions-lock/lockrecovery.go‎

Lines changed: 9 additions & 77 deletions

0 commit comments

Comments
 (0)