From a4b3c140e94b268ca4f57c01ce099fb5d18eea46 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:32:25 -0500 Subject: [PATCH 01/13] doc: rewrite package doc to lead with Parse, not ParseActionRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new user's first question is 'how do I read a lockfile,' not 'what is the security boundary.' The old package comment opened with ParseActionRef as 'the choke point' and never mentioned Parse() or File at all — burying the actual entry point below a security treatise. New doc: lead with Parse/File/LookupWorkflow, move ParseActionRef and ParseReusableWorkflowRef to a clear secondary section, and demote the security internals note to a clearly labeled contributor section. --- go/pkg/lockfile/doc.go | 55 ++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/go/pkg/lockfile/doc.go b/go/pkg/lockfile/doc.go index a594e75..7fee67f 100644 --- a/go/pkg/lockfile/doc.go +++ b/go/pkg/lockfile/doc.go @@ -1,24 +1,43 @@ -// Package lockfile is the source of truth for the workflow dependency -// lockfile format and pin grammar. +// Package lockfile is the source of truth for the GitHub Actions dependency +// lockfile format and its Go parser. // -// # Parsing as a security boundary +// # Getting started // -// ParseActionRef is the choke point. Untrusted uses: strings enter; only -// concrete repository actions leave. Everything else — expressions, docker:// -// images, local paths, reusable workflows, control characters — returns nil, -// before it can reach a URL or GraphQL builder. +// [Parse] is the primary entry point. Hand it the raw bytes of +// .github/workflows/actions.lock and it returns a [File] whose [File.Workflows] +// and [File.Dependencies] maps let you look up every pinned action for a +// workflow: // -// ParseReusableWorkflowRef is its mirror: the reusable-workflow shape -// ParseActionRef rejects, parsed through the same validation. Both split the -// ref at the first @, never the last — a ref may legitimately contain one. +// f, err := lockfile.Parse(contents) +// pins, ok := f.LookupWorkflow(".github/workflows/release.yml") // -// owner/repo/path pass isValidSegment: a fixed character set, ".."/"." barred. -// Drop-in safe. The ref is looser by necessity — git refs carry slashes, dots, -// even another @ — so isValidRef only guarantees it cannot escape a quoted -// literal or smuggle a traversal. A ref still needs escaping before it touches -// a URL path. Owner/repo do not. +// [File.LookupWorkflow] returns canonical pin key strings. Each key can be +// looked up in [File.Dependencies] to retrieve the associated [Action] +// metadata (commit hash, branch, tag, repository IDs). // -// Hand-rolled, no regexp, allocation-free, single-pass: it runs per dependency -// on the hot path and the reject-lists must stay auditable at a glance. They -// are load-bearing. Do not refactor them into regular expressions. +// # Parsing uses: strings +// +// [ParseActionRef] parses a single `uses:` string from a workflow step into +// its owner/repo/ref components. It returns nil for anything that is not a +// repository action — expressions, docker:// images, local paths, reusable +// workflow files — so callers never need to classify the input themselves. +// +// [ParseReusableWorkflowRef] is its mirror for the reusable-workflow shape +// (owner/repo/.github/workflows/name.yml@ref) that ParseActionRef deliberately +// rejects. Use [IsLocalReusableWorkflow] for the local ./.github/workflows/... +// shape, which has no owner/repo and is handled differently. +// +// Both parsers split the ref at the FIRST @, not the last — a ref may +// legitimately contain @ (e.g. a branch named "release@2024"). +// +// # Security note for contributors +// +// owner/repo/path components pass isValidSegment (fixed character set, +// ".."/"." barred) before reaching any URL or GraphQL builder. The ref is +// validated by a denylist rather than an allowlist because git refs carry +// slashes, dots, and embedded @; isValidRef only ensures the ref cannot escape +// a quoted string or smuggle a path traversal. A ref still needs escaping +// before use in URL paths. These validators are hand-rolled, allocation-free, +// and single-pass because they run on the hot path. Do not replace them with +// regular expressions. package lockfile From 8296f018c397ae70d2fa5337f0ea3ce4b29bfb80 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:32:48 -0500 Subject: [PATCH 02/13] lockfile: add godoc for File.Workflows and File.Dependencies fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fields had zero documentation at the struct level. A reader looking at the struct definition had no idea what the map keys or values were — the context lived only in the type-level comment block's YAML example. Add field-level doc comments that name the key/value formats and point readers toward LookupWorkflow and Dependencies for the intended lookup pattern. --- go/pkg/lockfile/lockfile.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index ff5a85d..a8e3f4e 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -110,9 +110,24 @@ const Path = ".github/workflows/actions.lock" // Workflow entries hold the full transitive closure as a flat list of pin // keys for cold readability. type File struct { - Version string `yaml:"version"` - Dependencies map[string]Action `yaml:"dependencies"` - Workflows map[string][]string `yaml:"workflows"` + // Version is the lockfile schema version string (e.g. "v0.0.1"). It is + // always equal to the [Version] constant for files Parse accepts. + Version string `yaml:"version"` + + // Dependencies maps each canonical pin key (OWNER/REPO@REF:ALGO-HEX) to + // the resolved [Action] metadata for that pin. The map is deduplicated: + // multiple workflows that share an action produce a single entry here. + // Use [File.LookupWorkflow] to find the pin keys for a specific workflow, + // then index into this map to retrieve each action's metadata. + Dependencies map[string]Action `yaml:"dependencies"` + + // Workflows maps each repo-relative workflow file path (e.g. + // ".github/workflows/release.yml") to the flat, transitive list of + // canonical pin keys that workflow depends on. Pin keys are in + // OWNER/REPO@REF:ALGO-HEX form and serve as lookup keys into + // [File.Dependencies]. Prefer [File.LookupWorkflow] over indexing this + // map directly. + Workflows map[string][]string `yaml:"workflows"` // node retains the parsed YAML tree so callers can resolve positions for // their own diagnostics via Position/KeyPosition. It is nil on the From 77d6abc1f9d3ff42ac5563d88a54f791bca35fe2 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:33:10 -0500 Subject: [PATCH 03/13] lockfile: rewrite ParseError godoc to remove yaml.v3 implementation details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old ParseError comment told callers the internals (yaml.v3 package prefix, node tree walking) rather than the contract (structured line/col for diagnostics). A caller doesn't care that we use yaml.v3 or how we extract the position — they care what fields they get, when Line/Column are zero, and how to use the type. Rewrite the comment to explain the public contract and add a usage nudge pointing to errors.As. --- go/pkg/lockfile/lockfile.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index a8e3f4e..17c663f 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -16,22 +16,21 @@ import ( // scraping the error string. var ErrFutureVersion = errors.New("lockfile version is newer than this binary supports") -// ParseError describes a failure to parse a dependency lockfile. +// ParseError describes a failure to parse a dependency lockfile. It is always +// returned (via errors.As) by [Parse] rather than plain errors, so callers can +// print file:line:col diagnostics without scraping error strings. // // Line and Column, when non-zero, are the 1-indexed position within the -// lockfile contents that the failure refers to. They index the lockfile -// itself, never a consumer's workflow file, so callers can anchor diagnostics -// on the lockfile (.github/workflows/actions.lock) rather than scraping -// yaml.v3's error string themselves. +// lockfile bytes that the failure refers to. They index the lockfile file +// (.github/workflows/actions.lock), not any workflow .yml file. // -// Column is populated for semantic failures Parse detects itself (it walks the -// retained YAML node tree to the offending key/value). It is left zero for raw -// yaml.v3 decode failures, whose errors report only a line: a malformed -// document has no node tree to read a column from, and yaml.v3 type errors -// carry a line but no column. +// Column is set for semantic failures that Parse detects by walking the +// retained YAML tree (e.g. an unknown field, a duplicate pin key). It is zero +// for low-level YAML syntax errors where only a line number is available — +// a structurally malformed document has no node tree to resolve a column from. // -// Msg is the human-readable reason with yaml.v3's "yaml:" package prefix and -// leading position removed. +// Msg is the human-readable description of the failure, without any position +// prefix. Use Error() to get the full "line N, column M: reason" string. type ParseError struct { Line int Column int From 6c9de2c5d1b89f640e5be35ebe0a06556841940f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:33:36 -0500 Subject: [PATCH 04/13] lockfile: replace jargon in Action godoc with plain-English explanations 'Fork-network signal' is GitHub-internal terminology that means nothing to an external consumer. Replace with a concrete explanation: a commit reachable from no branch in the source repo could belong to a fork, which SHA-only pinning cannot detect. Also clarify Tag (optional, why), Commit (same digest as pin key), OwnerID/RepoID (what they're for), and Uses (which action types populate it). --- go/pkg/lockfile/lockfile.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index 17c663f..f5428dd 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -228,19 +228,28 @@ func (f File) LookupWorkflow(workflowKey string) ([]string, bool) { // Action carries the per-action metadata recorded in the lockfile under the // pin key. // -// Tag is the discovered release/tag at the commit, if one exists. Optional. +// Tag is the release/tag name at the pinned commit, if one exists. Optional: +// commits that are not tagged (e.g. pinned directly to a branch SHA) omit +// this field. // -// Branch is a branch that contains the pinned commit. Required: a commit not -// on any branch is an impostor / fork-network signal, so Parse rejects an -// Action without one. It is the authenticity check that SHA-only pinning -// lacks. +// Branch is a branch of the action's repository that contains the pinned +// commit. Required: Parse rejects an Action without one. A valid branch +// confirms that the commit exists in the expected repository — a SHA that +// isn't reachable from any branch in the source repo could belong to a fork +// or an attacker-supplied commit, which SHA-only pinning cannot detect. // -// Commit holds the digest in algo-prefixed form (e.g. "sha1-..." or -// "sha256-..."). Required. +// Commit holds the digest in algo-prefixed form (e.g. "sha1-abc123..." or +// "sha256-def456..."). This is the same digest that appears in the pin key. +// Required. +// +// OwnerID and RepoID are the GitHub numeric IDs for the action's repository +// owner and repository respectively. Consumers use them to detect if the +// action has been transferred to a new owner between lockfile regenerations — +// a repository transfer changes the owner name but not the owner ID. // // Uses lists the action's direct nested dependencies (composite action -// `uses:` steps) as canonical pin keys. Empty for leaf actions; required for -// composites, a condition Parse can't enforce structurally. +// `uses:` steps) as canonical pin keys. Empty for leaf actions (node, +// docker); populated for composite actions. type Action struct { Tag string `yaml:"tag,omitempty"` Branch string `yaml:"branch,omitempty"` From 41a86ed136ab00b059b2d00847fe9aed4ccbd12e Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:33:53 -0500 Subject: [PATCH 05/13] lockfile: expand LookupWorkflow godoc with next-step example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old doc said 'returns the dependency closure' with no hint about what to do with the strings. A beginner gets a []string and has no idea they should index into File.Dependencies. Add a concrete usage example showing the key→Dependencies lookup, explain the pin key format, and clarify the ok=false vs empty-slice distinction. --- go/pkg/lockfile/lockfile.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index f5428dd..d169579 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -217,9 +217,23 @@ func mappingEntry(m *yaml.Node, key string) (k, v *yaml.Node) { return nil, nil } -// LookupWorkflow returns the dependency closure for the given repo-relative -// workflow key (e.g. ".github/workflows/deploy.yml"). The returned bool -// reports whether the key was found. +// LookupWorkflow returns the flat, transitive list of canonical pin keys for +// the given repo-relative workflow path (e.g. ".github/workflows/deploy.yml"). +// The returned bool reports whether the workflow path was found in the lockfile. +// +// Each string in the returned slice is a canonical pin key in +// OWNER/REPO@REF:ALGO-HEX form. To retrieve the full action metadata for a +// pin, look it up in [File.Dependencies]: +// +// pins, ok := f.LookupWorkflow(".github/workflows/deploy.yml") +// for _, key := range pins { +// action := f.Dependencies[key] +// fmt.Println(action.Branch, action.Commit) +// } +// +// A workflow that is present in the lockfile but has no dependencies returns +// an empty slice and ok=true. ok=false means the workflow path was never +// onboarded into the lockfile at all. func (f File) LookupWorkflow(workflowKey string) ([]string, bool) { w, ok := f.Workflows[workflowKey] return w, ok From 282643fa8d06d051abd3ac93bdf4d06ff18b6229 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:34:55 -0500 Subject: [PATCH 06/13] uses: fix IsLocalReusableWorkflow godoc and add missing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter was named 'localPath' suggesting callers should pass a resolved file path, but the function expects the raw uses: string (with the './' prefix intact). Rename the parameter to 'localUses' and rewrite the doc with a concrete two-example comparison showing what returns true vs false, plus a warning to call this only after the './' prefix check. Add TestIsLocalReusableWorkflow — this exported function had zero test coverage despite being part of the public API. --- go/pkg/lockfile/uses.go | 27 +++++++++++++++++++++------ go/pkg/lockfile/uses_test.go | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/go/pkg/lockfile/uses.go b/go/pkg/lockfile/uses.go index c10dde9..d15d301 100644 --- a/go/pkg/lockfile/uses.go +++ b/go/pkg/lockfile/uses.go @@ -318,10 +318,25 @@ func isReusableWorkflow(path string) bool { return isYAMLFile(name) } -// IsLocalReusableWorkflow reports whether a `./...`-prefixed local -// `uses:` value names a reusable workflow file (rather than a local -// composite action directory). Exposed for consumers that walk -// workflows themselves and need to distinguish the two shapes. -func IsLocalReusableWorkflow(localPath string) bool { - return isYAMLFile(localPath) +// IsLocalReusableWorkflow reports whether a local `uses:` value (one that +// starts with "./") names a reusable workflow file rather than a composite +// action directory. +// +// Pass the raw, untrimmed `uses:` string from the workflow step — the leading +// "./" must be present: +// +// - "./.github/workflows/ci.yml" → true (local reusable workflow) +// - "./my-composite-action" → false (local composite action) +// +// Call this only after confirming the `uses:` value has a "./" prefix; a +// value without "./" is a repository action or reusable workflow, not a +// local reference, and should be parsed with [ParseActionRef] or +// [ParseReusableWorkflowRef] instead. +// +// The distinction matters because composite action directories and reusable +// workflow files are resolved differently by the runner: workflow files are +// fetched from a specific checked-out path, while directories are run as +// composite actions. +func IsLocalReusableWorkflow(localUses string) bool { + return isYAMLFile(localUses) } diff --git a/go/pkg/lockfile/uses_test.go b/go/pkg/lockfile/uses_test.go index d42f1d5..1451201 100644 --- a/go/pkg/lockfile/uses_test.go +++ b/go/pkg/lockfile/uses_test.go @@ -137,6 +137,28 @@ func TestReusableWorkflowRefNames(t *testing.T) { assert.Equal(t, "", ReusableWorkflowRef{}.NWO()) } +func TestIsLocalReusableWorkflow(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "local reusable yml", input: "./.github/workflows/ci.yml", want: true}, + {name: "local reusable yaml", input: "./.github/workflows/ci.yaml", want: true}, + {name: "local composite action directory", input: "./my-action", want: false}, + {name: "local composite no extension", input: "./my-action/", want: false}, + {name: "remote action is not local", input: "actions/checkout@v4", want: false}, + {name: "remote reusable workflow is not local", input: "octo/repo/.github/workflows/ci.yml@v1", want: false}, + {name: "empty string", input: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsLocalReusableWorkflow(tt.input)) + }) + } +} + + func TestIsReusableWorkflow(t *testing.T) { tests := []struct { name string From b2c22825b6e7ebad7cef6dea3526c16a83de9150 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:35:16 -0500 Subject: [PATCH 07/13] pin: enumerate all ParsePin rejection reasons in godoc The old doc said 'returns ok=false if the string doesn't match' and called out only sub-action paths as an example. A caller trying to understand why their string failed had nowhere to look. List all actual rejection conditions: missing separators, sub-action paths, unknown algorithm, wrong digest length, invalid hex. Also note that Ref casing is preserved while all other components are lowercased. --- go/pkg/lockfile/pin.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/go/pkg/lockfile/pin.go b/go/pkg/lockfile/pin.go index 0b2a73a..9b67c77 100644 --- a/go/pkg/lockfile/pin.go +++ b/go/pkg/lockfile/pin.go @@ -66,10 +66,19 @@ func IndexKey(owner, repo, ref string) string { // // "OWNER/REPO@REF:ALGO-HEX" // -// Returns ok=false if the string doesn't match the expected format, -// including any sub-action path component (e.g. "owner/repo/sub@ref:...") -// — the lockfile grammar is strictly repo-scoped, matching the runner's -// tarball download identity. +// Returns ok=false for any input that does not match — including all of: +// - Missing "@" separator between repo and ref +// - Missing ":" separator between ref and digest +// - A sub-action path in the repo portion (e.g. "owner/repo/sub@ref:...") +// — the lockfile grammar is strictly repo-scoped, matching the runner's +// tarball download identity +// - An unknown or unsupported digest algorithm (only "sha1" and "sha256" +// are recognized) +// - A hex digest that is the wrong length or contains non-hex characters +// +// On success, all case-insensitive components (owner, repo, algo, hex) are +// normalized to lowercase. Ref preserves source casing — git refs are +// case-sensitive. The returned Pin is always in canonical form. func ParsePin(s string) (Pin, bool) { atIdx := strings.IndexByte(s, '@') if atIdx <= 0 || atIdx == len(s)-1 { From 5402f0957c285051ced619bdf41648e94c89ada6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:35:38 -0500 Subject: [PATCH 08/13] action_meta: fix misleading error message and godoc for ParseActionMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error wrapped 'parsing action.yml' but the function takes a content string — it has no idea if the caller read from action.yml or action.yaml. Change to the format-agnostic 'parsing action metadata'. Also update the godoc to explicitly mention both file name variants and clarify that the file name is not needed (just pass the content). --- go/pkg/lockfile/action_meta.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/go/pkg/lockfile/action_meta.go b/go/pkg/lockfile/action_meta.go index 58e3075..45f35fd 100644 --- a/go/pkg/lockfile/action_meta.go +++ b/go/pkg/lockfile/action_meta.go @@ -30,20 +30,22 @@ type ActionMeta struct { // action.yml files in the wild are well under 1 MiB. const MaxActionMetaSize = 1 << 20 // 1 MiB -// ParseActionMeta parses the contents of an action.yml file into an -// ActionMeta. Composite actions emit their nested step `uses:` strings -// in NestedUses; non-composite actions return an empty NestedUses. +// ParseActionMeta parses the contents of an action.yml or action.yaml file +// into an [ActionMeta]. Pass the raw file bytes as a string; the file name +// itself is not needed. Composite actions emit their nested step `uses:` +// strings in NestedUses; non-composite actions return an empty NestedUses. // -// Returns an error only on malformed YAML — unknown `runs.using` values -// resolve to ExecUnknown rather than failing. +// Returns an error only on malformed YAML. Unknown `runs.using` values +// (e.g. a future executor type) resolve to [ExecUnknown] rather than +// failing, so callers can handle them gracefully. func ParseActionMeta(content string) (*ActionMeta, error) { if len(content) > MaxActionMetaSize { - return nil, fmt.Errorf("action.yml too large: %d bytes (max %d)", len(content), MaxActionMetaSize) + return nil, fmt.Errorf("action metadata too large: %d bytes (max %d)", len(content), MaxActionMetaSize) } var doc yaml.Node if err := yaml.Unmarshal([]byte(content), &doc); err != nil { - return nil, fmt.Errorf("parsing action.yml: %w", err) + return nil, fmt.Errorf("parsing action metadata: %w", err) } if err := rejectYAMLAnchors(&doc); err != nil { @@ -61,7 +63,7 @@ func ParseActionMeta(content string) (*ActionMeta, error) { } if err := doc.Decode(&raw); err != nil { - return nil, fmt.Errorf("parsing action.yml: %w", err) + return nil, fmt.Errorf("parsing action metadata: %w", err) } meta := &ActionMeta{Name: raw.Name} From bd28d23121869d18c1c8de2e97bac86977f3f2e8 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:36:14 -0500 Subject: [PATCH 09/13] version: improve SemVer field and method godoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SemVer.Rest said 'anything after patch' which is not useful — callers who check it need to know what a non-empty Rest implies. Add that Rest != "" means pre-release (see IsStable). Likewise IsStable's comment said 'no trailing junk' which is vague; make it concrete. IsMutable said 'partial tag that should be narrowed' without explaining WHY a partial tag is dangerous — point callers to the actual security concern (authors can silently move major/minor tags). Cross-link IsMutable↔IsFull since they are exact inverses. --- go/pkg/lockfile/version.go | 41 +++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/go/pkg/lockfile/version.go b/go/pkg/lockfile/version.go index 734bcab..3614f99 100644 --- a/go/pkg/lockfile/version.go +++ b/go/pkg/lockfile/version.go @@ -6,6 +6,24 @@ import ( "strconv" ) +// SemVer holds parsed semantic version components. +// +// API stability: the type and its comparison helpers (Greater, Narrows, +// UpgradeOver, MajorTag, MinorTag, IsFull) are part of the exported surface +// because the tag recommendation engine in downstream consumers relies on +// them. Their semantics are deliberately non-strict-semver (see below) and are +// committed to as-is; they are not internal helpers despite the +// recommendation-engine flavor. +// +// GitHub Actions has no first-class version scheme — a uses: ref can be +// any git ref (tag, branch, SHA, or even "main"). In practice most +// action authors follow semver-ish conventions, but the ecosystem +// diverges from strict semver in ways that golang.org/x/mod/semver +// cannot handle: bare versions without a "v" prefix ("2.0.0"), partial +// versions ("v4", "v4.2"), and arbitrary suffixes all appear in the +// wild. x/mod/semver rejects bare and partial tags, and doesn't expose +// individual components — we need Major/Minor/Patch to compute +// MajorTag, MinorTag, and IsFull for the tag recommendation engine. // SemVer holds parsed semantic version components. // // API stability: the type and its comparison helpers (Greater, Narrows, @@ -25,12 +43,15 @@ import ( // individual components — we need Major/Minor/Patch to compute // MajorTag, MinorTag, and IsFull for the tag recommendation engine. type SemVer struct { - Prefix string // "v" or "" + Prefix string // "v" or "" — whether the original tag had a "v" prefix Major int Minor int Patch int - Rest string // anything after patch (e.g. "-beta.1") - Raw string + // Rest is everything after the patch number, e.g. "-beta.1" for + // "v1.2.3-beta.1". An empty Rest means the version is stable (no + // pre-release suffix). See [SemVer.IsStable]. + Rest string + Raw string // original tag string as written (e.g. "v4" or "2.0.0-rc.1") } var versionRE = regexp.MustCompile(`^(v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$`) @@ -99,18 +120,24 @@ func (s SemVer) Greater(o SemVer) bool { return s.Raw > o.Raw } -// IsStable returns true if the tag has no pre-release suffix or trailing junk. +// IsStable returns true if the version has no pre-release suffix (Rest == ""). +// "v1.2.3" is stable; "v1.2.3-beta.1" is not. func (s SemVer) IsStable() bool { return s.Rest == "" } // IsFull returns true if the version has all three components // (major.minor.patch) and no pre-release suffix. Tags like "v4" or "v4.2" -// return false. +// return false. IsFull is the prerequisite for a locked dependency: only a +// full version uniquely identifies a release. func (s SemVer) IsFull() bool { return s.Rest == "" && s.Raw != s.MajorTag() && s.Raw != s.MinorTag() } -// IsMutable reports whether this version is a partial (major-only or -// major.minor) tag that should be narrowed to a specific patch version. +// IsMutable reports whether the version is a partial (major-only or +// major.minor) tag — the opposite of [SemVer.IsFull]. A partial tag like +// "v4" or "v4.2" is "mutable" because the author can silently move it to +// a new patch commit without changing the tag name, which makes it unsafe +// to trust without a SHA pin. Use [SemVer.Narrows] to find a full patch +// version that narrows a mutable tag. func (s SemVer) IsMutable() bool { return !s.IsFull() } // IsMajorOnly reports whether the raw tag is a bare major version (e.g. "v4"). From 9ccb643b18e764c02004685f2ec247a4bda97a45 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 18:36:47 -0500 Subject: [PATCH 10/13] lockfile: rewrite Parse godoc for beginner clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old doc used expert shorthand ('scopes per-dependency validation', 'fail-open by design') that means nothing to someone new to the library. Specific improvements: - Lead with what to pass: the lockfile bytes, with a pointer to Path. - Separate the optional paths parameter into its own section with two clearly labeled cases (omit = validate all; pass paths = limit scope). - Explain WHY the paths parameter exists — so a corrupt unrelated entry doesn't block the workflows you care about. - Make the 'path not in lockfile' behavior explicit and explain it's intentional (not-yet-onboarded workflow should not fail Parse). - Rename 'Document-level invariants' to the plain 'Canonicalization' section, clarify it's about case normalization, and cross-link ParsePin and Pin.String. --- go/pkg/lockfile/lockfile.go | 59 ++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index d169579..c54f4a0 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -273,36 +273,49 @@ type Action struct { Uses []string `yaml:"uses,omitempty"` } -// Parse unmarshals YAML lockfile contents and verifies the version is -// supported. It enforces structural validity — unknown top-level keys are -// rejected and required fields must be present — but does not validate pin -// integrity (e.g. whether a SHA actually matches the ref) or action -// existence. That belongs to the consumer (e.g. gh-actions-lock's check -// command). +// Parse unmarshals the raw bytes of a lockfile and returns the parsed [File]. +// Pass the contents of .github/workflows/actions.lock (available as the +// [Path] constant) or any other lockfile source. +// +// Parse checks structural validity — unknown top-level keys are rejected +// and required [Action] fields must be present — but does not verify pin +// integrity (e.g. that a SHA matches the ref) or that actions exist on +// GitHub. Those checks belong to the caller (e.g. the check command in +// gh-actions-lock). // -// The optional paths parameter scopes per-dependency validation to only the -// entries referenced by the named workflow paths (via f.Workflows[p]). // MaxParseSize is the maximum number of bytes Parse will accept. Inputs larger // than this are rejected before any YAML parsing takes place to prevent // memory-exhaustion DoS from oversized or yaml-bomb documents. const MaxParseSize = 1 << 20 // 1 MiB - -// When paths is empty, every dependency entry is validated — the default for -// whole-file tooling (CLI regen, Dependabot). When paths is non-empty, a -// dependency entry outside the referenced set is left unchecked so one -// corrupt entry doesn't fail unrelated workflows that share the lockfile. -// A requested path absent from f.Workflows contributes zero entries and -// validates nothing — fail-open by design for workflows not yet onboarded. // -// Document-level invariants (version required/supported, unknown top-level -// keys) always run regardless of paths. +// # Optional paths parameter +// +// The variadic paths parameter is optional. Most callers should omit it. +// +// - Omit paths (or pass nil) to validate every dependency entry in the +// lockfile. This is the right choice for whole-file tooling: CLI +// regeneration, Dependabot, schema linters. +// +// - Pass one or more repo-relative workflow file paths (e.g. +// ".github/workflows/deploy.yml") to limit field validation to only the +// dependency entries referenced by those workflows. Entries outside the +// requested set are still parsed and returned, but required-field checks +// are skipped for them. This lets a single corrupt unrelated entry fail +// without blocking the workflows you actually care about. +// +// A path that does not appear in the lockfile's workflows map silently +// contributes zero entries to validate — the lockfile is returned as-is for +// that path. This is intentional: a workflow not yet onboarded into the +// lockfile should not cause Parse to fail. +// +// # Canonicalization // -// Action map keys and workflow dependency entries are canonicalized via -// ParsePin so downstream lookups by canonical key (e.g. pin.String()) match -// regardless of the source casing of owner/repo/algo/hex in the YAML. -// Entries that do not parse as a valid pin are left untouched; consumers -// can flag them via diagnostics. Workflow path keys are NOT canonicalized -// — filesystem paths are case-sensitive on the platforms we run on. +// Action map keys and workflow dependency entries are lowercased via +// [ParsePin] so that lookups by [Pin.String] succeed regardless of the +// source casing of owner, repo, algorithm, or hex in the YAML. Entries +// that are not valid pin strings are preserved verbatim for caller +// diagnostics. Workflow path keys are NOT canonicalized — file paths are +// case-sensitive on Linux. func Parse(contents []byte, paths ...string) (File, error) { if len(contents) > MaxParseSize { return File{}, &ParseError{ From 359ea4677dc6772b09f22c60be4dad3f08ba85a4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 16:39:13 -0500 Subject: [PATCH 11/13] docs: replace unrenderable field doc links with plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkgsite (and godoc) only hyperlink doc references to methods and package-level symbols — not struct fields. [File.Workflows] and [File.Dependencies] rendered as literal bracket-wrapped text instead of links. Replace all field references with plain field names; keep [File.LookupWorkflow] since it's a method and does render correctly. --- go/pkg/lockfile/doc.go | 6 +++--- go/pkg/lockfile/lockfile.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go/pkg/lockfile/doc.go b/go/pkg/lockfile/doc.go index 7fee67f..2068d84 100644 --- a/go/pkg/lockfile/doc.go +++ b/go/pkg/lockfile/doc.go @@ -4,15 +4,15 @@ // # Getting started // // [Parse] is the primary entry point. Hand it the raw bytes of -// .github/workflows/actions.lock and it returns a [File] whose [File.Workflows] -// and [File.Dependencies] maps let you look up every pinned action for a +// .github/workflows/actions.lock and it returns a [File] whose Workflows +// and Dependencies maps let you look up every pinned action for a // workflow: // // f, err := lockfile.Parse(contents) // pins, ok := f.LookupWorkflow(".github/workflows/release.yml") // // [File.LookupWorkflow] returns canonical pin key strings. Each key can be -// looked up in [File.Dependencies] to retrieve the associated [Action] +// looked up in File.Dependencies to retrieve the associated [Action] // metadata (commit hash, branch, tag, repository IDs). // // # Parsing uses: strings diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index c54f4a0..2352dc9 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -124,7 +124,7 @@ type File struct { // ".github/workflows/release.yml") to the flat, transitive list of // canonical pin keys that workflow depends on. Pin keys are in // OWNER/REPO@REF:ALGO-HEX form and serve as lookup keys into - // [File.Dependencies]. Prefer [File.LookupWorkflow] over indexing this + // Dependencies. Prefer [File.LookupWorkflow] over indexing this // map directly. Workflows map[string][]string `yaml:"workflows"` @@ -223,7 +223,7 @@ func mappingEntry(m *yaml.Node, key string) (k, v *yaml.Node) { // // Each string in the returned slice is a canonical pin key in // OWNER/REPO@REF:ALGO-HEX form. To retrieve the full action metadata for a -// pin, look it up in [File.Dependencies]: +// pin, look it up in File.Dependencies: // // pins, ok := f.LookupWorkflow(".github/workflows/deploy.yml") // for _, key := range pins { From 8332c2281ca53a1501eb685ec0ee5a8271fae51a Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 16:58:48 -0500 Subject: [PATCH 12/13] version: remove duplicated SemVer type doc comment --- go/pkg/lockfile/version.go | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/go/pkg/lockfile/version.go b/go/pkg/lockfile/version.go index 3614f99..7e0bd96 100644 --- a/go/pkg/lockfile/version.go +++ b/go/pkg/lockfile/version.go @@ -6,24 +6,6 @@ import ( "strconv" ) -// SemVer holds parsed semantic version components. -// -// API stability: the type and its comparison helpers (Greater, Narrows, -// UpgradeOver, MajorTag, MinorTag, IsFull) are part of the exported surface -// because the tag recommendation engine in downstream consumers relies on -// them. Their semantics are deliberately non-strict-semver (see below) and are -// committed to as-is; they are not internal helpers despite the -// recommendation-engine flavor. -// -// GitHub Actions has no first-class version scheme — a uses: ref can be -// any git ref (tag, branch, SHA, or even "main"). In practice most -// action authors follow semver-ish conventions, but the ecosystem -// diverges from strict semver in ways that golang.org/x/mod/semver -// cannot handle: bare versions without a "v" prefix ("2.0.0"), partial -// versions ("v4", "v4.2"), and arbitrary suffixes all appear in the -// wild. x/mod/semver rejects bare and partial tags, and doesn't expose -// individual components — we need Major/Minor/Patch to compute -// MajorTag, MinorTag, and IsFull for the tag recommendation engine. // SemVer holds parsed semantic version components. // // API stability: the type and its comparison helpers (Greater, Narrows, From 6511578001a7f95aa922b7bbe2ac6a409c2c1a6b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 17:06:28 -0500 Subject: [PATCH 13/13] uses: require ./ prefix in IsLocalReusableWorkflow The function only checked for a .yml/.yaml suffix, making it return true for non-local paths like '.github/workflows/ci.yml' (missing ./ prefix). This contradicts the documented contract which says the value must start with './'. Add the HasPrefix check. Also add test cases for the missing-prefix failure mode and other edge cases. --- go/pkg/lockfile/uses.go | 2 +- go/pkg/lockfile/uses_test.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go/pkg/lockfile/uses.go b/go/pkg/lockfile/uses.go index d15d301..22c1780 100644 --- a/go/pkg/lockfile/uses.go +++ b/go/pkg/lockfile/uses.go @@ -338,5 +338,5 @@ func isReusableWorkflow(path string) bool { // fetched from a specific checked-out path, while directories are run as // composite actions. func IsLocalReusableWorkflow(localUses string) bool { - return isYAMLFile(localUses) + return strings.HasPrefix(localUses, "./") && isYAMLFile(localUses) } diff --git a/go/pkg/lockfile/uses_test.go b/go/pkg/lockfile/uses_test.go index 1451201..99c59cb 100644 --- a/go/pkg/lockfile/uses_test.go +++ b/go/pkg/lockfile/uses_test.go @@ -147,9 +147,13 @@ func TestIsLocalReusableWorkflow(t *testing.T) { {name: "local reusable yaml", input: "./.github/workflows/ci.yaml", want: true}, {name: "local composite action directory", input: "./my-action", want: false}, {name: "local composite no extension", input: "./my-action/", want: false}, + {name: "missing ./ prefix yml", input: ".github/workflows/ci.yml", want: false}, + {name: "missing ./ prefix bare file", input: "ci.yml", want: false}, {name: "remote action is not local", input: "actions/checkout@v4", want: false}, {name: "remote reusable workflow is not local", input: "octo/repo/.github/workflows/ci.yml@v1", want: false}, {name: "empty string", input: "", want: false}, + {name: "yaml outside .github/workflows still true", input: "./scripts/run.yml", want: true}, + {name: "local non-yaml extension", input: "./something.txt", want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {