Skip to content

Commit f64f112

Browse files
committed
actions: drop private actions-lockfile dependency from generator
The generator pulled in github.com/github/actions-lockfile/go purely to parse a small, stable YAML file, which meant it could not build without a local clone of that (currently private) module -- forcing a gitignored go.work with a machine-specific replace and breaking any CI/bazel build. Parse the minimal core of the lockfile format directly instead (new lockfile.go: YAML unmarshal, pin-key parsing, and the semver major/minor/full logic), faithfully mirroring the canonical parser's semantics. The golden fixture (testdata/expected.yml) is unchanged, byte for byte, which proves the reimplementation matches. Added unit tests for parsePin, parseSemVer/isFull, and parseLockfile. The generator now depends only on gopkg.in/yaml.v3 and builds anywhere the Go toolchain is available, with no replace directive and no go.work. Verified end-to-end through the real extractor: on-demand 'go build' during database create succeeds with a stock toolchain, and the lockfile-pinned ref is still suppressed while an unlocked ref still fires.
1 parent 7879f40 commit f64f112

8 files changed

Lines changed: 272 additions & 55 deletions

File tree

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
11
/lockfile-extension-generator
2-
3-
# local-only module workspace carrying the actions-lockfile replace
4-
go.work
5-
go.work.sum

actions/extractor/tools/lockfile-extension-generator/README.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ the query otherwise lacks. Feeding the lockfile's bindings into
1515
`pinnedByLockfileDataModel` lets the query suppress those references while still
1616
flagging genuinely unpinned ones.
1717

18-
The generator is deliberately **transport-agnostic**. It parses the lockfile
19-
with the canonical parser at `github.com/github/actions-lockfile/go` and emits
20-
the `[workflow_path, nwo, ref]` rows the query already matches on. Today those
21-
rows ship as a data-extension model pack applied at analysis time via
22-
`--model-packs` (the same mechanism as `codeql/immutable-actions-list`). The
23-
same parsing core can later feed an extractor-native relation without changing
24-
the query.
18+
The generator is deliberately **transport-agnostic**. It parses the minimal,
19+
stable core of the lockfile format directly (see `lockfile.go`) and emits the
20+
`[workflow_path, nwo, ref]` rows the query already matches on. It has no
21+
dependency on the `github.com/github/actions-lockfile` module, so it builds
22+
anywhere the Go toolchain is available. Today those rows ship as a
23+
data-extension model pack applied at analysis time via `--model-packs` (the same
24+
mechanism as `codeql/immutable-actions-list`). The same parsing core can later
25+
feed an extractor-native relation without changing the query.
2526

2627
## Extractor integration
2728

@@ -77,16 +78,19 @@ writing anything, so it is safe to run unconditionally.
7778

7879
## Building and testing locally
7980

80-
`github.com/github/actions-lockfile` is not yet public, so the module cannot be
81-
fetched by the module proxy. Point the `replace` directive in `go.mod` at a
82-
local clone before building or testing:
81+
The generator depends only on `gopkg.in/yaml.v3`, so it builds and tests with a
82+
stock Go toolchain and no special setup:
8383

8484
```
85-
go mod edit -replace github.com/github/actions-lockfile/go=/path/to/actions-lockfile/go
85+
go build ./...
8686
go test ./...
8787
```
8888

89-
Remove the `replace` directive once actions-lockfile is published.
89+
The golden test (`testdata/expected.yml`) pins the exact generated output for a
90+
representative lockfile, so any change to parsing or ref normalization must be
91+
reflected there deliberately. The lockfile parsing in `lockfile.go` mirrors the
92+
canonical semantics of `github.com/github/actions-lockfile`; if that format
93+
evolves, update `lockfile.go` and the golden fixture together.
9094

9195
## End-to-end check
9296

actions/extractor/tools/lockfile-extension-generator/generator.go

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
// `pinnedByLockfileDataModel` extensible predicate consumed by the
44
// `actions/unpinned-tag` query.
55
//
6-
// The generator is deliberately transport-agnostic: it parses the lockfile with
7-
// the canonical parser at `github.com/github/actions-lockfile/go` and emits the
8-
// same `[workflow_path, nwo, ref]` rows the query already matches on. Today those
9-
// rows are shipped as a data-extension model pack (applied at analysis time via
10-
// `--model-packs`, exactly like `codeql/immutable-actions-list`). The same parsing
11-
// core can later feed an extractor-native TRAP relation without changing the query.
6+
// The generator is deliberately transport-agnostic: it parses the lockfile and
7+
// emits the same `[workflow_path, nwo, ref]` rows the query already matches on.
8+
// Today those rows are shipped as a data-extension model pack (applied at
9+
// analysis time via `--model-packs`, exactly like `codeql/immutable-actions-list`).
10+
// The same parsing core can later feed an extractor-native TRAP relation without
11+
// changing the query.
12+
//
13+
// The lockfile format is owned by `github.com/github/actions-lockfile`; this
14+
// generator parses the minimal, stable core it needs directly (see lockfile.go)
15+
// so it has no dependency on that (currently private) module and builds anywhere
16+
// the Go toolchain is available.
1217
package main
1318

