diff --git a/README.md b/README.md index 0b52d3f..a58c4de 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > [!NOTE] > **Public preview.** This project is pre-1.0 and under active development. The -> lockfile schema (currently `v0.0.2`) and the Go module's exported surface may +> lockfile schema (currently `v0.0.3`) and the Go module's exported surface may > change before a `v1.0.0` release. Pin to an exact version and expect breaking > changes between minor versions until then. @@ -89,14 +89,14 @@ _ = file The lockfile is a YAML document whose shape is defined by a JSON Schema 2020-12 document embedded in the package and reachable via `lockfile.Schema()`. -The current schema version is `v0.0.2` -([`schema/lockfile-v0.0.2.json`](https://github.com/github/actions-lockfile/blob/main/schema/lockfile-v0.0.2.json)). +The current schema version is `v0.0.3` +([`schema/lockfile-v0.0.3.json`](https://github.com/github/actions-lockfile/blob/main/schema/lockfile-v0.0.3.json)). The on-disk file lives at [`Path`](https://github.com/github/actions-lockfile/blob/main/go/pkg/lockfile/lockfile.go) (`.github/workflows/actions.lock`) and has three top-level keys: ```yaml -version: v0.0.2 +version: v0.0.3 workflows: # workflow path -> flat, transitive list of pin keys .github/workflows/release.yml: @@ -114,18 +114,25 @@ A pin key is `OWNER/REPO@REF`. The same key appears in both `workflows` (as flat transitive lists) and `dependencies` (as deduplicated graph entries with `uses:` links to direct dependencies). -The parser also reads v0.0.1 lockfiles (which used `tag`/`branch` fields and -`:algo-hex` suffixed pin keys) and normalizes them to the v0.0.2 `File` struct. -Use `ParseWithPolicy` with a `VersionPolicy` to control which versions are -accepted. +The `hostname` field is optional in v0.0.3. Dotcom-only producers may omit it. +Hostname-aware producers running in Proxima record the canonical hostname for +every direct and transitive dependency, including `github.com` dependencies in +mixed graphs. When present, `hostname` must be the bare lowercase `github.com` +hostname or a lowercase GHE tenant hostname such as `octocorp.ghe.com`. + +The parser also reads the dotcom-only v0.0.1 and v0.0.2 lockfiles, defaulting +every dependency's `hostname` to `github.com` in memory. v0.0.1 `tag`/`branch` +fields and `:algo-hex` suffixed pin keys are also normalized to the v0.0.3 +`File` struct. Parsing does not rewrite the source lockfile. Use +`ParseWithPolicy` with a `VersionPolicy` to control which versions are accepted. ## Compatibility and stability - The Go module follows [semver](https://semver.org/). The publicly documented exported surface is intended to be stable across minor versions. - The lockfile schema is versioned independently. The current schema version - is `v0.0.2`, embedded in the package and emitted as the `version` field of - every lockfile. The parser reads both v0.0.1 and v0.0.2. + is `v0.0.3`, embedded in the package and emitted as the `version` field of + every lockfile. The parser reads v0.0.1 through v0.0.3. - Pre-1.0, the package reserves the right to remove any incidentally-exported helper not covered by the [Usage](#usage) and [What this package does](#what-this-package-does) sections. Those sections diff --git a/go/pkg/lockfile/bench_test.go b/go/pkg/lockfile/bench_test.go index a1db06f..b4a3140 100644 --- a/go/pkg/lockfile/bench_test.go +++ b/go/pkg/lockfile/bench_test.go @@ -28,6 +28,35 @@ dependencies: - actions/checkout@v4 `) +var benchV003 = []byte(`version: v0.0.3 +workflows: + .github/workflows/ci.yml: + - actions/checkout@v4 + - actions/setup-go@v5 + - actions/cache@v4 +dependencies: + actions/checkout@v4: + hostname: github.com + ref: v4 + commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 + owner_id: 44036562 + repo_id: 197814629 + actions/setup-go@v5: + hostname: github.com + ref: v5 + commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + owner_id: 44036562 + repo_id: 249058325 + actions/cache@v4: + hostname: github.com + ref: v4 + commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + owner_id: 44036562 + repo_id: 251882839 + uses: + - actions/checkout@v4 +`) + var benchV001 = []byte(`version: v0.0.1 workflows: .github/workflows/ci.yml: @@ -54,6 +83,14 @@ dependencies: - actions/checkout@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683 `) +func BenchmarkParse_V003(b *testing.B) { + for i := 0; i < b.N; i++ { + if _, err := Parse(benchV003); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkParse_V002(b *testing.B) { for i := 0; i < b.N; i++ { if _, err := Parse(benchV002); err != nil { @@ -78,3 +115,12 @@ func BenchmarkParseWithPolicy_V002(b *testing.B) { } } } + +func BenchmarkParseWithPolicy_V003(b *testing.B) { + policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.3"} + for i := 0; i < b.N; i++ { + if _, err := ParseWithPolicy(benchV003, policy); err != nil { + b.Fatal(err) + } + } +} diff --git a/go/pkg/lockfile/compat.go b/go/pkg/lockfile/compat.go index 78bda77..057ef18 100644 --- a/go/pkg/lockfile/compat.go +++ b/go/pkg/lockfile/compat.go @@ -9,7 +9,7 @@ import ( // supportedVersions lists all schema versions this binary can parse, // ordered from oldest to newest. -var supportedVersions = []string{"v0.0.1", "v0.0.2"} +var supportedVersions = []string{"v0.0.1", "v0.0.2", "v0.0.3"} // ErrUnsupportedVersion is the sentinel returned when ParseWithPolicy refuses a // lockfile whose version is older than the consumer's minimum. @@ -88,9 +88,17 @@ func parseInternal(contents []byte, policy *VersionPolicy, paths []string) (File return File{}, pe } - // For v0.0.1 files, migrate branch/tag → ref and canonicalize legacy pin keys. + // v0.0.1 and v0.0.2 were dotcom-only. Normalize their in-memory + // representation without changing the parsed YAML tree. if f.Version == "v0.0.1" { migrateV001Actions(&f) + } + if f.Version == "v0.0.1" || f.Version == "v0.0.2" { + migrateLegacyHostnames(&f) + } + + // For v0.0.1 files, canonicalize legacy pin keys. + if f.Version == "v0.0.1" { if conflictKey, err := canonicalizeActionsV001(&f); err != nil { pe := &ParseError{Msg: err.Error(), err: err} if l, c, ok := f.KeyPosition("dependencies", conflictKey); ok { @@ -218,9 +226,21 @@ func positionFromNode(node *yaml.Node, key string) (line, col int, ok bool) { return v.Line, v.Column, true } -// ── v0.0.1 compat layer ───────────────────────────────────────────────────── +// ── Legacy compatibility ───────────────────────────────────────────────────── -// allowedActionKeysV001 extends the v0.0.2 set with the legacy branch/tag fields. +const legacyDotcomHostname = "github.com" + +// migrateLegacyHostnames defaults dependencies from the dotcom-only schemas to +// github.com. It updates only the decoded File; the retained YAML node remains +// an exact representation of the caller's input. +func migrateLegacyHostnames(f *File) { + for key, action := range f.Dependencies { + action.Hostname = legacyDotcomHostname + f.Dependencies[key] = action + } +} + +// allowedActionKeysV001 describes the legacy branch/tag action shape. var allowedActionKeysV001 = map[string]struct{}{ "tag": {}, "branch": {}, @@ -233,6 +253,17 @@ var allowedActionKeysV001 = map[string]struct{}{ // requiredActionKeysV001 — v0.0.1 did not require ref (it used tag/branch). var requiredActionKeysV001 = []string{"commit", "owner_id", "repo_id"} +// v0.0.2 introduced ref and the current pin grammar but had no hostname. +var allowedActionKeysV002 = map[string]struct{}{ + "ref": {}, + "commit": {}, + "owner_id": {}, + "repo_id": {}, + "uses": {}, +} + +var requiredActionKeysV002 = []string{"ref", "commit", "owner_id", "repo_id"} + // migrateV001Actions walks the YAML node tree for a v0.0.1 lockfile and // populates Action.Ref from the tag/branch fields using BestRef. func migrateV001Actions(f *File) { @@ -404,9 +435,13 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars allowed := allowedActionKeys required := requiredActionKeys - if version == "v0.0.1" { + switch version { + case "v0.0.1": allowed = allowedActionKeysV001 required = requiredActionKeysV001 + case "v0.0.2": + allowed = allowedActionKeysV002 + required = requiredActionKeysV002 } root := docMapping(f.node) @@ -424,28 +459,24 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars return nil } - var inScope map[string]struct{} - if len(paths) > 0 { - inScope = make(map[string]struct{}) - for _, p := range paths { - for _, pin := range f.Workflows[p] { - inScope[pin] = struct{}{} - } - } - } + inScope := scopedDependencyPins(f, paths, version) for i := 0; i+1 < len(deps.Content); i += 2 { pinKey := deps.Content[i] action := deps.Content[i+1] - if action.Kind != yaml.MappingNode { - continue - } if inScope != nil { - if _, ok := inScope[pinKey.Value]; !ok { + if _, ok := inScope[canonicalPinForVersion(pinKey.Value, version)]; !ok { continue } } + if action.Kind != yaml.MappingNode { + return &ParseError{ + Line: action.Line, + Column: action.Column, + Msg: fmt.Sprintf("action metadata for dependency %q must be a mapping", pinKey.Value), + } + } present := make(map[string]struct{}, len(action.Content)/2) for j := 0; j+1 < len(action.Content); j += 2 { @@ -484,6 +515,55 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars return nil } +func scopedDependencyPins(f *File, paths []string, version string) map[string]struct{} { + if len(paths) == 0 { + return nil + } + + actionsByPin := make(map[string][]Action, len(f.Dependencies)) + for key, action := range f.Dependencies { + pin := canonicalPinForVersion(key, version) + actionsByPin[pin] = append(actionsByPin[pin], action) + } + + inScope := make(map[string]struct{}) + var pending []string + for _, path := range paths { + for _, pin := range f.Workflows[path] { + pending = append(pending, canonicalPinForVersion(pin, version)) + } + } + + for len(pending) > 0 { + pin := pending[len(pending)-1] + pending = pending[:len(pending)-1] + if _, seen := inScope[pin]; seen { + continue + } + inScope[pin] = struct{}{} + for _, action := range actionsByPin[pin] { + for _, used := range action.Uses { + pending = append(pending, canonicalPinForVersion(used, version)) + } + } + } + + return inScope +} + +func canonicalPinForVersion(value, version string) string { + if version == "v0.0.1" { + if pin, ok := parsePinV001(value); ok { + return pin.String() + } + return value + } + if pin, ok := ParsePin(value); ok { + return pin.String() + } + return value +} + // rejectDuplicateDependencyKeys walks the top-level `dependencies` mapping and // returns a positioned ParseError on the first duplicate key. yaml.v3's Decode // would reject duplicates too, but with a generic message; this yields a diff --git a/go/pkg/lockfile/compat_test.go b/go/pkg/lockfile/compat_test.go index 2391a4c..6281ad0 100644 --- a/go/pkg/lockfile/compat_test.go +++ b/go/pkg/lockfile/compat_test.go @@ -33,13 +33,49 @@ dependencies: // Pin keys are canonicalized to the v0.0.2 format (no :algo-hex suffix). checkout := f.Dependencies["actions/checkout@v4"] + assert.Equal(t, "github.com", checkout.Hostname) assert.Equal(t, "v4", checkout.Ref) assert.Equal(t, "sha1-11bd71901bbe5b1630ceea73d27597364c9af683", checkout.Commit) internal := f.Dependencies["actions/internal@trunk"] + assert.Equal(t, "github.com", internal.Hostname) assert.Equal(t, "trunk", internal.Ref) } +func TestParse_V002_HostnameDefaultsToDotcomWithoutMutatingInput(t *testing.T) { + input := []byte(`version: v0.0.2 +dependencies: + actions/checkout@v4: + ref: v4 + commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 + owner_id: 1 + repo_id: 2 +`) + original := append([]byte(nil), input...) + + f, err := Parse(input) + require.NoError(t, err) + + assert.Equal(t, Version, f.Version) + assert.Equal(t, "github.com", f.Dependencies["actions/checkout@v4"].Hostname) + assert.Equal(t, original, input, "Parse must not rewrite caller-owned lockfile bytes") +} + +func TestParse_V002_HostnameFieldRejected(t *testing.T) { + input := `version: v0.0.2 +dependencies: + actions/checkout@v4: + hostname: github.com + ref: v4 + commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 + owner_id: 1 + repo_id: 2 +` + _, err := Parse([]byte(input)) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown action field "hostname"`) +} + func TestParse_V001_TagWinsOverBranch(t *testing.T) { input := `version: v0.0.1 dependencies: @@ -140,18 +176,20 @@ dependencies: // ── VersionPolicy tests ────────────────────────────────────────────────────── func TestParseWithPolicy_AcceptsVersionInRange(t *testing.T) { - input := `version: v0.0.2 + input := `version: v0.0.3 dependencies: actions/checkout@v4: + hostname: octocorp.ghe.com ref: v4 commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 owner_id: 1 repo_id: 2 ` - policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.2"} + policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.3"} f, err := ParseWithPolicy([]byte(input), policy) require.NoError(t, err) assert.Equal(t, Version, f.Version) + assert.Equal(t, "octocorp.ghe.com", f.Dependencies["actions/checkout@v4"].Hostname) } func TestParseWithPolicy_RejectsVersionBelowMin(t *testing.T) { @@ -171,10 +209,16 @@ dependencies: } func TestParseWithPolicy_RejectsVersionAboveMax(t *testing.T) { - input := `version: v0.0.2 -dependencies: {} + input := `version: v0.0.3 +dependencies: + actions/checkout@v4: + hostname: octocorp.ghe.com + ref: v4 + commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 + owner_id: 1 + repo_id: 2 ` - policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.1"} + policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.2"} _, err := ParseWithPolicy([]byte(input), policy) require.Error(t, err) assert.True(t, errors.Is(err, ErrFutureVersion)) @@ -194,6 +238,7 @@ dependencies: f, err := ParseWithPolicy([]byte(input), policy) require.NoError(t, err) assert.Equal(t, Version, f.Version, "parsed file should be normalized to latest version") + assert.Equal(t, "github.com", f.Dependencies["actions/checkout@v4"].Hostname) assert.Equal(t, "v4", f.Dependencies["actions/checkout@v4"].Ref) } @@ -201,7 +246,7 @@ func TestParseWithPolicy_UnknownFutureVersion(t *testing.T) { input := `version: v1.0.0 dependencies: {} ` - policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.2"} + policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.3"} _, err := ParseWithPolicy([]byte(input), policy) require.Error(t, err) assert.True(t, errors.Is(err, ErrFutureVersion)) diff --git a/go/pkg/lockfile/internal/cmd/genschema/main.go b/go/pkg/lockfile/internal/cmd/genschema/main.go index d1feaa7..6875fcd 100644 --- a/go/pkg/lockfile/internal/cmd/genschema/main.go +++ b/go/pkg/lockfile/internal/cmd/genschema/main.go @@ -9,14 +9,9 @@ import ( ) func main() { - schemaV001, err := os.ReadFile("../../../schema/lockfile-v0.0.1.json") - if err != nil { - panic(err) - } - schemaV002, err := os.ReadFile("../../../schema/lockfile-v0.0.2.json") - if err != nil { - panic(err) - } + schemaV001 := readSchema("../../../schema/lockfile-v0.0.1.json") + schemaV002 := readSchema("../../../schema/lockfile-v0.0.2.json") + schemaV003 := readSchema("../../../schema/lockfile-v0.0.3.json") var out bytes.Buffer fmt.Fprintln(&out, "// Code generated by go generate; DO NOT EDIT.") @@ -24,7 +19,8 @@ func main() { fmt.Fprintln(&out, "package lockfile") fmt.Fprintln(&out) fmt.Fprintf(&out, "const schemaV001 = %s\n\n", strconv.Quote(string(schemaV001))) - fmt.Fprintf(&out, "const schemaV002 = %s\n", strconv.Quote(string(schemaV002))) + fmt.Fprintf(&out, "const schemaV002 = %s\n\n", strconv.Quote(string(schemaV002))) + fmt.Fprintf(&out, "const schemaV003 = %s\n", strconv.Quote(string(schemaV003))) formatted, err := format.Source(out.Bytes()) if err != nil { @@ -34,3 +30,11 @@ func main() { panic(err) } } + +func readSchema(path string) []byte { + schema, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return bytes.ReplaceAll(schema, []byte("\r\n"), []byte("\n")) +} diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index 7284159..87143ad 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -69,7 +69,7 @@ func newYAMLParseError(err error) *ParseError { } // Version is the latest lockfile schema version this binary writes. -const Version = "v0.0.2" +const Version = "v0.0.3" // Path is the canonical repo-relative location of the dependency lockfile. const Path = ".github/workflows/actions.lock" @@ -80,12 +80,13 @@ const CLIName = "gh actions-lock" // File is the parsed lockfile shape. // // # .github/workflows/actions.lock -// version: v0.0.1 +// version: v0.0.3 // workflows: // .github/workflows/deploy.yml: // - actions/checkout@v6 // dependencies: // actions/checkout@v4.3.1: +// hostname: github.com // ref: v4.3.1 // commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 // owner_id: 44036562 @@ -215,19 +216,22 @@ func (f File) LookupWorkflow(workflowKey string) ([]string, bool) { // Action carries the per-action metadata recorded under a pin key. // -// Ref is the git ref the commit was resolved from (required). Commit is the -// digest in algo-prefixed form (e.g. "sha1-abc123...", "sha256-def456..."), -// matching the digest in the pin key (required). OwnerID and RepoID are the -// GitHub numeric IDs for the owner and repository, used to detect a repository -// transfer (the name changes but the ID does not). Uses lists the action's -// direct nested dependencies as canonical pin keys — empty for leaf actions, -// populated for composite actions. +// Hostname is the optional bare canonical hostname of the GitHub instance that +// owns the dependency: github.com or a lowercase GHE tenant hostname such as +// octocorp.ghe.com. It is empty when omitted. Ref is the git ref the commit was +// resolved from (required). Commit is the digest in algo-prefixed form (e.g. +// "sha1-abc123...", "sha256-def456...") (required). OwnerID and RepoID are the +// host-specific numeric IDs for the owner and repository, used to detect a +// repository transfer (the name changes but the ID does not). Uses lists the +// action's direct nested dependencies as canonical pin keys — empty for leaf +// actions, populated for composite actions. type Action struct { - Ref string `yaml:"ref,omitempty"` - Commit string `yaml:"commit,omitempty"` - OwnerID int64 `yaml:"owner_id"` - RepoID int64 `yaml:"repo_id"` - Uses []string `yaml:"uses,omitempty"` + Hostname string `yaml:"hostname,omitempty"` + Ref string `yaml:"ref,omitempty"` + Commit string `yaml:"commit,omitempty"` + OwnerID int64 `yaml:"owner_id"` + RepoID int64 `yaml:"repo_id"` + Uses []string `yaml:"uses,omitempty"` } // MaxParseSize is the maximum number of bytes Parse accepts. Larger inputs are @@ -353,9 +357,10 @@ var allowedFileKeys = map[string]struct{}{ "dependencies": {}, } -// allowedActionKeys is the set of permitted keys within a v0.0.2 dependency's +// allowedActionKeys is the set of permitted keys within a v0.0.3 dependency's // Action mapping. var allowedActionKeys = map[string]struct{}{ + "hostname": {}, "ref": {}, "commit": {}, "owner_id": {}, @@ -363,14 +368,21 @@ var allowedActionKeys = map[string]struct{}{ "uses": {}, } -// requiredActionKeys lists the keys every v0.0.2 dependency's Action mapping +// requiredActionKeys lists the keys every v0.0.3 dependency's Action mapping // must carry, in report order. var requiredActionKeys = []string{"ref", "commit", "owner_id", "repo_id"} -// nonEmptyStringKeys lists action fields that must be non-empty strings. +// canonicalHostnamePattern matches github.com or a single lowercase DNS tenant +// label under ghe.com. +const canonicalHostnamePattern = `^(github\.com|[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.ghe\.com)$` + +var canonicalHostnameRE = regexp.MustCompile(canonicalHostnamePattern) + +// nonEmptyStringKeys lists action fields that must be non-empty when present. var nonEmptyStringKeys = map[string]struct{}{ - "ref": {}, - "commit": {}, + "hostname": {}, + "ref": {}, + "commit": {}, } // positiveIntKeys lists action fields that must be positive integers (> 0). @@ -379,15 +391,23 @@ var positiveIntKeys = map[string]struct{}{ "repo_id": {}, } -// rejectZeroValues checks that required action fields carry meaningful values: -// commit must be a valid algo-hex digest, ID fields must be positive, and -// nonEmptyStringKeys must not be blank. A present-but-zero value would silently -// disable the security check it enforces. +// rejectZeroValues checks that action fields carry meaningful values when +// present: commit must be a valid algo-hex digest, ID fields must be positive, +// and nonEmptyStringKeys must not be blank. A present-but-zero value would +// silently disable the security check it enforces. func rejectZeroValues(action *yaml.Node, dep string) *ParseError { for j := 0; j+1 < len(action.Content); j += 2 { key := action.Content[j] val := action.Content[j+1] + if key.Value == "hostname" && (val.Kind != yaml.ScalarNode || val.Tag != "!!str") { + return &ParseError{ + Line: val.Line, + Column: val.Column, + Msg: fmt.Sprintf("action field %q must be a string for dependency %q", key.Value, dep), + } + } + if _, ok := nonEmptyStringKeys[key.Value]; ok { if val.Value == "" { return &ParseError{ @@ -398,6 +418,14 @@ func rejectZeroValues(action *yaml.Node, dep string) *ParseError { } } + if key.Value == "hostname" && val.Value != "" && !canonicalHostnameRE.MatchString(val.Value) { + return &ParseError{ + Line: val.Line, + Column: val.Column, + Msg: fmt.Sprintf("action field %q must be \"github.com\" or a lowercase canonical GHE tenant hostname for dependency %q, got %q", key.Value, dep, val.Value), + } + } + if key.Value == "commit" && val.Value != "" { if !isValidAlgoHex(val.Value) { return &ParseError{ @@ -522,7 +550,7 @@ func canonicalizeActions(f *File) (string, error) { } func equalAction(a, b Action) bool { - if a.Ref != b.Ref || a.Commit != b.Commit || + if a.Hostname != b.Hostname || a.Ref != b.Ref || a.Commit != b.Commit || a.OwnerID != b.OwnerID || a.RepoID != b.RepoID { return false } diff --git a/go/pkg/lockfile/schema.go b/go/pkg/lockfile/schema.go index e36beba..b6d9c1a 100644 --- a/go/pkg/lockfile/schema.go +++ b/go/pkg/lockfile/schema.go @@ -3,10 +3,10 @@ package lockfile //go:generate go run ./internal/cmd/genschema // Schema returns the embedded JSON Schema document for the latest lockfile -// version (v0.0.2). Callers can surface it for editor integration or external +// version (v0.0.3). Callers can surface it for editor integration or external // validation. func Schema() string { - return schemaV002 + return schemaV003 } // SchemaForVersion returns the embedded JSON Schema for a specific lockfile @@ -17,6 +17,8 @@ func SchemaForVersion(version string) (string, bool) { return schemaV001, true case "v0.0.2": return schemaV002, true + case "v0.0.3": + return schemaV003, true default: return "", false } diff --git a/go/pkg/lockfile/schema_gen.go b/go/pkg/lockfile/schema_gen.go index 57e13e0..46268af 100644 --- a/go/pkg/lockfile/schema_gen.go +++ b/go/pkg/lockfile/schema_gen.go @@ -5,3 +5,5 @@ package lockfile const schemaV001 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://gh.io/actions-lockfile/v0.0.1.json\",\n \"title\": \"GitHub Actions dependency lockfile\",\n \"description\": \"Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-lock`.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"version\"],\n \"properties\": {\n \"version\": {\n \"description\": \"Lockfile schema version. Only v0.0.1 is supported.\",\n \"const\": \"v0.0.1\"\n },\n \"workflows\": {\n \"description\": \"Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.\",\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n },\n \"dependencies\": {\n \"description\": \"Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.\",\n \"type\": \"object\",\n \"additionalProperties\": { \"$ref\": \"#/$defs/action\" }\n }\n },\n \"$defs\": {\n \"pin\": {\n \"description\": \"Canonical dependency pin: OWNER/REPO@REF:ALGO-HEX (e.g. actions/checkout@v4:sha1-abc123...).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+:(sha1|sha256)-[a-f0-9]+$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"tag\": {\n \"description\": \"The tag the commit was resolved from, if any.\",\n \"type\": \"string\"\n },\n \"branch\": {\n \"description\": \"The branch the commit was resolved from, if no tag was available.\",\n \"type\": \"string\"\n },\n \"commit\": {\n \"description\": \"The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...).\",\n \"type\": \"string\"\n },\n \"owner_id\": {\n \"description\": \"The numeric ID of the action's owner (user or org).\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"repo_id\": {\n \"description\": \"The numeric ID of the action's repository.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"uses\": {\n \"description\": \"The action's own direct dependencies, as canonical pin keys.\",\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n }\n }\n }\n}\n" const schemaV002 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://gh.io/actions-lockfile/v0.0.2.json\",\n \"title\": \"GitHub Actions dependency lockfile\",\n \"description\": \"Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-lock`.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"version\"],\n \"properties\": {\n \"version\": {\n \"description\": \"Lockfile schema version. Only v0.0.2 is supported.\",\n \"const\": \"v0.0.2\"\n },\n \"workflows\": {\n \"description\": \"Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.\",\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n },\n \"dependencies\": {\n \"description\": \"Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.\",\n \"type\": \"object\",\n \"additionalProperties\": { \"$ref\": \"#/$defs/action\" }\n }\n },\n \"$defs\": {\n \"pin\": {\n \"description\": \"Canonical dependency pin: OWNER/REPO@REF (e.g. actions/checkout@v4).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"ref\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"ref\": {\n \"description\": \"The git ref the commit was resolved from. Required: every dep that passes impostor checks has a resolvable ref. The CLI picks the best ref with priority: full semver tag > any tag > branch (protected > default > release/v* > any). The parser enforces presence; priority ordering is the CLI's concern.\",\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"commit\": {\n \"description\": \"The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.\",\n \"type\": \"string\"\n },\n \"owner_id\": {\n \"description\": \"The numeric ID of the action's owner (user or org). Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"repo_id\": {\n \"description\": \"The numeric ID of the action's repository. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"uses\": {\n \"description\": \"The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.\",\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n }\n }\n }\n}\n" + +const schemaV003 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://gh.io/actions-lockfile/v0.0.3.json\",\n \"title\": \"GitHub Actions dependency lockfile\",\n \"description\": \"Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-lock`.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"version\"],\n \"properties\": {\n \"version\": {\n \"description\": \"Lockfile schema version. Only v0.0.3 is supported.\",\n \"const\": \"v0.0.3\"\n },\n \"workflows\": {\n \"description\": \"Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.\",\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n },\n \"dependencies\": {\n \"description\": \"Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.\",\n \"type\": \"object\",\n \"additionalProperties\": { \"$ref\": \"#/$defs/action\" }\n }\n },\n \"$defs\": {\n \"pin\": {\n \"description\": \"Canonical dependency pin: OWNER/REPO@REF (e.g. actions/checkout@v4).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"ref\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"hostname\": {\n \"description\": \"The optional bare canonical hostname of the GitHub instance that owns and resolves this dependency. The value must be github.com or a lowercase GHE tenant hostname such as octocorp.ghe.com; schemes, ports, paths, query strings, fragments, and surrounding whitespace are not allowed.\",\n \"type\": \"string\",\n \"minLength\": 1,\n \"pattern\": \"^(github\\\\.com|[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\\\.ghe\\\\.com)$\"\n },\n \"ref\": {\n \"description\": \"The git ref the commit was resolved from. Required: every dep that passes impostor checks has a resolvable ref. The CLI picks the best ref with priority: full semver tag > any tag > branch (protected > default > release/v* > any). The parser enforces presence; priority ordering is the CLI's concern.\",\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"commit\": {\n \"description\": \"The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.\",\n \"type\": \"string\"\n },\n \"owner_id\": {\n \"description\": \"The numeric ID of the action's owner (user or org) on the recorded hostname. Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"repo_id\": {\n \"description\": \"The numeric ID of the action's repository on the recorded hostname. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"uses\": {\n \"description\": \"The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.\",\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n }\n }\n }\n}\n" diff --git a/go/pkg/lockfile/schema_test.go b/go/pkg/lockfile/schema_test.go index 0b6573b..57f8feb 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -3,6 +3,7 @@ package lockfile import ( "encoding/json" "errors" + "fmt" "os" "testing" @@ -17,6 +18,7 @@ func TestSchema_EmbeddedMatchesRootInvariant(t *testing.T) { }{ {"v0.0.1", "../../../schema/lockfile-v0.0.1.json"}, {"v0.0.2", "../../../schema/lockfile-v0.0.2.json"}, + {"v0.0.3", "../../../schema/lockfile-v0.0.3.json"}, } { t.Run(ver.version, func(t *testing.T) { rootSchema, err := os.ReadFile(ver.file) @@ -71,6 +73,13 @@ func TestSchema_EmbeddedMatchesEnforcement(t *testing.T) { assert.ElementsMatch(t, doc.Defs.Action.Required, requiredActionKeys, "schema action.required must match the keys enforcement requires") + + var hostnameSchema struct { + Pattern string `json:"pattern"` + } + require.NoError(t, json.Unmarshal(doc.Defs.Action.Properties["hostname"], &hostnameSchema)) + assert.Equal(t, canonicalHostnamePattern, hostnameSchema.Pattern, + "schema hostname pattern must match parser enforcement") } func TestParse_UnknownTopLevelFieldRejected(t *testing.T) { @@ -127,6 +136,98 @@ dependencies: assert.Greater(t, pe.Column, 0, "expected a column anchored on the pin key") } +func TestParse_OmittedHostnameAccepted(t *testing.T) { + yaml := `version: v0.0.3 +dependencies: + actions/checkout@v4: + ref: v4 + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 +` + f, err := Parse([]byte(yaml)) + require.NoError(t, err) + assert.Empty(t, f.Dependencies["actions/checkout@v4"].Hostname) +} + +func v003WithHostname(hostname string) []byte { + return []byte(fmt.Sprintf(`version: v0.0.3 +dependencies: + actions/checkout@v4: + hostname: %q + ref: v4 + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 +`, hostname)) +} + +func TestParse_EmptyHostnameRejected(t *testing.T) { + _, err := Parse(v003WithHostname("")) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"hostname"`) + assert.Contains(t, pe.Msg, "must not be empty") +} + +func TestParse_NullHostnameRejected(t *testing.T) { + yaml := `version: v0.0.3 +dependencies: + actions/checkout@v4: + hostname: null + ref: v4 + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 +` + _, err := Parse([]byte(yaml)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"hostname"`) + assert.Contains(t, pe.Msg, "must be a string") +} + +func TestParse_CanonicalHostnamesAccepted(t *testing.T) { + for _, hostname := range []string{"github.com", "octocorp.ghe.com", "octo-corp1.ghe.com"} { + t.Run(hostname, func(t *testing.T) { + f, err := Parse(v003WithHostname(hostname)) + require.NoError(t, err) + assert.Equal(t, hostname, f.Dependencies["actions/checkout@v4"].Hostname) + }) + } +} + +func TestParse_NonCanonicalHostnameRejected(t *testing.T) { + for _, hostname := range []string{ + "example.com", + "GITHUB.COM", + "https://github.com", + "github.com:443", + "github.com/path", + "github.com?tenant=octocorp", + "github.com#fragment", + " github.com", + "github.com ", + "api.octocorp.ghe.com", + "-octocorp.ghe.com", + "octocorp-.ghe.com", + } { + t.Run(hostname, func(t *testing.T) { + _, err := Parse(v003WithHostname(hostname)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"hostname"`) + assert.Contains(t, pe.Msg, "canonical GHE tenant hostname") + }) + } +} + func TestParse_EmptyCommitRejected(t *testing.T) { yaml := `version: v0.0.2 dependencies: @@ -200,12 +301,13 @@ dependencies: } func TestParse_KnownFieldsAccepted(t *testing.T) { - yaml := `version: v0.0.2 + yaml := `version: v0.0.3 workflows: .github/workflows/ci.yml: - actions/checkout@v4 dependencies: actions/checkout@v4: + hostname: octocorp.ghe.com ref: v4 commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 owner_id: 1 @@ -217,6 +319,7 @@ dependencies: require.NoError(t, err) assert.Len(t, f.Dependencies, 1) assert.Contains(t, f.Workflows, ".github/workflows/ci.yml") + assert.Equal(t, "octocorp.ghe.com", f.Dependencies["actions/checkout@v4"].Hostname) } // corruptLockfile is a shared fixture for scoped-validation tests: goodPin is @@ -271,6 +374,73 @@ func TestParse_ScopedValidation_CorruptPathOnly_Errors(t *testing.T) { assert.Contains(t, pe.Msg, corruptPin) } +func TestParse_ScopedValidation_CanonicalPinStillValidatesHostname(t *testing.T) { + data := `version: v0.0.3 +workflows: + .github/workflows/a.yml: + - Actions/Checkout@v4 +dependencies: + actions/checkout@v4: + hostname: null + ref: v4 + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `action field "hostname" must be a string`) + assert.Contains(t, pe.Msg, "actions/checkout@v4") +} + +func TestParse_ScopedValidation_ValidatesTransitiveUses(t *testing.T) { + data := `version: v0.0.3 +workflows: + .github/workflows/a.yml: + - actions/composite@v1 +dependencies: + actions/composite@v1: + ref: v1 + commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + owner_id: 1 + repo_id: 2 + uses: + - actions/cache@v4 + actions/cache@v4: + hostname: null + ref: v4 + commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + owner_id: 3 + repo_id: 4 +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `action field "hostname" must be a string`) + assert.Contains(t, pe.Msg, "actions/cache@v4") +} + +func TestParse_ScopedValidation_NullDependencyRejected(t *testing.T) { + data := `version: v0.0.3 +workflows: + .github/workflows/a.yml: + - actions/checkout@v4 +dependencies: + actions/checkout@v4: null +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `action metadata for dependency "actions/checkout@v4" must be a mapping`) +} + func TestParse_ScopedValidation_AbsentPath_FailOpen(t *testing.T) { f, err := Parse([]byte(corruptLockfile), ".github/workflows/c.yml") require.NoError(t, err) diff --git a/schema/lockfile-v0.0.3.json b/schema/lockfile-v0.0.3.json new file mode 100644 index 0000000..a3158c4 --- /dev/null +++ b/schema/lockfile-v0.0.3.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gh.io/actions-lockfile/v0.0.3.json", + "title": "GitHub Actions dependency lockfile", + "description": "Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-lock`.", + "type": "object", + "additionalProperties": false, + "required": ["version"], + "properties": { + "version": { + "description": "Lockfile schema version. Only v0.0.3 is supported.", + "const": "v0.0.3" + }, + "workflows": { + "description": "Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/$defs/pin" } + } + }, + "dependencies": { + "description": "Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.", + "type": "object", + "additionalProperties": { "$ref": "#/$defs/action" } + } + }, + "$defs": { + "pin": { + "description": "Canonical dependency pin: OWNER/REPO@REF (e.g. actions/checkout@v4).", + "type": "string", + "pattern": "^[^/@:]+/[^/@:]+@[^:]+$" + }, + "action": { + "description": "Resolved metadata for a single pinned action.", + "type": "object", + "additionalProperties": false, + "required": ["ref", "commit", "owner_id", "repo_id"], + "properties": { + "hostname": { + "description": "The optional bare canonical hostname of the GitHub instance that owns and resolves this dependency. The value must be github.com or a lowercase GHE tenant hostname such as octocorp.ghe.com; schemes, ports, paths, query strings, fragments, and surrounding whitespace are not allowed.", + "type": "string", + "minLength": 1, + "pattern": "^(github\\.com|[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.ghe\\.com)$" + }, + "ref": { + "description": "The git ref the commit was resolved from. Required: every dep that passes impostor checks has a resolvable ref. The CLI picks the best ref with priority: full semver tag > any tag > branch (protected > default > release/v* > any). The parser enforces presence; priority ordering is the CLI's concern.", + "type": "string", + "minLength": 1 + }, + "commit": { + "description": "The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.", + "type": "string" + }, + "owner_id": { + "description": "The numeric ID of the action's owner (user or org) on the recorded hostname. Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.", + "type": "integer", + "minimum": 1 + }, + "repo_id": { + "description": "The numeric ID of the action's repository on the recorded hostname. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.", + "type": "integer", + "minimum": 1 + }, + "uses": { + "description": "The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.", + "type": "array", + "items": { "$ref": "#/$defs/pin" } + } + } + } + } +}