From 3c6d43e41d833361248e7f95b132e983a33cc269 Mon Sep 17 00:00:00 2001 From: Kaibo Cai Date: Fri, 4 Sep 2026 14:53:41 -0500 Subject: [PATCH 1/5] Add hostname-aware lockfile schema Introduce lockfile schema v0.0.3 with a required canonical hostname on every dependency. Preserve hostnames from new lockfiles and default v0.0.1/v0.0.2 dotcom-only entries to github.com in memory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 22 +++--- go/pkg/lockfile/bench_test.go | 15 ++-- go/pkg/lockfile/compat.go | 45 ++++++++++-- go/pkg/lockfile/compat_test.go | 57 +++++++++++++-- .../lockfile/internal/cmd/genschema/main.go | 22 +++--- go/pkg/lockfile/lockfile.go | 45 ++++++------ go/pkg/lockfile/schema.go | 6 +- go/pkg/lockfile/schema_gen.go | 2 + go/pkg/lockfile/schema_test.go | 43 ++++++++++- schema/lockfile-v0.0.3.json | 72 +++++++++++++++++++ 10 files changed, 270 insertions(+), 59 deletions(-) create mode 100644 schema/lockfile-v0.0.3.json diff --git a/README.md b/README.md index 0b52d3f..ba88024 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: @@ -104,6 +104,7 @@ workflows: dependencies: # pin key -> resolved action metadata actions/checkout@v6.0.2: + hostname: github.com ref: v6.0.2 commit: sha1-de0fac2e... owner_id: 44036562 @@ -114,18 +115,19 @@ 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 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..02c2ec9 100644 --- a/go/pkg/lockfile/bench_test.go +++ b/go/pkg/lockfile/bench_test.go @@ -2,7 +2,7 @@ package lockfile import "testing" -var benchV002 = []byte(`version: v0.0.2 +var benchV003 = []byte(`version: v0.0.3 workflows: .github/workflows/ci.yml: - actions/checkout@v4 @@ -10,16 +10,19 @@ workflows: - 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 @@ -54,9 +57,9 @@ dependencies: - actions/checkout@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683 `) -func BenchmarkParse_V002(b *testing.B) { +func BenchmarkParse_V003(b *testing.B) { for i := 0; i < b.N; i++ { - if _, err := Parse(benchV002); err != nil { + if _, err := Parse(benchV003); err != nil { b.Fatal(err) } } @@ -70,10 +73,10 @@ func BenchmarkParse_V001_Compat(b *testing.B) { } } -func BenchmarkParseWithPolicy_V002(b *testing.B) { - policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.2"} +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(benchV002, policy); err != nil { + 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..1fc2a01 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 ───────────────────────────────────────────────────── + +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 extends the v0.0.2 set with the legacy branch/tag fields. +// 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) diff --git a/go/pkg/lockfile/compat_test.go b/go/pkg/lockfile/compat_test.go index 2391a4c..75848db 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: github.example.test 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, "github.example.test", 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: github.example.test + 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..03aa7a4 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,21 @@ 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 canonical hostname of the GitHub instance that owns the +// dependency (required). 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 +356,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 +367,15 @@ 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"} +var requiredActionKeys = []string{"hostname", "ref", "commit", "owner_id", "repo_id"} // nonEmptyStringKeys lists action fields that must be non-empty strings. var nonEmptyStringKeys = map[string]struct{}{ - "ref": {}, - "commit": {}, + "hostname": {}, + "ref": {}, + "commit": {}, } // positiveIntKeys lists action fields that must be positive integers (> 0). @@ -522,7 +527,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..6ec046f 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\": [\"hostname\", \"ref\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"hostname\": {\n \"description\": \"The canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Producers record the exact canonical hostname; consumers use it to select the matching resolver.\",\n \"type\": \"string\",\n \"minLength\": 1\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..a03eb1d 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -17,6 +17,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) @@ -127,6 +128,44 @@ dependencies: assert.Greater(t, pe.Column, 0, "expected a column anchored on the pin key") } +func TestParse_MissingRequiredHostnameRejected(t *testing.T) { + yaml := `version: v0.0.3 +dependencies: + actions/checkout@v4: + 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, `missing required action field "hostname"`) + assert.Contains(t, pe.Msg, "actions/checkout@v4") + assert.Equal(t, 3, pe.Line) +} + +func TestParse_EmptyHostnameRejected(t *testing.T) { + yaml := `version: v0.0.3 +dependencies: + actions/checkout@v4: + hostname: "" + 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 not be empty") +} + func TestParse_EmptyCommitRejected(t *testing.T) { yaml := `version: v0.0.2 dependencies: @@ -200,12 +239,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: github.example.test ref: v4 commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 owner_id: 1 @@ -217,6 +257,7 @@ dependencies: require.NoError(t, err) assert.Len(t, f.Dependencies, 1) assert.Contains(t, f.Workflows, ".github/workflows/ci.yml") + assert.Equal(t, "github.example.test", f.Dependencies["actions/checkout@v4"].Hostname) } // corruptLockfile is a shared fixture for scoped-validation tests: goodPin is diff --git a/schema/lockfile-v0.0.3.json b/schema/lockfile-v0.0.3.json new file mode 100644 index 0000000..2919894 --- /dev/null +++ b/schema/lockfile-v0.0.3.json @@ -0,0 +1,72 @@ +{ + "$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": ["hostname", "ref", "commit", "owner_id", "repo_id"], + "properties": { + "hostname": { + "description": "The canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Producers record the exact canonical hostname; consumers use it to select the matching resolver.", + "type": "string", + "minLength": 1 + }, + "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" } + } + } + } + } +} From f0f4c79febdbeaa49b67333a98e3542e67d0825c Mon Sep 17 00:00:00 2001 From: Kaibo Cai Date: Fri, 4 Sep 2026 15:29:18 -0500 Subject: [PATCH 2/5] Canonicalize pins before scoped validation Ensure equivalent pin casing cannot skip required action-field checks during scoped parsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/pkg/lockfile/compat.go | 17 +++++++++++++++-- go/pkg/lockfile/schema_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/go/pkg/lockfile/compat.go b/go/pkg/lockfile/compat.go index 1fc2a01..d9fae27 100644 --- a/go/pkg/lockfile/compat.go +++ b/go/pkg/lockfile/compat.go @@ -464,7 +464,7 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars inScope = make(map[string]struct{}) for _, p := range paths { for _, pin := range f.Workflows[p] { - inScope[pin] = struct{}{} + inScope[canonicalPinForVersion(pin, version)] = struct{}{} } } } @@ -477,7 +477,7 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars } if inScope != nil { - if _, ok := inScope[pinKey.Value]; !ok { + if _, ok := inScope[canonicalPinForVersion(pinKey.Value, version)]; !ok { continue } } @@ -519,6 +519,19 @@ func validateKnownFieldsVersioned(f *File, paths []string, version string) *Pars return nil } +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/schema_test.go b/go/pkg/lockfile/schema_test.go index a03eb1d..4624f12 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -312,6 +312,27 @@ func TestParse_ScopedValidation_CorruptPathOnly_Errors(t *testing.T) { assert.Contains(t, pe.Msg, corruptPin) } +func TestParse_ScopedValidation_CanonicalPinStillRequiresHostname(t *testing.T) { + data := `version: v0.0.3 +workflows: + .github/workflows/a.yml: + - Actions/Checkout@v4 +dependencies: + actions/checkout@v4: + 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, `missing required action field "hostname"`) + assert.Contains(t, pe.Msg, "actions/checkout@v4") +} + func TestParse_ScopedValidation_AbsentPath_FailOpen(t *testing.T) { f, err := Parse([]byte(corruptLockfile), ".github/workflows/c.yml") require.NoError(t, err) From 17a4692621e350dcbb05f6e272d656c13cbc7952 Mon Sep 17 00:00:00 2001 From: Kaibo Cai Date: Fri, 4 Sep 2026 16:04:37 -0500 Subject: [PATCH 3/5] Validate transitive scoped dependencies Traverse each selected workflow's reachable uses graph before applying schema-specific dependency validation. Reject malformed dependency bodies and non-string hostnames while preserving scoped fail-open behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/pkg/lockfile/compat.go | 56 ++++++++++++++++++++++------- go/pkg/lockfile/lockfile.go | 8 +++++ go/pkg/lockfile/schema_test.go | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/go/pkg/lockfile/compat.go b/go/pkg/lockfile/compat.go index d9fae27..057ef18 100644 --- a/go/pkg/lockfile/compat.go +++ b/go/pkg/lockfile/compat.go @@ -459,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[canonicalPinForVersion(pin, version)] = 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[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 { @@ -519,6 +515,42 @@ 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 { diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index 03aa7a4..c418a83 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -393,6 +393,14 @@ func rejectZeroValues(action *yaml.Node, dep string) *ParseError { 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{ diff --git a/go/pkg/lockfile/schema_test.go b/go/pkg/lockfile/schema_test.go index 4624f12..b1a49dc 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -166,6 +166,25 @@ dependencies: 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_EmptyCommitRejected(t *testing.T) { yaml := `version: v0.0.2 dependencies: @@ -333,6 +352,51 @@ dependencies: 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: + hostname: github.example.test + ref: v1 + commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + owner_id: 1 + repo_id: 2 + uses: + - actions/cache@v4 + actions/cache@v4: + 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, `missing required action field "hostname"`) + 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) From e430fcb2be5b185df9143a019269c8ab718123ea Mon Sep 17 00:00:00 2001 From: Kaibo Cai Date: Fri, 4 Sep 2026 17:01:55 -0500 Subject: [PATCH 4/5] Make dependency hostname optional Allow dotcom-only v0.0.3 lockfiles to omit hostname while retaining strict validation when the field is present. Preserve legacy normalization and scoped graph validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2f6db8ef-20bf-421d-878b-6d044d3865ea --- README.md | 6 +++++- go/pkg/lockfile/lockfile.go | 18 +++++++++--------- go/pkg/lockfile/schema_gen.go | 2 +- go/pkg/lockfile/schema_test.go | 22 +++++++++------------- schema/lockfile-v0.0.3.json | 4 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ba88024..7ba8d79 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,6 @@ workflows: dependencies: # pin key -> resolved action metadata actions/checkout@v6.0.2: - hostname: github.com ref: v6.0.2 commit: sha1-de0fac2e... owner_id: 44036562 @@ -115,6 +114,11 @@ 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 `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 a non-empty string. + 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 diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index c418a83..36ba2e6 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -216,9 +216,9 @@ func (f File) LookupWorkflow(workflowKey string) ([]string, bool) { // Action carries the per-action metadata recorded under a pin key. // -// Hostname is the canonical hostname of the GitHub instance that owns the -// dependency (required). Ref is the git ref the commit was resolved from -// (required). Commit is the digest in algo-prefixed form (e.g. +// Hostname is the optional canonical hostname of the GitHub instance that owns +// the dependency; 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 @@ -369,9 +369,9 @@ var allowedActionKeys = map[string]struct{}{ // requiredActionKeys lists the keys every v0.0.3 dependency's Action mapping // must carry, in report order. -var requiredActionKeys = []string{"hostname", "ref", "commit", "owner_id", "repo_id"} +var requiredActionKeys = []string{"ref", "commit", "owner_id", "repo_id"} -// nonEmptyStringKeys lists action fields that must be non-empty strings. +// nonEmptyStringKeys lists action fields that must be non-empty when present. var nonEmptyStringKeys = map[string]struct{}{ "hostname": {}, "ref": {}, @@ -384,10 +384,10 @@ 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] diff --git a/go/pkg/lockfile/schema_gen.go b/go/pkg/lockfile/schema_gen.go index 6ec046f..a249094 100644 --- a/go/pkg/lockfile/schema_gen.go +++ b/go/pkg/lockfile/schema_gen.go @@ -6,4 +6,4 @@ const schemaV001 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/sc 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\": [\"hostname\", \"ref\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"hostname\": {\n \"description\": \"The canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Producers record the exact canonical hostname; consumers use it to select the matching resolver.\",\n \"type\": \"string\",\n \"minLength\": 1\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" +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 canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Hostname-aware producers record the exact canonical hostname for every dependency; when present, consumers use it to select the matching resolver.\",\n \"type\": \"string\",\n \"minLength\": 1\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 b1a49dc..20cbcdc 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -128,7 +128,7 @@ dependencies: assert.Greater(t, pe.Column, 0, "expected a column anchored on the pin key") } -func TestParse_MissingRequiredHostnameRejected(t *testing.T) { +func TestParse_OmittedHostnameAccepted(t *testing.T) { yaml := `version: v0.0.3 dependencies: actions/checkout@v4: @@ -137,14 +137,9 @@ dependencies: 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, `missing required action field "hostname"`) - assert.Contains(t, pe.Msg, "actions/checkout@v4") - assert.Equal(t, 3, pe.Line) + f, err := Parse([]byte(yaml)) + require.NoError(t, err) + assert.Empty(t, f.Dependencies["actions/checkout@v4"].Hostname) } func TestParse_EmptyHostnameRejected(t *testing.T) { @@ -331,13 +326,14 @@ func TestParse_ScopedValidation_CorruptPathOnly_Errors(t *testing.T) { assert.Contains(t, pe.Msg, corruptPin) } -func TestParse_ScopedValidation_CanonicalPinStillRequiresHostname(t *testing.T) { +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 @@ -348,7 +344,7 @@ dependencies: var pe *ParseError require.True(t, errors.As(err, &pe)) - assert.Contains(t, pe.Msg, `missing required action field "hostname"`) + assert.Contains(t, pe.Msg, `action field "hostname" must be a string`) assert.Contains(t, pe.Msg, "actions/checkout@v4") } @@ -359,7 +355,6 @@ workflows: - actions/composite@v1 dependencies: actions/composite@v1: - hostname: github.example.test ref: v1 commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa owner_id: 1 @@ -367,6 +362,7 @@ dependencies: uses: - actions/cache@v4 actions/cache@v4: + hostname: null ref: v4 commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb owner_id: 3 @@ -377,7 +373,7 @@ dependencies: var pe *ParseError require.True(t, errors.As(err, &pe)) - assert.Contains(t, pe.Msg, `missing required action field "hostname"`) + assert.Contains(t, pe.Msg, `action field "hostname" must be a string`) assert.Contains(t, pe.Msg, "actions/cache@v4") } diff --git a/schema/lockfile-v0.0.3.json b/schema/lockfile-v0.0.3.json index 2919894..5f39030 100644 --- a/schema/lockfile-v0.0.3.json +++ b/schema/lockfile-v0.0.3.json @@ -35,10 +35,10 @@ "description": "Resolved metadata for a single pinned action.", "type": "object", "additionalProperties": false, - "required": ["hostname", "ref", "commit", "owner_id", "repo_id"], + "required": ["ref", "commit", "owner_id", "repo_id"], "properties": { "hostname": { - "description": "The canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Producers record the exact canonical hostname; consumers use it to select the matching resolver.", + "description": "The optional canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Hostname-aware producers record the exact canonical hostname for every dependency; when present, consumers use it to select the matching resolver.", "type": "string", "minLength": 1 }, From 6f4822e699f4e22518dab2c4a3db624144e09ddc Mon Sep 17 00:00:00 2001 From: Kaibo Cai Date: Fri, 4 Sep 2026 19:21:50 -0500 Subject: [PATCH 5/5] Tighten hostname validation Accept only github.com or canonical GHE tenant hostnames when hostname is present. Restore the v0.0.2 benchmark baseline alongside v0.0.3 for compatibility comparisons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2f6db8ef-20bf-421d-878b-6d044d3865ea --- README.md | 3 +- go/pkg/lockfile/bench_test.go | 43 +++++++++++++++++++++++ go/pkg/lockfile/compat_test.go | 6 ++-- go/pkg/lockfile/lockfile.go | 19 +++++++++-- go/pkg/lockfile/schema_gen.go | 2 +- go/pkg/lockfile/schema_test.go | 62 ++++++++++++++++++++++++++++++---- schema/lockfile-v0.0.3.json | 5 +-- 7 files changed, 124 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7ba8d79..a58c4de 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ flat transitive lists) and `dependencies` (as deduplicated graph entries with 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 a non-empty string. +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` diff --git a/go/pkg/lockfile/bench_test.go b/go/pkg/lockfile/bench_test.go index 02c2ec9..b4a3140 100644 --- a/go/pkg/lockfile/bench_test.go +++ b/go/pkg/lockfile/bench_test.go @@ -2,6 +2,32 @@ package lockfile import "testing" +var benchV002 = []byte(`version: v0.0.2 +workflows: + .github/workflows/ci.yml: + - actions/checkout@v4 + - actions/setup-go@v5 + - actions/cache@v4 +dependencies: + actions/checkout@v4: + ref: v4 + commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 + owner_id: 44036562 + repo_id: 197814629 + actions/setup-go@v5: + ref: v5 + commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + owner_id: 44036562 + repo_id: 249058325 + actions/cache@v4: + ref: v4 + commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + owner_id: 44036562 + repo_id: 251882839 + uses: + - actions/checkout@v4 +`) + var benchV003 = []byte(`version: v0.0.3 workflows: .github/workflows/ci.yml: @@ -65,6 +91,14 @@ func BenchmarkParse_V003(b *testing.B) { } } +func BenchmarkParse_V002(b *testing.B) { + for i := 0; i < b.N; i++ { + if _, err := Parse(benchV002); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkParse_V001_Compat(b *testing.B) { for i := 0; i < b.N; i++ { if _, err := Parse(benchV001); err != nil { @@ -73,6 +107,15 @@ func BenchmarkParse_V001_Compat(b *testing.B) { } } +func BenchmarkParseWithPolicy_V002(b *testing.B) { + policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.2"} + for i := 0; i < b.N; i++ { + if _, err := ParseWithPolicy(benchV002, policy); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkParseWithPolicy_V003(b *testing.B) { policy := VersionPolicy{Min: "v0.0.1", Max: "v0.0.3"} for i := 0; i < b.N; i++ { diff --git a/go/pkg/lockfile/compat_test.go b/go/pkg/lockfile/compat_test.go index 75848db..6281ad0 100644 --- a/go/pkg/lockfile/compat_test.go +++ b/go/pkg/lockfile/compat_test.go @@ -179,7 +179,7 @@ func TestParseWithPolicy_AcceptsVersionInRange(t *testing.T) { input := `version: v0.0.3 dependencies: actions/checkout@v4: - hostname: github.example.test + hostname: octocorp.ghe.com ref: v4 commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 owner_id: 1 @@ -189,7 +189,7 @@ dependencies: f, err := ParseWithPolicy([]byte(input), policy) require.NoError(t, err) assert.Equal(t, Version, f.Version) - assert.Equal(t, "github.example.test", f.Dependencies["actions/checkout@v4"].Hostname) + assert.Equal(t, "octocorp.ghe.com", f.Dependencies["actions/checkout@v4"].Hostname) } func TestParseWithPolicy_RejectsVersionBelowMin(t *testing.T) { @@ -212,7 +212,7 @@ func TestParseWithPolicy_RejectsVersionAboveMax(t *testing.T) { input := `version: v0.0.3 dependencies: actions/checkout@v4: - hostname: github.example.test + hostname: octocorp.ghe.com ref: v4 commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683 owner_id: 1 diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index 36ba2e6..87143ad 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -216,8 +216,9 @@ func (f File) LookupWorkflow(workflowKey string) ([]string, bool) { // Action carries the per-action metadata recorded under a pin key. // -// Hostname is the optional canonical hostname of the GitHub instance that owns -// the dependency; it is empty when omitted. Ref is the git ref the commit was +// 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 @@ -371,6 +372,12 @@ var allowedActionKeys = map[string]struct{}{ // must carry, in report order. var requiredActionKeys = []string{"ref", "commit", "owner_id", "repo_id"} +// 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{}{ "hostname": {}, @@ -411,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{ diff --git a/go/pkg/lockfile/schema_gen.go b/go/pkg/lockfile/schema_gen.go index a249094..46268af 100644 --- a/go/pkg/lockfile/schema_gen.go +++ b/go/pkg/lockfile/schema_gen.go @@ -6,4 +6,4 @@ const schemaV001 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/sc 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 canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Hostname-aware producers record the exact canonical hostname for every dependency; when present, consumers use it to select the matching resolver.\",\n \"type\": \"string\",\n \"minLength\": 1\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" +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 20cbcdc..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" @@ -72,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) { @@ -142,17 +150,20 @@ dependencies: assert.Empty(t, f.Dependencies["actions/checkout@v4"].Hostname) } -func TestParse_EmptyHostnameRejected(t *testing.T) { - yaml := `version: v0.0.3 +func v003WithHostname(hostname string) []byte { + return []byte(fmt.Sprintf(`version: v0.0.3 dependencies: actions/checkout@v4: - hostname: "" + hostname: %q ref: v4 commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 owner_id: 1 repo_id: 2 -` - _, err := Parse([]byte(yaml)) +`, hostname)) +} + +func TestParse_EmptyHostnameRejected(t *testing.T) { + _, err := Parse(v003WithHostname("")) require.Error(t, err) var pe *ParseError @@ -180,6 +191,43 @@ dependencies: 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: @@ -259,7 +307,7 @@ workflows: - actions/checkout@v4 dependencies: actions/checkout@v4: - hostname: github.example.test + hostname: octocorp.ghe.com ref: v4 commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 owner_id: 1 @@ -271,7 +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, "github.example.test", f.Dependencies["actions/checkout@v4"].Hostname) + assert.Equal(t, "octocorp.ghe.com", f.Dependencies["actions/checkout@v4"].Hostname) } // corruptLockfile is a shared fixture for scoped-validation tests: goodPin is diff --git a/schema/lockfile-v0.0.3.json b/schema/lockfile-v0.0.3.json index 5f39030..a3158c4 100644 --- a/schema/lockfile-v0.0.3.json +++ b/schema/lockfile-v0.0.3.json @@ -38,9 +38,10 @@ "required": ["ref", "commit", "owner_id", "repo_id"], "properties": { "hostname": { - "description": "The optional canonical hostname of the GitHub instance that owns and resolves this dependency (for example, github.com). Hostname-aware producers record the exact canonical hostname for every dependency; when present, consumers use it to select the matching resolver.", + "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 + "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.",