1419
import (
1520
"fmt"
1621
"sort"
1722
"strings"
18-
19-
"github.com/github/actions-lockfile/go/pkg/lockfile"
2023
)
2124

2225
// row is a single `pinnedByLockfileDataModel` tuple: the reference
@@ -39,26 +42,26 @@ type row struct {
3942
// `uses: owner/action@v4` be recognised as pinned by a lockfile entry that
4043
// resolved to `v4.3.1`, which is the common real-world case.
4144
func rowsFromLockfile(contents []byte) ([]row, error) {
42-
f, err := lockfile.Parse(contents)
45+
doc, err := parseLockfile(contents)
4346
if err != nil {
44-
return nil, fmt.Errorf("parsing lockfile: %w", err)
47+
return nil, err
4548
}
4649

4750
seen := make(map[row]struct{})
48-
for workflowPath, pinKeys := range f.Workflows {
51+
for workflowPath, pinKeys := range doc.Workflows {
4952
for _, key := range pinKeys {
50-
pin, ok := lockfile.ParsePin(key)
53+
nwo, keyRef, ok := parsePin(key)
5154
if !ok {
5255
continue
5356
}
5457
// Prefer the resolved ref recorded in the dependency metadata; fall
5558
// back to the ref embedded in the pin key.
56-
ref := pin.Ref
57-
if dep, ok := f.Dependencies[key]; ok && dep.Ref != "" {
59+
ref := keyRef
60+
if dep, ok := doc.Dependencies[key]; ok && dep.Ref != "" {
5861
ref = dep.Ref
5962
}
6063
for _, r := range refVariants(ref) {
61-
seen[row{workflowPath: workflowPath, nwo: pin.NWO, ref: r}] = struct{}{}
64+
seen[row{workflowPath: workflowPath, nwo: nwo, ref: r}] = struct{}{}
6265
}
6366
}
6467
}
@@ -84,15 +87,15 @@ func rowsFromLockfile(contents []byte) ([]row, error) {
8487
// this is the tag itself plus its major.minor (`v4.3`) and major-only (`v4`)
8588
// forms; for anything else (a branch, a partial tag, a SHA) it is just the ref.
8689
func refVariants(ref string) []string {
87-
sv, ok := lockfile.ParseSemVer(ref)
88-
if !ok || !sv.IsFull() {
90+
sv, ok := parseSemVer(ref)
91+
if !ok || !sv.isFull() {
8992
return []string{ref}
9093
}
9194
variants := []string{ref}
92-
if minor := sv.MinorTag(); minor != ref {
95+
if minor := sv.minorTag(); minor != ref {
9396
variants = append(variants, minor)
9497
}
95-
if major := sv.MajorTag(); major != ref {
98+
if major := sv.majorTag(); major != ref {
9699
variants = append(variants, major)
97100
}
98101
return variants

actions/extractor/tools/lockfile-extension-generator/go.mod

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,4 @@ module github.com/github/codeql/actions/extractor/tools/lockfile-extension-gener
22

33
go 1.23
44

5-
require github.com/github/actions-lockfile/go v0.0.0-00010101000000-000000000000
6-
7-
require gopkg.in/yaml.v3 v3.0.1 // indirect
8-
9-
// LOCAL TESTING ONLY: github.com/github/actions-lockfile is not yet public, so it
10-
// cannot be fetched by the module proxy. Point this at a local clone of the
11-
// repository to build and test the generator:
12-
//
13-
// go mod edit -replace github.com/github/actions-lockfile/go=/path/to/actions-lockfile/go
14-
//
15-
// Remove this replace directive once actions-lockfile is published.
5+
require gopkg.in/yaml.v3 v3.0.1

actions/extractor/tools/lockfile-extension-generator/go.sum

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2-
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3-
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4-
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5-
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6-
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
71
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
82
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
93
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
// lockfilePath is the repo-relative location of the GitHub Actions lockfile.
13+
const lockfilePath = ".github/workflows/actions.lock"
14+
15+
// lockfileDoc is the subset of the Actions lockfile (schema v0.0.2) that the
16+
// generator needs: the per-workflow flat list of canonical pin keys, and the
17+
// resolved ref recorded for each dependency.
18+
//
19+
// The full format is owned by `github.com/github/actions-lockfile`. We parse it
20+
// directly here rather than depending on that (currently private) module so the
21+
// generator builds anywhere the Go toolchain is available, with no module-proxy
22+
// access to a private repository. The fields we read are a stable, minimal core
23+
// of the format; see the schema in that repository for the authoritative shape.
24+
type lockfileDoc struct {
25+
Version string `yaml:"version"`
26+
Workflows map[string][]string `yaml:"workflows"`
27+
Dependencies map[string]lockfileDependency `yaml:"dependencies"`
28+
}
29+
30+
type lockfileDependency struct {
31+
Ref string `yaml:"ref"`
32+
Commit string `yaml:"commit"`
33+
}
34+
35+
func parseLockfile(contents []byte) (*lockfileDoc, error) {
36+
var doc lockfileDoc
37+
if err := yaml.Unmarshal(contents, &doc); err != nil {
38+
return nil, fmt.Errorf("parsing lockfile YAML: %w", err)
39+
}
40+
return &doc, nil
41+
}
42+
43+
// parsePin parses a canonical lockfile pin key of the form "OWNER/REPO@REF".
44+
//
45+
// It mirrors `lockfile.ParsePin`: the "@" separates repo from ref, the repo
46+
// portion must contain exactly one "/", owner and repo are lower-cased (git
47+
// forges treat them case-insensitively) while the ref preserves its casing, and
48+
// a ref may not be empty or contain a colon. Sub-action paths
49+
// ("owner/repo/sub@ref") are rejected because the lockfile pins at repo+ref
50+
// granularity. Returns ok=false for anything that does not match.
51+
func parsePin(s string) (nwo, ref string, ok bool) {
52+
atIdx := strings.IndexByte(s, '@')
53+
if atIdx <= 0 || atIdx == len(s)-1 {
54+
return "", "", false
55+
}
56+
repoPath := s[:atIdx]
57+
ref = s[atIdx+1:]
58+
59+
if strings.Count(repoPath, "/") != 1 {
60+
return "", "", false
61+
}
62+
slashIdx := strings.IndexByte(repoPath, '/')
63+
owner := repoPath[:slashIdx]
64+
repo := repoPath[slashIdx+1:]
65+
if owner == "" || repo == "" {
66+
return "", "", false
67+
}
68+
if ref == "" || strings.Contains(ref, ":") {
69+
return "", "", false
70+
}
71+
nwo = strings.ToLower(owner) + "/" + strings.ToLower(repo)
72+
return nwo, ref, true
73+
}
74+
75+
// semVer holds the parsed components of a semver-ish Actions tag. The scheme is
76+
// deliberately lax (bare "2.0.0", partial "v4"/"v4.2", arbitrary suffixes all
77+
// appear in the wild), matching `lockfile.SemVer`.
78+
type semVer struct {
79+
prefix string
80+
major int
81+
minor int
82+
patch int
83+
rest string
84+
raw string
85+
}
86+
87+
var versionRE = regexp.MustCompile(`^(v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$`)
88+
89+
func parseSemVer(tag string) (semVer, bool) {
90+
if isFullSha(tag) {
91+
return semVer{}, false
92+
}
93+
m := versionRE.FindStringSubmatch(tag)
94+
if m == nil {
95+
return semVer{}, false
96+
}
97+
major, err := strconv.Atoi(m[2])
98+
if err != nil {
99+
return semVer{}, false
100+
}
101+
minor := 0
102+
if m[3] != "" {
103+
if minor, err = strconv.Atoi(m[3]); err != nil {
104+
return semVer{}, false
105+
}
106+
}
107+
patch := 0
108+
if m[4] != "" {
109+
if patch, err = strconv.Atoi(m[4]); err != nil {
110+
return semVer{}, false
111+
}
112+
}
113+
return semVer{prefix: m[1], major: major, minor: minor, patch: patch, rest: m[5], raw: tag}, true
114+
}
115+
116+
func (s semVer) majorTag() string { return fmt.Sprintf("%s%d", s.prefix, s.major) }
117+
118+
func (s semVer) minorTag() string { return fmt.Sprintf("%s%d.%d", s.prefix, s.major, s.minor) }
119+
120+
// isFull reports whether the tag has all three components (major.minor.patch)
121+
// and no pre-release suffix. Tags like "v4" or "v4.2" return false. Only a full
122+
// version uniquely identifies a release, so only full versions get expanded
123+
// into their shorter mutable forms.
124+
func (s semVer) isFull() bool {
125+
return s.rest == "" && s.raw != s.majorTag() && s.raw != s.minorTag()
126+
}
127+
128+
// isFullSha reports whether s looks like a full commit hash (SHA-1 or SHA-256),
129+
// which must not be treated as a version tag.
130+
func isFullSha(s string) bool {
131+
if len(s) != 40 && len(s) != 64 {
132+
return false
133+
}
134+
for _, c := range s {
135+
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
136+
return false
137+
}
138+
}
139+
return true
140+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestParsePin(t *testing.T) {
6+
cases := []struct {
7+
in string
8+
wantNWO string
9+
wantRef string
10+
wantOK bool
11+
}{
12+
{"actions/checkout@v4.3.1", "actions/checkout", "v4.3.1", true},
13+
{"Some-Owner/Pinned-Action@v1.2.0", "some-owner/pinned-action", "v1.2.0", true}, // owner/repo lower-cased, ref preserved
14+
{"owner/repo@V1.2.0", "owner/repo", "V1.2.0", true}, // ref casing preserved
15+
{"owner/repo@sha1-deadbeef", "owner/repo", "sha1-deadbeef", true}, // colon-free ref accepted
16+
{"owner/repo", "", "", false}, // no @
17+
{"owner/repo@", "", "", false}, // empty ref
18+
{"@v1", "", "", false}, // no repo path
19+
{"owner/repo/sub@v1", "", "", false}, // sub-action path rejected
20+
{"owneronly@v1", "", "", false}, // no slash
21+
{"owner/repo@ref:with:colon", "", "", false}, // colon rejected
22+
}
23+
for _, c := range cases {
24+
nwo, ref, ok := parsePin(c.in)
25+
if ok != c.wantOK || nwo != c.wantNWO || ref != c.wantRef {
26+
t.Errorf("parsePin(%q) = (%q, %q, %v), want (%q, %q, %v)",
27+
c.in, nwo, ref, ok, c.wantNWO, c.wantRef, c.wantOK)
28+
}
29+
}
30+
}
31+
32+
func TestParseSemVerAndIsFull(t *testing.T) {
33+
cases := []struct {
34+
tag string
35+
wantOK bool
36+
wantFull bool
37+
wantMajor string
38+
wantMinor string
39+
}{
40+
{"v4.3.1", true, true, "v4", "v4.3"},
41+
{"4.3.1", true, true, "4", "4.3"}, // bare, no "v" prefix
42+
{"v4", true, false, "v4", "v4.0"},
43+
{"v4.3", true, false, "v4", "v4.3"},
44+
{"v2.0.0-beta.1", true, false, "v2", "v2.0"}, // pre-release: not full
45+
{"main", false, false, "", ""},
46+
{"0123456789abcdef0123456789abcdef01234567", false, false, "", ""}, // 40-char SHA
47+
}
48+
for _, c := range cases {
49+
sv, ok := parseSemVer(c.tag)
50+
if ok != c.wantOK {
51+
t.Errorf("parseSemVer(%q) ok = %v, want %v", c.tag, ok, c.wantOK)
52+
continue
53+
}
54+
if !ok {
55+
continue
56+
}
57+
if got := sv.isFull(); got != c.wantFull {
58+
t.Errorf("parseSemVer(%q).isFull() = %v, want %v", c.tag, got, c.wantFull)
59+
}
60+
if got := sv.majorTag(); got != c.wantMajor {
61+
t.Errorf("parseSemVer(%q).majorTag() = %q, want %q", c.tag, got, c.wantMajor)
62+
}
63+
if got := sv.minorTag(); got != c.wantMinor {
64+
t.Errorf("parseSemVer(%q).minorTag() = %q, want %q", c.tag, got, c.wantMinor)
65+
}
66+
}
67+
}
68+
69+
func TestParseLockfile(t *testing.T) {
70+
doc, err := parseLockfile([]byte(`version: 'v0.0.2'
71+
workflows:
72+
'.github/workflows/ci.yml':
73+
- 'actions/checkout@v4.3.1'
74+
dependencies:
75+
'actions/checkout@v4.3.1':
76+
ref: 'v4.3.1'
77+
commit: 'sha1-abc'
78+
`))
79+
if err != nil {
80+
t.Fatalf("parseLockfile: %v", err)
81+
}
82+
if doc.Version != "v0.0.2" {
83+
t.Errorf("version = %q, want v0.0.2", doc.Version)
84+
}
85+
keys := doc.Workflows[".github/workflows/ci.yml"]
86+
if len(keys) != 1 || keys[0] != "actions/checkout@v4.3.1" {
87+
t.Errorf("workflows entry = %v", keys)
88+
}
89+
if dep := doc.Dependencies["actions/checkout@v4.3.1"]; dep.Ref != "v4.3.1" {
90+
t.Errorf("dependency ref = %q, want v4.3.1", dep.Ref)
91+
}
92+
}

0 commit comments

Comments
 (0)