diff --git a/README.md b/README.md index 7a439bf..c4c06a8 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ func main() { | elm | elm.json, elm-package.json | | | gem | Gemfile, gems.rb, *.gemspec | Gemfile.lock, gems.locked | | git | .gitmodules | | -| github-actions | .github/workflows/*.yml | | +| github-actions | .github/workflows/*.yml | actions.lock | | golang | go.mod, Godeps, glide.yaml, Gopkg.toml | Godeps.json, glide.lock, Gopkg.lock, vendor.json, go-resolved-dependencies.json, vendor/manifest | | guix | manifest.scm | | | hackage | *.cabal | stack.yaml.lock, cabal.config, cabal.project.freeze | diff --git a/internal/github_actions/actions_lock.go b/internal/github_actions/actions_lock.go new file mode 100644 index 0000000..6f23352 --- /dev/null +++ b/internal/github_actions/actions_lock.go @@ -0,0 +1,87 @@ +package github_actions + +import ( + "sort" + "strings" + + "github.com/git-pkgs/manifests/internal/core" + "gopkg.in/yaml.v3" +) + +func init() { + core.Register("github-actions", core.Lockfile, &actionsLockParser{}, core.ExactMatch("actions.lock")) +} + +// actionsLockParser parses the GitHub Actions dependency lockfile written by +// gh actions-lock to .github/workflows/actions.lock. The format is pre-1.0; +// this parser is deliberately lenient (unknown fields ignored, no version +// gate) so older git-pkgs binaries keep working across schema bumps. +// +// See https://github.com/github/actions-lockfile for the schema. +type actionsLockParser struct{} + +type actionsLockFile struct { + Version string `yaml:"version"` + Workflows map[string][]string `yaml:"workflows"` + Dependencies map[string]actionsLockAction `yaml:"dependencies"` +} + +type actionsLockAction struct { + Ref string `yaml:"ref"` + Commit string `yaml:"commit"` +} + +func (p *actionsLockParser) Parse(filename string, content []byte) (*core.Result, error) { + var lock actionsLockFile + if err := yaml.Unmarshal(content, &lock); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + // Pin keys that appear in a workflows: list are direct dependencies of + // the repo; the rest arrived transitively via an action's uses: list. + direct := make(map[string]bool) + for _, pins := range lock.Workflows { + for _, pin := range pins { + direct[pin] = true + } + } + + // Sort keys so output order is stable across runs. + keys := make([]string, 0, len(lock.Dependencies)) + for k := range lock.Dependencies { + keys = append(keys, k) + } + sort.Strings(keys) + + deps := make([]core.Dependency, 0, len(keys)) + for _, key := range keys { + action := lock.Dependencies[key] + name, keyRef := splitPin(key) + if name == "" { + continue + } + // Prefer the entry's ref: field (the resolved tag). Fall back to the + // ref segment of the pin key when it's absent. + version := action.Ref + if version == "" { + version = keyRef + } + deps = append(deps, core.Dependency{ + Name: name, + Version: version, + Scope: core.Runtime, + Integrity: action.Commit, + Direct: direct[key], + }) + } + + return &core.Result{Dependencies: deps}, nil +} + +// splitPin splits an OWNER/REPO@REF pin key into name and ref. +func splitPin(key string) (name, ref string) { + if idx := strings.Index(key, "@"); idx > 0 { + return key[:idx], key[idx+1:] + } + return key, "" +} diff --git a/internal/github_actions/actions_lock_test.go b/internal/github_actions/actions_lock_test.go new file mode 100644 index 0000000..495f71c --- /dev/null +++ b/internal/github_actions/actions_lock_test.go @@ -0,0 +1,72 @@ +package github_actions + +import ( + "os" + "testing" + + "github.com/git-pkgs/manifests/internal/core" +) + +func TestActionsLock(t *testing.T) { + content, err := os.ReadFile("../../testdata/github-actions/actions.lock") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &actionsLockParser{} + res, err := parser.Parse("actions.lock", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(res.Dependencies) != 5 { + t.Fatalf("expected 5 dependencies, got %d: %+v", len(res.Dependencies), res.Dependencies) + } + + byKey := make(map[string]core.Dependency) + for _, d := range res.Dependencies { + byKey[d.Name+"@"+d.Version] = d + } + + checkout5, ok := byKey["actions/checkout@v5.0.1"] + if !ok { + t.Fatalf("expected actions/checkout@v5.0.1, got %+v", res.Dependencies) + } + if checkout5.Integrity != "sha1-93cb6efe18208431cddfb8368fd83d5badbf9bfd" { + t.Errorf("checkout@v5.0.1 integrity = %q", checkout5.Integrity) + } + if !checkout5.Direct { + t.Error("checkout@v5.0.1 should be direct (listed in workflows)") + } + if checkout5.Scope != core.Runtime { + t.Errorf("checkout@v5.0.1 scope = %q, want runtime", checkout5.Scope) + } + + if _, ok := byKey["actions/checkout@v6.0.2"]; !ok { + t.Error("expected second actions/checkout entry at v6.0.2") + } + + // actions/attest is only reached via cli/gh-extension-precompile's uses: + // list, so it is transitive. Its pin key uses a SHA but ref: carries the + // tag, which is what should surface as the version. + attest, ok := byKey["actions/attest@v1.4.1"] + if !ok { + t.Fatalf("expected actions/attest@v1.4.1, got %+v", res.Dependencies) + } + if attest.Direct { + t.Error("actions/attest should not be direct") + } +} + +func TestActionsLockIdentify(t *testing.T) { + parser, eco, kind := core.IdentifyParser(".github/workflows/actions.lock") + if parser == nil { + t.Fatal("expected .github/workflows/actions.lock to be identified") + } + if eco != "github-actions" { + t.Errorf("ecosystem = %q, want github-actions", eco) + } + if kind != core.Lockfile { + t.Errorf("kind = %q, want lockfile", kind) + } +} diff --git a/manifests_test.go b/manifests_test.go index 63eee5a..2cb298a 100644 --- a/manifests_test.go +++ b/manifests_test.go @@ -192,6 +192,10 @@ func TestIdentifyFiles(t *testing.T) { {"Dockerfile", "docker", Manifest, true}, {"docker-compose.yml", "docker", Manifest, true}, + // github-actions + {".github/workflows/ci.yml", "github-actions", Manifest, true}, + {".github/workflows/actions.lock", "github-actions", Lockfile, true}, + // unknown {"unknown.txt", "", "", false}, {"random.file", "", "", false}, diff --git a/testdata/github-actions/actions.lock b/testdata/github-actions/actions.lock new file mode 100644 index 0000000..9870647 --- /dev/null +++ b/testdata/github-actions/actions.lock @@ -0,0 +1,39 @@ +# This file is machine-generated by `gh actions-lock`. +# Do not edit by hand; run `gh actions-lock` to update. +# Docs: https://gh.io/actions-lockfile +version: 'v0.0.2' +workflows: + '.github/workflows/release.yml': + - 'actions/checkout@v6.0.2' + - 'cli/gh-extension-precompile@v2.1.0' + '.github/workflows/test.yml': + - 'actions/checkout@v5.0.1' + - 'actions/setup-go@v6.4.0' +dependencies: + 'actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d': + ref: 'v1.4.1' + commit: 'sha1-67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d' + owner_id: 44036562 + repo_id: 760701061 + 'actions/checkout@v5.0.1': + ref: 'v5.0.1' + commit: 'sha1-93cb6efe18208431cddfb8368fd83d5badbf9bfd' + owner_id: 44036562 + repo_id: 197814629 + 'actions/checkout@v6.0.2': + ref: 'v6.0.2' + commit: 'sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd' + owner_id: 44036562 + repo_id: 197814629 + 'actions/setup-go@v6.4.0': + ref: 'v6.4.0' + commit: 'sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c' + owner_id: 44036562 + repo_id: 192624594 + 'cli/gh-extension-precompile@v2.1.0': + ref: 'v2.1.0' + commit: 'sha1-9e2237c30f869ad3bcaed6a4be2cd43564dd421b' + owner_id: 59704711 + repo_id: 421583656 + uses: + - 'actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d'