Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions go/pkg/lockfile/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ dependencies:
- actions/checkout@v4
`)

var benchV003 = []byte(`version: v0.0.3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep benchV002 and its benchmark as the compatibility baseline, then add benchV003 as a distinct entry. Replacing v0.0.2 drops coverage and prevents comparing parse cost across schema versions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I restored benchV002, BenchmarkParse_V002, and BenchmarkParseWithPolicy_V002, and kept the v0.0.3 cases separate. That preserves the compatibility baseline and lets us compare parse cost across versions.

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:
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
116 changes: 98 additions & 18 deletions go/pkg/lockfile/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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": {},
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Comment thread
kaibocai marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading