diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4e7b9f..354e2d1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
+ cache-dependency-path: go/go.sum
- name: Generate
run: make generate
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..80d7d1e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,47 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: Version bump for the Go module (go/vX.Y.Z tag)
+ type: choice
+ required: true
+ default: patch
+ options:
+ - patch
+ - minor
+ - major
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Cut go/ release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Guard release branch
+ if: github.ref != 'refs/heads/main'
+ run: |
+ echo "::error::Releases must be cut from main (got $GITHUB_REF)."
+ exit 1
+
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.19'
+ cache-dependency-path: go/go.sum
+
+ - name: Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: script/release "${{ inputs.bump }}"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b6a58a9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 GitHub Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
index cf50c01..eed9c2b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,185 +1,10 @@
-BIN := gh-actions-lockfile
-RUNNER_ROOT ?= $(HOME)/ghq/github.com/actions/runner
-RUNNER_BIN := $(RUNNER_ROOT)/_layout/bin
-RUNNER_DIAG := $(RUNNER_ROOT)/_layout/_diag
+.PHONY: generate test lint
-.PHONY: build test test-unit lint clean install help
-.PHONY: serve run-runner run-runner-lockfile run-runner-tampered build-runner runner-logs demo
+generate:
+ cd go/pkg/lockfile && go generate ./...
-## Build the binary
-build:
- go build -o $(BIN) ./cmd/gh-actions-lockfile
-
-## Run all tests
test:
- go test ./... -count=1
-
-## Run unit tests only (no network)
-test-unit:
- go test ./pkg/lockfile/... ./pkg/actionmeta/... -count=1
+ cd go && go test ./... -count=1
-## Lint
lint:
- go vet ./...
-
-## Install to GOPATH/bin
-install:
- go install ./cmd/gh-actions-lockfile
-
-## Clean build artifacts
-clean:
- rm -f $(BIN)
-
-## Pin a workflow file
-pin: build
- @test -n "$(FILE)" || (echo "usage: make pin FILE=path/to/workflow.yml" && exit 1)
- ./$(BIN) pin $(FILE)
-
-## Validate a workflow file
-validate: build
- @test -n "$(FILE)" || (echo "usage: make validate FILE=path/to/workflow.yml" && exit 1)
- ./$(BIN) validate $(FILE)
-
-## Pin all real-world corpus workflows (dry-run)
-corpus: build
- @for f in testdata/real-world/*.yml; do \
- name=$$(basename "$$f"); \
- printf "\033[1m%s\033[0m\n" "$$name"; \
- ./$(BIN) pin --dry-run "$$f" 2>&1 | sed 's/^/ /'; \
- echo ""; \
- done
-
-# ---------------------------------------------------------------------------
-# Dev: runner integration (fake launch + harness)
-# ---------------------------------------------------------------------------
-
-# Helper: run the harness, capture the runner log, print enforcement output.
-# Args passed via env: _LOCKFILE_DEPS, _USES
-define run_harness
- @GITHUB_TOKEN=$$(gh auth token) LAUNCH_ENDPOINT=http://localhost:9399 \
- LOCKFILE_DEPS='$(_LOCKFILE_DEPS)' \
- dotnet run --project dev/harness-dotnet -- \
- "$(RUNNER_BIN)" \
- "$(_USES)" 2>&1; \
- rc=$$?; \
- LOGFILE=$$(ls -t $(RUNNER_DIAG)/Worker_*.log 2>/dev/null | head -1); \
- echo ""; \
- if [ -n "$$LOGFILE" ]; then \
- if grep -q "LOCKFILE VIOLATION" "$$LOGFILE"; then \
- grep "LOCKFILE VIOLATION" "$$LOGFILE" | head -1 | sed 's/.*LOCKFILE/ \x1b[31mLOCKFILE/' | sed 's/$$/\x1b[0m/'; \
- elif grep -q "verified" "$$LOGFILE"; then \
- grep "verified" "$$LOGFILE" | sed 's/.*Enqueue web console line queue: / \x1b[32m/' | sed 's/$$/\x1b[0m/'; \
- fi; \
- download=$$(grep "Download action" "$$LOGFILE" | head -1); \
- if [ -n "$$download" ]; then \
- echo "$$download" | sed 's/.*Enqueue web console line queue: / \x1b[36m/' | sed 's/$$/\x1b[0m/'; \
- fi; \
- fi; \
- echo ""; \
- if [ $$rc -eq 100 ] || [ $$rc -eq 0 ]; then \
- printf ' \033[32m✓ runner exit %d (job completed)\033[0m\n' $$rc; \
- elif [ $$rc -eq 102 ]; then \
- if [ -n "$$LOGFILE" ] && grep -q "LOCKFILE VIOLATION" "$$LOGFILE"; then \
- printf ' \033[31m✗ runner exit 102 (lockfile violation)\033[0m\n'; \
- else \
- printf ' \033[33m✗ runner exit 102 (step execution failed -- not a lockfile issue)\033[0m\n'; \
- fi; \
- else \
- printf ' \033[33m? runner exit %d\033[0m\n' $$rc; \
- fi
-endef
-
-## Start fake Launch server (default port 9399)
-serve: build
- ./$(BIN) serve --port 9399
-
-## Run real runner against fake Launch (usage: make run-runner USES="owner/repo@ref")
-run-runner: build
- @test -n "$(USES)" || (echo "usage: make run-runner USES='nodeselector/actions-test-fixtures/simple-node@main'" && exit 1)
- $(eval _USES := $(USES))
- $(eval _LOCKFILE_DEPS := )
- $(run_harness)
-
-## Run runner with correct lockfile deps (should pass)
-run-runner-lockfile: build
- @printf '\033[1mCorrect lockfile deps:\033[0m\n'
- $(eval _USES := nodeselector/actions-test-fixtures/simple-node@main)
- $(eval _LOCKFILE_DEPS := ["github.com/nodeselector/actions-test-fixtures@main:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d"])
- $(run_harness)
-
-## Run runner with tampered lockfile deps (should fail)
-run-runner-tampered: build
- @printf '\033[1mTampered lockfile deps:\033[0m\n'
- $(eval _USES := nodeselector/actions-test-fixtures/simple-node@main)
- $(eval _LOCKFILE_DEPS := ["github.com/nodeselector/actions-test-fixtures@main:sha1-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"])
- $(run_harness)
-
-## Run composite action with correct transitive deps (should pass both rounds)
-run-runner-composite: build
- @printf '\033[1mComposite action -- correct transitive deps:\033[0m\n'
- $(eval _USES := nodeselector/actions-test-fixtures/simple-composite@main)
- $(eval _LOCKFILE_DEPS := ["github.com/nodeselector/actions-test-fixtures@main:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d","github.com/nodeselector/actions-test-fixtures-b@main:sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637"])
- $(run_harness)
-
-## Run composite action with tampered transitive dep (should fail on second round)
-run-runner-composite-tampered: build
- @printf '\033[1mComposite action -- tampered transitive dep (fixtures-b): \033[0m\n'
- $(eval _USES := nodeselector/actions-test-fixtures/simple-composite@main)
- $(eval _LOCKFILE_DEPS := ["github.com/nodeselector/actions-test-fixtures@main:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d","github.com/nodeselector/actions-test-fixtures-b@main:sha1-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"])
- $(run_harness)
-
-## Full enforcement demo: serve must be running, runs all five cases
-demo: build
- @printf '\n\033[1;4m=== Test 1: Correct lockfile ===\033[0m\n'
- @$(MAKE) --no-print-directory run-runner-lockfile
- @printf '\n\033[1;4m=== Test 2: Tampered lockfile ===\033[0m\n'
- @$(MAKE) --no-print-directory run-runner-tampered
- @printf '\n\033[1;4m=== Test 3: No lockfile (backward compat) ===\033[0m\n'
- @$(MAKE) --no-print-directory run-runner USES="nodeselector/actions-test-fixtures/simple-node@main"
- @printf '\n\033[1;4m=== Test 4: Composite -- correct transitive deps ===\033[0m\n'
- @$(MAKE) --no-print-directory run-runner-composite
- @printf '\n\033[1;4m=== Test 5: Composite -- tampered transitive dep ===\033[0m\n'
- @$(MAKE) --no-print-directory run-runner-composite-tampered
-
-## Build the runner from source (at RUNNER_ROOT)
-build-runner:
- cd $(RUNNER_ROOT)/src && ./dev.sh build 2>&1 | tail -3
- @if [ ! -f $(RUNNER_ROOT)/_layout/externals/node20/bin/node ]; then \
- cd $(RUNNER_ROOT)/src/Misc && bash externals.sh "osx-arm64" 2>&1 | tail -1; \
- fi
- @echo '{"agentId":1,"agentName":"lockfile-test","poolId":1,"poolName":"Default","serverUrl":"http://localhost:9399","workFolder":"/tmp/actions-work","ephemeral":true}' > $(RUNNER_ROOT)/_layout/.runner
- @echo '{"scheme":"OAuth","data":{"clientId":"fake","authorizationUrl":"http://localhost:9399"}}' > $(RUNNER_ROOT)/_layout/.credentials
- @mkdir -p $(RUNNER_DIAG)
- @echo "Runner built: $(RUNNER_BIN)/Runner.Worker"
-
-## Tail the latest runner worker log
-runner-logs:
- @LOGFILE=$$(ls -t $(RUNNER_DIAG)/Worker_*.log 2>/dev/null | head -1); \
- if [ -z "$$LOGFILE" ]; then \
- echo "No runner logs found. Run a runner target first."; \
- exit 1; \
- fi; \
- echo "Tailing: $$LOGFILE"; \
- tail -f "$$LOGFILE" | grep --line-buffered -iE 'lockfile|violation|action|resolve|download|composite|error|warn'
-
-## Show help
-help:
- @echo "gh-actions-lockfile targets:"
- @echo ""
- @echo " CLI:"
- @echo " make build Build the binary"
- @echo " make test Run all tests"
- @echo " make pin FILE=x Pin a workflow"
- @echo " make validate FILE=x"
- @echo " make corpus Dry-run pin against real-world workflows"
- @echo ""
- @echo " Runner integration (start 'make serve' first):"
- @echo " make serve Start fake Launch on :9399"
- @echo " make demo Run all three enforcement cases"
- @echo " make run-runner-lockfile Correct lockfile (should pass)"
- @echo " make run-runner-tampered Tampered lockfile (should fail)"
- @echo " make run-runner USES=x Run with custom action ref"
- @echo " make build-runner Build runner from source"
- @echo " make runner-logs Tail latest runner log"
- @echo ""
- @echo " RUNNER_ROOT=$(RUNNER_ROOT)"
+ cd go && go vet ./...
diff --git a/README.md b/README.md
index d972208..6446d63 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,145 @@
-Go here https://github.com/github/gh-actions-lockfile
+# actions-lockfile
+
+The authoritative definition of the GitHub Actions dependency lockfile
+format, plus a Go parser for it. The lockfile records the resolved transitive
+dependency graph for a repository's workflows so tools can audit and verify the
+exact action pins in use.
+
+## Installation
+
+```sh
+go get github.com/github/actions-lockfile/go/pkg/lockfile
+```
+
+The Go module lives under [`go/`](./go/) so the repository can grow
+additional language bindings around the same lockfile schema.
+
+## Usage
+
+### Parse a lockfile and look up a workflow's pins
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ lockfile "github.com/github/actions-lockfile/go/pkg/lockfile"
+)
+
+func main() {
+ contents, err := os.ReadFile(lockfile.Path) // ".github/workflows/actions.lock"
+ if err != nil {
+ panic(err)
+ }
+
+ file, err := lockfile.Parse(contents)
+ if err != nil {
+ panic(err)
+ }
+
+ pins, ok := file.LookupWorkflow(".github/workflows/release.yml")
+ if !ok {
+ fmt.Println("workflow not present in lockfile")
+ return
+ }
+ for _, key := range pins {
+ fmt.Println(key) // e.g. actions/checkout@v6.0.2:sha1-de0fac2e...
+ }
+}
+```
+
+### Surface structured parse errors
+
+`Parse` returns a `*lockfile.ParseError` carrying line and column for semantic
+failures, so callers can anchor diagnostics on the lockfile itself instead of
+scraping yaml.v3's error string.
+
+```go
+file, err := lockfile.Parse(contents)
+if err != nil {
+ var perr *lockfile.ParseError
+ if errors.As(err, &perr) {
+ fmt.Printf("%s:%d:%d: %s\n", lockfile.Path, perr.Line, perr.Column, perr.Msg)
+ return
+ }
+ panic(err)
+}
+_ = file
+```
+
+## Schema
+
+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.1`
+([`schema/lockfile-v0.0.1.json`](https://github.com/github/actions-lockfile/blob/main/schema/lockfile-v0.0.1.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.1
+workflows:
+ # workflow path → flat, transitive list of canonical pin keys
+ .github/workflows/release.yml:
+ - actions/checkout@v6.0.2:sha1-de0fac2e...
+dependencies:
+ # canonical pin key → resolved action metadata
+ actions/checkout@v6.0.2:sha1-de0fac2e...:
+ tag: v6.0.2
+ branch: main
+ commit: sha1-de0fac2e...
+ owner_id: 44036562
+ repo_id: 197814629
+```
+
+A canonical pin key is `OWNER/REPO@REF:ALGO-HEX`. The same key appears in both
+`workflows` (as flat transitive lists) and `dependencies` (as deduplicated
+graph entries with `uses:` links to direct dependencies).
+
+## 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.1`, embedded in the package and emitted as the `version` field of
+ every lockfile.
+- 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
+ define the intended stable surface.
+- Schema changes follow the rules in `RELEASING.md`: backward-compatible
+ additions can ship in a minor schema version; breaking changes require a
+ new schema `$id` and bumped `version` const.
+
+## Related projects
+
+- [`github/gh-actions-pin`](https://github.com/github/gh-actions-pin) —
+ produces and maintains lockfiles.
+- [`actions/languageservices/workflow-parser`](https://github.com/actions/languageservices/tree/main/workflow-parser) —
+ parses workflow YAML. Sibling library: `workflow-parser` reads the `.yml`
+ source, `actions-lockfile` reads the resolved `.lock` artifact derived
+ from it.
+
+This package is format infrastructure. It does not resolve actions, update
+pins, or assess vulnerability risk. Tools that do those things consume this
+package to read the lockfile.
+
+## Development
+
+```sh
+make test
+make lint
+```
+
+Schema changes (any modification to `schema/lockfile-vX.Y.Z.json`, generated
+language bindings, or the fields emitted in `dependencies` / `workflows`)
+require coordination with `github/gh-actions-pin` because the CLI is the
+schema's primary producer.
+
+## License
+
+MIT — see [`LICENSE`](./LICENSE).
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..29755c1
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,66 @@
+# Releasing `actions-lockfile`
+
+`github.com/github/actions-lockfile` is the multi-language home for the
+GitHub Actions lockfile format. The Go implementation lives in the
+`github.com/github/actions-lockfile/go` module under `go/`, kept as a
+standalone module so consumers can import it on its own.
+
+## Cutting a release
+
+Releases are cut by CI, not from a maintainer's laptop. Open the **Actions**
+tab, run the **Release** workflow from `main`, and choose a bump:
+
+- **patch** — backward-compatible fixes
+- **minor** — backward-compatible additions
+- **major** — breaking changes (see the version-suffix caveat below)
+
+CI runs `script/release`, which regenerates and verifies the tree, runs the
+full build, computes the next version from the latest `go/vX.Y.Z` tag, pushes
+the tag, cuts a GitHub Release with generated notes, and warms the Go module
+proxy. The first release has no prior tag, so it bases off `v0.0.0` — pick
+**minor** to land on `v0.1.0`.
+
+`script/release` is the single source of truth and runs locally too. Preview
+without touching anything:
+
+```sh
+RELEASE_DRY_RUN=1 script/release minor
+```
+
+## Tag conventions
+
+The Go sub-module uses path-prefixed semver tags, per Go's multi-module
+repository rules:
+
+```
+go/v0.1.0
+go/v0.1.1
+go/v1.0.0
+```
+
+A consumer resolves `go/vX.Y.Z` as module version `vX.Y.Z`:
+
+```sh
+go get github.com/github/actions-lockfile/go@v0.1.0
+```
+
+Major versions `>= 2` need a matching `/vN` suffix on the module path; the
+release script refuses to mint such a tag until `go.mod` carries the suffix.
+
+## Format invariants
+
+Shared lockfile invariants live outside language implementations:
+
+- `schema/lockfile-v0.0.1.json` is the published schema.
+- `go/pkg/lockfile/schema_gen.go` is generated from the root schema for Go
+ consumers. Run `make generate` after schema changes; Go tests enforce that
+ the generated value still matches the root schema.
+
+## Local development
+
+Run the module tests directly:
+
+```sh
+make generate
+make test
+```
diff --git a/dev/fakelaunch/server.go b/dev/fakelaunch/server.go
deleted file mode 100644
index b80b936..0000000
--- a/dev/fakelaunch/server.go
+++ /dev/null
@@ -1,283 +0,0 @@
-// Package server implements a fake Launch receiver that speaks the same
-// protocol as the real runner expects. Resolves actions via GitHub GraphQL API.
-package fakelaunch
-
-import (
- "encoding/json"
- "fmt"
- "log"
- "net/http"
- "strings"
-
- "github.com/github/actions-lockfile/pkg/resolver"
- "github.com/github/actions-lockfile/pkg/lockfile"
-)
-
-// ActionReferenceRequest matches the runner's ActionReferenceRequest shape.
-type ActionReferenceRequest struct {
- Action string `json:"action"`
- Version string `json:"version"`
- Path string `json:"path"`
-}
-
-// ActionReferenceRequestList matches ActionReferenceRequestList.
-type ActionReferenceRequestList struct {
- Actions []ActionReferenceRequest `json:"actions"`
-}
-
-// ActionDownloadInfoResponse matches the runner's expected response shape.
-type ActionDownloadInfoResponse struct {
- Name string `json:"name"`
- ResolvedName string `json:"resolved_name"`
- ResolvedSha string `json:"resolved_sha"`
- TarURL string `json:"tar_url"`
- ZipURL string `json:"zip_url"`
- Version string `json:"version"`
- Auth *ActionDownloadAuthenticationResponse `json:"authentication,omitempty"`
-}
-
-type ActionDownloadAuthenticationResponse struct {
- Token string `json:"token"`
- ExpiresAt string `json:"expires_at"`
-}
-
-// ActionDownloadInfoResponseCollection is the top-level response.
-type ActionDownloadInfoResponseCollection struct {
- Actions map[string]ActionDownloadInfoResponse `json:"actions"`
-}
-
-// Server is the fake Launch receiver.
-type Server struct {
- token string
- resolver *resolver.Client
- port int
-}
-
-// New creates a fake Launch server.
-func New(token string, port int) *Server {
- return &Server{
- token: token,
- resolver: resolver.New(token),
- port: port,
- }
-}
-
-// Run starts the server.
-func (s *Server) Run() error {
- mux := http.NewServeMux()
-
- // The runner POSTs to /actions/build/{planId}/jobs/{jobId}/runnerresolve/actions
- mux.HandleFunc("/", s.handleDefault)
-
- addr := fmt.Sprintf(":%d", s.port)
- log.Printf("Fake Launch server listening on %s", addr)
- log.Printf("Set system.github.launch_endpoint=http://localhost%s", addr)
- return http.ListenAndServe(addr, mux)
-}
-
-func (s *Server) handleDefault(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
-
- if strings.HasSuffix(path, "/_apis/connectionData") {
- s.handleConnectionData(w, r)
- return
- }
-
- if strings.HasSuffix(path, "/completejob") {
- s.handleCompleteJob(w, r)
- return
- }
-
- if strings.Contains(path, "/_apis/distributedtask") ||
- strings.Contains(path, "/_apis/pipelines") {
- s.handleStub(w, r)
- return
- }
-
- if strings.HasSuffix(path, "/runnerresolve/actions") ||
- strings.HasSuffix(path, "/resolve/actions") {
- s.handleResolveActions(w, r)
- return
- }
-
- log.Printf("[UNHANDLED] %s %s", r.Method, path)
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
- w.Write([]byte(`{}`))
-}
-
-func (s *Server) handleConnectionData(w http.ResponseWriter, r *http.Request) {
- log.Printf("[STUB] %s %s (connection data)", r.Method, r.URL.Path)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "locationServiceData": map[string]interface{}{
- "serviceDefinitions": []interface{}{},
- },
- "instanceId": "00000000-0000-0000-0000-000000000000",
- "deploymentId": "00000000-0000-0000-0000-000000000000",
- "authenticatedUser": map[string]interface{}{
- "id": "00000000-0000-0000-0000-000000000000",
- "descriptor": "System:00000000-0000-0000-0000-000000000000",
- "providerDisplayName": "test",
- },
- "authorizedUser": map[string]interface{}{
- "id": "00000000-0000-0000-0000-000000000000",
- "descriptor": "System:00000000-0000-0000-0000-000000000000",
- "providerDisplayName": "test",
- },
- })
-}
-
-func (s *Server) handleStub(w http.ResponseWriter, r *http.Request) {
- log.Printf("[STUB] %s %s", r.Method, r.URL.Path)
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
- w.Write([]byte(`{}`))
-}
-
-func (s *Server) handleCompleteJob(w http.ResponseWriter, r *http.Request) {
- var body map[string]interface{}
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- log.Printf("[COMPLETE] failed to parse: %v", err)
- w.WriteHeader(200)
- return
- }
-
- names := []string{"Succeeded", "SucceededWithIssues", "Failed", "Cancelled", "Skipped", "Abandoned"}
-
- resolveName := func(v interface{}) string {
- switch val := v.(type) {
- case float64:
- if int(val) >= 0 && int(val) < len(names) {
- return names[int(val)]
- }
- case string:
- return val
- }
- return fmt.Sprintf("%v", v)
- }
-
- conclusion := resolveName(body["conclusion"])
-
- var stepResults []interface{}
- if sr, ok := body["stepResults"].([]interface{}); ok {
- stepResults = sr
- }
-
- color := "\033[31m" // red
- if strings.EqualFold(conclusion, "succeeded") || strings.EqualFold(conclusion, "succeededWithIssues") {
- color = "\033[32m" // green
- }
- log.Printf("%s[COMPLETE] Job %s (%d steps)\033[0m", color, conclusion, len(stepResults))
-
- for i, sr := range stepResults {
- step, ok := sr.(map[string]interface{})
- if !ok {
- continue
- }
- name := step["name"]
- result := resolveName(step["conclusion"])
- icon := "\033[31m\u2717" // red X
- if strings.EqualFold(result, "succeeded") || strings.EqualFold(result, "succeededWithIssues") {
- icon = "\033[32m\u2713" // green check
- }
- log.Printf(" %s Step %d: %v -- %s\033[0m", icon, i+1, name, result)
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
- w.Write([]byte(`{}`))
-}
-
-func (s *Server) handleResolveActions(w http.ResponseWriter, r *http.Request) {
- // Accept any path that ends with /runnerresolve/actions or /resolve/actions
- if !strings.HasSuffix(r.URL.Path, "/runnerresolve/actions") &&
- !strings.HasSuffix(r.URL.Path, "/resolve/actions") {
- http.NotFound(w, r)
- return
- }
-
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- var req ActionReferenceRequestList
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, fmt.Sprintf("bad request: %v", err), http.StatusBadRequest)
- return
- }
-
- log.Printf("Resolving %d action(s):", len(req.Actions))
- for _, a := range req.Actions {
- path := ""
- if a.Path != "" {
- path = "/" + a.Path
- }
- log.Printf(" %s%s@%s", a.Action, path, a.Version)
- }
-
- // Convert to our internal ActionRef format
- var refs []lockfile.ActionRef
- for _, a := range req.Actions {
- parts := strings.SplitN(a.Action, "/", 2)
- if len(parts) != 2 {
- continue
- }
- refs = append(refs, lockfile.ActionRef{
- Owner: parts[0],
- Repo: parts[1],
- Path: a.Path,
- Ref: a.Version,
- Raw: fmt.Sprintf("%s@%s", a.Action, a.Version),
- })
- }
-
- // Resolve via GraphQL
- deps, err := s.resolver.ResolveAll(refs)
- if err != nil {
- log.Printf("Resolution error: %v", err)
- http.Error(w, fmt.Sprintf("resolution failed: %v", err), http.StatusUnprocessableEntity)
- return
- }
-
- // Build response in runner-expected format
- // Match deps to input refs by NWO (GraphQL alias order may differ from input order)
- resp := ActionDownloadInfoResponseCollection{
- Actions: make(map[string]ActionDownloadInfoResponse),
- }
-
- depsByNWO := make(map[string]lockfile.Dependency)
- for _, dep := range deps {
- depsByNWO[dep.NWO+"@"+dep.Ref] = dep
- }
-
- for _, ref := range refs {
- key := ref.Raw
- nwo := ref.NWO()
- lookupKey := nwo + "@" + ref.Ref
-
- dep, ok := depsByNWO[lookupKey]
- if !ok {
- log.Printf(" [WARN] no resolution for %s", lookupKey)
- continue
- }
-
- tarURL := fmt.Sprintf("https://api.github.com/repos/%s/tarball/%s", nwo, dep.SHA)
- zipURL := fmt.Sprintf("https://api.github.com/repos/%s/zipball/%s", nwo, dep.SHA)
-
- resp.Actions[key] = ActionDownloadInfoResponse{
- Name: nwo,
- ResolvedName: nwo,
- ResolvedSha: dep.SHA,
- TarURL: tarURL,
- ZipURL: zipURL,
- Version: ref.Ref,
- }
-
- log.Printf(" -> %s@%s = %s", nwo, ref.Ref, dep.SHA[:12])
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
-}
diff --git a/dev/harness-dotnet/.gitignore b/dev/harness-dotnet/.gitignore
deleted file mode 100644
index cd42ee3..0000000
--- a/dev/harness-dotnet/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-bin/
-obj/
diff --git a/dev/harness-dotnet/Harness.csproj b/dev/harness-dotnet/Harness.csproj
deleted file mode 100644
index 4493e04..0000000
--- a/dev/harness-dotnet/Harness.csproj
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- Exe
- net8.0
- osx-arm64
- enable
-
-
-
-
-
-
diff --git a/dev/harness-dotnet/Program.cs b/dev/harness-dotnet/Program.cs
deleted file mode 100644
index 6bb79f7..0000000
--- a/dev/harness-dotnet/Program.cs
+++ /dev/null
@@ -1,242 +0,0 @@
-// Harness that spawns the real Runner.Worker with a properly constructed job message.
-// Uses the runner's own types for message construction to ensure wire format compatibility.
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.IO.Pipes;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using GitHub.DistributedTask.Pipelines;
-using GitHub.DistributedTask.WebApi;
-using GitHub.Runner.Sdk;
-
-class Program
-{
- static async Task Main(string[] args)
- {
- if (args.Length < 2)
- {
- Console.Error.WriteLine("Usage: harness ");
- Console.Error.WriteLine(" workflow-uses-spec: comma-separated uses: refs, e.g. 'actions/checkout@v4,actions/setup-go@v5'");
- Console.Error.WriteLine("");
- Console.Error.WriteLine("Environment:");
- Console.Error.WriteLine(" LAUNCH_ENDPOINT Fake Launch URL (default: http://localhost:9399)");
- Console.Error.WriteLine(" GITHUB_TOKEN Token for authentication");
- return 1;
- }
-
- var runnerBinDir = args[0];
- var usesSpecs = args[1].Split(',', StringSplitOptions.RemoveEmptyEntries);
-
- var launchEndpoint = Environment.GetEnvironmentVariable("LAUNCH_ENDPOINT") ?? "http://localhost:9399";
- var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "fake-token";
-
- var workerPath = Path.Combine(runnerBinDir, "Runner.Worker");
- if (!File.Exists(workerPath))
- {
- Console.Error.WriteLine($"Runner.Worker not found at {workerPath}");
- return 1;
- }
-
- // Build the job message using runner's own types
- var message = BuildMessage(usesSpecs, launchEndpoint, token);
- var messageJson = StringUtil.ConvertToJson(message);
- Console.Error.WriteLine($"Job message: {messageJson.Length} bytes, {message.Steps.Count} steps");
- if (Environment.GetEnvironmentVariable("DUMP_MSG") == "1") { Console.WriteLine(messageJson); return 0; }
-
- // Create anonymous pipes
- using var outServer = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
- using var inServer = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
-
- var outHandle = outServer.GetClientHandleAsString();
- var inHandle = inServer.GetClientHandleAsString();
-
- Console.Error.WriteLine($"Starting worker: {workerPath} spawnclient {outHandle} {inHandle}");
-
- var psi = new ProcessStartInfo
- {
- FileName = workerPath,
- Arguments = $"spawnclient {outHandle} {inHandle}",
- WorkingDirectory = runnerBinDir,
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- };
-
- var process = Process.Start(psi)!;
- outServer.DisposeLocalCopyOfClientHandle();
- inServer.DisposeLocalCopyOfClientHandle();
-
- // Forward output
- _ = Task.Run(async () => { string? l; while ((l = await process.StandardOutput.ReadLineAsync()) != null) Console.WriteLine($"[WORKER] {l}"); });
- _ = Task.Run(async () => { string? l; while ((l = await process.StandardError.ReadLineAsync()) != null) Console.Error.WriteLine($"[WORKER:ERR] {l}"); });
-
- // Send job message using the EXACT same wire protocol as ProcessChannel/StreamString
- // CRITICAL: StreamString uses UnicodeEncoding (UTF-16LE), NOT UTF-8!
- Console.Error.WriteLine("Sending job message...");
- var bodyBytes = Encoding.Unicode.GetBytes(messageJson); // UTF-16LE!
- await WriteInt32Async(outServer, 1); // MessageType.NewJobRequest
- await WriteInt32Async(outServer, bodyBytes.Length);
- await outServer.WriteAsync(bodyBytes, 0, bodyBytes.Length);
- await outServer.FlushAsync();
- Console.Error.WriteLine("Job message sent. Waiting for worker...");
-
- // Read worker responses in background
- _ = Task.Run(async () =>
- {
- try
- {
- while (true)
- {
- var msgType = await ReadInt32Async(inServer);
- var bodyLen = await ReadInt32Async(inServer);
- var buf = new byte[bodyLen];
- int read = 0;
- while (read < bodyLen) { var n = await inServer.ReadAsync(buf, read, bodyLen - read); if (n == 0) break; read += n; }
- Console.Error.WriteLine($"[WORKER->HARNESS] type={msgType} body={Encoding.UTF8.GetString(buf, 0, Math.Min(read, 200))}...");
- }
- }
- catch { /* pipe closed */ }
- });
-
- await process.WaitForExitAsync();
- Console.Error.WriteLine($"Worker exited with code {process.ExitCode}");
- return process.ExitCode;
- }
-
- static AgentJobRequestMessage BuildMessage(string[] usesSpecs, string launchEndpoint, string token)
- {
- var plan = new TaskOrchestrationPlanReference();
- var timeline = new TimelineReference { Id = Guid.NewGuid() };
- var jobId = Guid.NewGuid();
-
- var steps = new List();
- for (int i = 0; i < usesSpecs.Length; i++)
- {
- var spec = usesSpecs[i].Trim();
- var step = ParseUsesSpec(spec, i);
- if (step != null) steps.Add(step);
- }
-
- var variables = new Dictionary
- {
- ["system.culture"] = "en-US",
- ["system.github.launch_endpoint"] = launchEndpoint,
- ["system.github.job"] = "test-job",
- ["system.github.workspace"] = "/tmp/actions-workspace",
- ["system.github.token"] = new VariableValue(token, true),
- };
-
- // If LOCKFILE_DEPS env var is set, pass it as the dependencies variable
- var lockfileDeps = Environment.GetEnvironmentVariable("LOCKFILE_DEPS");
- if (!string.IsNullOrEmpty(lockfileDeps))
- {
- variables["system.actions.dependencies"] = lockfileDeps;
- }
-
- var resources = new JobResources();
- resources.Endpoints.Add(new ServiceEndpoint
- {
- Name = WellKnownServiceEndpointNames.SystemVssConnection,
- Url = new Uri(launchEndpoint),
- Authorization = new EndpointAuthorization
- {
- Scheme = "OAuth",
- Parameters = { { "AccessToken", token } }
- }
- });
- resources.Repositories.Add(new RepositoryResource
- {
- Alias = PipelineConstants.SelfAlias,
- Id = "github",
- Version = "sha1"
- });
-
- var contextData = new GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData();
- var githubContext = new GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData();
- githubContext.Add("repository", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("nodeselector/actions-test-fixtures"));
- githubContext.Add("repository_owner", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("nodeselector"));
- githubContext.Add("workspace", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("/tmp/actions-workspace"));
- githubContext.Add("sha", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("abc123"));
- githubContext.Add("ref", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("refs/heads/main"));
- githubContext.Add("server_url", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("https://github.com"));
- githubContext.Add("api_url", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("https://api.github.com"));
- githubContext.Add("action_path", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData(""));
- githubContext.Add("event_name", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("workflow_dispatch"));
- githubContext.Add("event", new GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData());
- githubContext.Add("repository_id", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("repository_owner_id", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("actor", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("test"));
- githubContext.Add("actor_id", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("workflow", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("test"));
- githubContext.Add("run_id", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("run_number", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("run_attempt", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("1"));
- githubContext.Add("head_ref", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData(""));
- githubContext.Add("base_ref", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData(""));
- githubContext.Add("ref_name", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("main"));
- githubContext.Add("ref_type", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("branch"));
- githubContext.Add("repository_visibility", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("public"));
- githubContext.Add("graphql_url", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("https://api.github.com/graphql"));
- githubContext.Add("retention_days", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("90"));
- githubContext.Add("artifact_cache_size_limit", new GitHub.DistributedTask.Pipelines.ContextData.StringContextData("10"));
- contextData.Add("github", githubContext);
-
- var message = new AgentJobRequestMessage(
- plan, timeline, jobId,
- "test-job", "test-job",
- null, null, null,
- variables, new List(),
- resources, contextData,
- new WorkspaceOptions(), steps,
- null, null, null,
- new ActionsEnvironmentReference("production"),
- null,
- messageType: "RunnerJobRequest");
-
- return message;
- }
-
- static ActionStep? ParseUsesSpec(string spec, int index)
- {
- // owner/repo(/path)?@ref
- var atParts = spec.Split('@', 2);
- if (atParts.Length != 2) return null;
- var gitRef = atParts[1];
- var segments = atParts[0].Split('/', 3);
- if (segments.Length < 2) return null;
-
- var name = $"{segments[0]}/{segments[1]}";
- var path = segments.Length > 2 ? segments[2] : "";
-
- var step = new ActionStep
- {
- Id = Guid.NewGuid(),
- DisplayName = spec,
- Condition = "success()",
- Reference = new RepositoryPathReference
- {
- Name = name,
- Ref = gitRef,
- Path = path,
- RepositoryType = "GitHub",
- }
- };
- return step;
- }
-
- static async Task WriteInt32Async(Stream stream, int value)
- {
- await stream.WriteAsync(BitConverter.GetBytes(value));
- }
-
- static async Task ReadInt32Async(Stream stream)
- {
- var bytes = new byte[4];
- int read = 0;
- while (read < 4) { var n = await stream.ReadAsync(bytes, read, 4 - read); if (n == 0) throw new EndOfStreamException(); read += n; }
- return BitConverter.ToInt32(bytes, 0);
- }
-}
diff --git a/dev/jobmsg/jobmsg.go b/dev/jobmsg/jobmsg.go
deleted file mode 100644
index 8a4c512..0000000
--- a/dev/jobmsg/jobmsg.go
+++ /dev/null
@@ -1,157 +0,0 @@
-// Package jobmsg builds a minimal AgentJobRequestMessage JSON from a workflow file.
-package jobmsg
-
-import (
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- "github.com/github/actions-lockfile/pkg/lockfile"
- "gopkg.in/yaml.v3"
-)
-
-// Build creates a job message JSON from a workflow file.
-func Build(wfPath string, launchEndpoint string, token string) ([]byte, error) {
- wf, err := lockfile.Load(wfPath)
- if err != nil {
- return nil, fmt.Errorf("loading workflow: %w", err)
- }
-
- var raw map[string]interface{}
- if err := yaml.Unmarshal(wf.Content, &raw); err != nil {
- return nil, err
- }
-
- jobs, ok := raw["jobs"].(map[string]interface{})
- if !ok {
- return nil, fmt.Errorf("no jobs found in workflow")
- }
-
- // Take the first job
- var steps []map[string]interface{}
- for _, jobData := range jobs {
- job, ok := jobData.(map[string]interface{})
- if !ok {
- continue
- }
- rawSteps, ok := job["steps"].([]interface{})
- if !ok {
- continue
- }
- for i, s := range rawSteps {
- stepMap, ok := s.(map[string]interface{})
- if !ok {
- continue
- }
- uses, ok := stepMap["uses"].(string)
- if !ok {
- // run: step, skip
- continue
- }
- step := buildStep(uses, i)
- if step != nil {
- steps = append(steps, step)
- }
- }
- break
- }
-
- msg := map[string]interface{}{
- "messageType": "PipelineAgentJobRequest",
- "plan": map[string]interface{}{
- "scopeIdentifier": "00000000-0000-0000-0000-000000000000",
- "planType": "Build",
- "planId": "00000000-0000-0000-0000-000000000001",
- },
- "timeline": map[string]interface{}{
- "id": "00000000-0000-0000-0000-000000000002",
- },
- "jobId": "00000000-0000-0000-0000-000000000003",
- "jobDisplayName": "test-job",
- "requestId": 1,
- "lockedUntil": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
- "resources": map[string]interface{}{
- "endpoints": []map[string]interface{}{
- {
- "name": "SystemVssConnection",
- "url": launchEndpoint,
- "authorization": map[string]interface{}{
- "scheme": "OAuth",
- "parameters": map[string]string{
- "AccessToken": token,
- },
- },
- "data": map[string]string{},
- },
- },
- },
- "variables": map[string]interface{}{
- "system.github.launch_endpoint": map[string]string{"value": launchEndpoint},
- "system.github.job": map[string]string{"value": "test-job"},
- "system.github.workspace": map[string]string{"value": "/tmp/actions-workspace"},
- "system.github.token": map[string]string{"value": token, "isSecret": "true"},
- "DistributedTask.NewActionMetadata": map[string]string{"value": "true"},
- },
- "steps": steps,
- }
-
- return json.MarshalIndent(msg, "", " ")
-}
-
-func buildStep(uses string, index int) map[string]interface{} {
- uses = strings.TrimSpace(uses)
-
- if strings.HasPrefix(uses, "./") {
- return map[string]interface{}{
- "type": "Action",
- "reference": map[string]interface{}{
- "type": "repository",
- "repositoryType": "self",
- "path": strings.TrimPrefix(uses, "./"),
- },
- "id": fmt.Sprintf("00000000-0000-0000-0000-0000000000%02d", index+10),
- "displayName": uses,
- }
- }
-
- if strings.HasPrefix(uses, "docker://") {
- return map[string]interface{}{
- "type": "Action",
- "reference": map[string]interface{}{
- "type": "containerRegistry",
- "image": strings.TrimPrefix(uses, "docker://"),
- },
- "id": fmt.Sprintf("00000000-0000-0000-0000-0000000000%02d", index+10),
- "displayName": uses,
- }
- }
-
- atParts := strings.SplitN(uses, "@", 2)
- if len(atParts) != 2 {
- return nil
- }
- ref := atParts[1]
- segments := strings.SplitN(atParts[0], "/", 3)
- if len(segments) < 2 {
- return nil
- }
-
- name := segments[0] + "/" + segments[1]
- path := ""
- if len(segments) == 3 {
- path = segments[2]
- }
-
- return map[string]interface{}{
- "type": "Action",
- "reference": map[string]interface{}{
- "type": "repository",
- "name": name,
- "ref": ref,
- "path": path,
- },
- "id": fmt.Sprintf("00000000-0000-0000-0000-0000000000%02d", index+10),
- "displayName": uses,
- }
-}
diff --git a/go.mod b/go.mod
deleted file mode 100644
index 940f461..0000000
--- a/go.mod
+++ /dev/null
@@ -1,16 +0,0 @@
-module github.com/github/actions-lockfile
-
-go 1.25.3
-
-require (
- github.com/cli/go-gh/v2 v2.12.1
- github.com/spf13/cobra v1.9.1
- gopkg.in/yaml.v3 v3.0.1
-)
-
-require (
- github.com/cli/safeexec v1.0.0 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/kr/pretty v0.3.1 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
-)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 463b3c0..0000000
--- a/go.sum
+++ /dev/null
@@ -1,31 +0,0 @@
-github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=
-github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
-github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
-github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
-github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 0000000..76d7699
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,13 @@
+module github.com/github/actions-lockfile/go
+
+go 1.19
+
+require (
+ github.com/stretchr/testify v1.11.1
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+)
diff --git a/go/go.sum b/go/go.sum
new file mode 100644
index 0000000..c4c1710
--- /dev/null
+++ b/go/go.sum
@@ -0,0 +1,10 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pkg/actionmeta/actionmeta.go b/go/pkg/lockfile/action_meta.go
similarity index 57%
rename from pkg/actionmeta/actionmeta.go
rename to go/pkg/lockfile/action_meta.go
index 7bc453c..0fda6ae 100644
--- a/pkg/actionmeta/actionmeta.go
+++ b/go/pkg/lockfile/action_meta.go
@@ -1,9 +1,4 @@
-// Package actionmeta parses action.yml files to discover execution type and nested uses: refs.
-// Terminology aligns with the runner's ActionExecutionType enum:
-// - ExecNode = ActionExecutionType.NodeJS
-// - ExecDocker = ActionExecutionType.Container
-// - ExecComposite = ActionExecutionType.Composite
-package actionmeta
+package lockfile
import (
"fmt"
@@ -22,17 +17,22 @@ const (
ExecUnknown ExecutionType = "unknown"
)
-// ActionMeta is the parsed subset of action.yml we care about.
+// ActionMeta is the parsed subset of `action.yml` (or `action.yaml`) relevant
+// to dependency resolution: the action's name, how it executes, and composite
+// action nested `uses:` strings.
type ActionMeta struct {
- Name string `yaml:"name"`
- Execution ExecutionType // derived from runs.using
- // NestedUses contains the raw uses: strings from composite steps.
- // Only populated when Execution == ExecComposite.
+ Name string
+ Execution ExecutionType
NestedUses []string
}
-// Parse parses an action.yml content string and extracts execution type + nested uses:.
-func Parse(content string) (*ActionMeta, error) {
+// ParseActionMeta parses the contents of an action.yml file into an
+// ActionMeta. Composite actions emit their nested step `uses:` strings
+// in NestedUses; non-composite actions return an empty NestedUses.
+//
+// Returns an error only on malformed YAML — unknown `runs.using` values
+// resolve to ExecUnknown rather than failing.
+func ParseActionMeta(content string) (*ActionMeta, error) {
var raw struct {
Name string `yaml:"name"`
Runs struct {
@@ -47,9 +47,7 @@ func Parse(content string) (*ActionMeta, error) {
return nil, fmt.Errorf("parsing action.yml: %w", err)
}
- meta := &ActionMeta{
- Name: raw.Name,
- }
+ meta := &ActionMeta{Name: raw.Name}
using := strings.ToLower(raw.Runs.Using)
switch {
diff --git a/go/pkg/lockfile/action_meta_test.go b/go/pkg/lockfile/action_meta_test.go
new file mode 100644
index 0000000..732bcb3
--- /dev/null
+++ b/go/pkg/lockfile/action_meta_test.go
@@ -0,0 +1,37 @@
+package lockfile
+
+import (
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseActionMeta(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ wantExec ExecutionType
+ wantNested int
+ }{
+ {name: "composite action", file: "testdata/composite_action.yml", wantExec: ExecComposite, wantNested: 2},
+ {name: "node action", file: "testdata/node_action.yml", wantExec: ExecNode, wantNested: 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ content, err := os.ReadFile(tt.file)
+ if errors.Is(err, os.ErrNotExist) {
+ t.Skip("testdata is not present in this module checkout (symlink to repo-root testdata is dropped from module zips)")
+ }
+ require.NoError(t, err)
+
+ meta, err := ParseActionMeta(string(content))
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantExec, meta.Execution)
+ assert.Len(t, meta.NestedUses, tt.wantNested)
+ })
+ }
+}
diff --git a/go/pkg/lockfile/doc.go b/go/pkg/lockfile/doc.go
new file mode 100644
index 0000000..a594e75
--- /dev/null
+++ b/go/pkg/lockfile/doc.go
@@ -0,0 +1,24 @@
+// Package lockfile is the source of truth for the workflow dependency
+// lockfile format and pin grammar.
+//
+// # Parsing as a security boundary
+//
+// ParseActionRef is the choke point. Untrusted uses: strings enter; only
+// concrete repository actions leave. Everything else — expressions, docker://
+// images, local paths, reusable workflows, control characters — returns nil,
+// before it can reach a URL or GraphQL builder.
+//
+// ParseReusableWorkflowRef is its mirror: the reusable-workflow shape
+// ParseActionRef rejects, parsed through the same validation. Both split the
+// ref at the first @, never the last — a ref may legitimately contain one.
+//
+// owner/repo/path pass isValidSegment: a fixed character set, ".."/"." barred.
+// Drop-in safe. The ref is looser by necessity — git refs carry slashes, dots,
+// even another @ — so isValidRef only guarantees it cannot escape a quoted
+// literal or smuggle a traversal. A ref still needs escaping before it touches
+// a URL path. Owner/repo do not.
+//
+// Hand-rolled, no regexp, allocation-free, single-pass: it runs per dependency
+// on the hot path and the reject-lists must stay auditable at a glance. They
+// are load-bearing. Do not refactor them into regular expressions.
+package lockfile
diff --git a/go/pkg/lockfile/internal/cmd/genschema/main.go b/go/pkg/lockfile/internal/cmd/genschema/main.go
new file mode 100644
index 0000000..e0517f5
--- /dev/null
+++ b/go/pkg/lockfile/internal/cmd/genschema/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "go/format"
+ "os"
+ "strconv"
+)
+
+func main() {
+ schema, err := os.ReadFile("../../../schema/lockfile-v0.0.1.json")
+ if err != nil {
+ panic(err)
+ }
+
+ var out bytes.Buffer
+ fmt.Fprintln(&out, "// Code generated by go generate; DO NOT EDIT.")
+ fmt.Fprintln(&out)
+ fmt.Fprintln(&out, "package lockfile")
+ fmt.Fprintln(&out)
+ fmt.Fprintf(&out, "const schemaV001 = %s\n", strconv.Quote(string(schema)))
+
+ formatted, err := format.Source(out.Bytes())
+ if err != nil {
+ panic(err)
+ }
+ if err := os.WriteFile("schema_gen.go", formatted, 0644); err != nil {
+ panic(err)
+ }
+}
diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go
new file mode 100644
index 0000000..ad76bd6
--- /dev/null
+++ b/go/pkg/lockfile/lockfile.go
@@ -0,0 +1,499 @@
+package lockfile
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+
+ "gopkg.in/yaml.v3"
+)
+
+// ErrFutureVersion is the sentinel returned (via errors.Is) when Parse refuses
+// a lockfile whose schema version is newer than this binary supports. External
+// consumers (e.g. Dependabot) can detect this specific failure mode without
+// scraping the error string.
+var ErrFutureVersion = errors.New("lockfile version is newer than this binary supports")
+
+// ParseError describes a failure to parse a dependency lockfile.
+//
+// Line and Column, when non-zero, are the 1-indexed position within the
+// lockfile contents that the failure refers to. They index the lockfile
+// itself, never a consumer's workflow file, so callers can anchor diagnostics
+// on the lockfile (.github/workflows/actions.lock) rather than scraping
+// yaml.v3's error string themselves.
+//
+// Column is populated for semantic failures Parse detects itself (it walks the
+// retained YAML node tree to the offending key/value). It is left zero for raw
+// yaml.v3 decode failures, whose errors report only a line: a malformed
+// document has no node tree to read a column from, and yaml.v3 type errors
+// carry a line but no column.
+//
+// Msg is the human-readable reason with yaml.v3's "yaml:" package prefix and
+// leading position removed.
+type ParseError struct {
+ Line int
+ Column int
+ Msg string
+ err error
+}
+
+func (e *ParseError) Error() string {
+ switch {
+ case e.Line > 0 && e.Column > 0:
+ return fmt.Sprintf("line %d, column %d: %s", e.Line, e.Column, e.Msg)
+ case e.Line > 0:
+ return fmt.Sprintf("line %d: %s", e.Line, e.Msg)
+ default:
+ return e.Msg
+ }
+}
+
+func (e *ParseError) Unwrap() error {
+ return e.err
+}
+
+// yamlLinePattern matches the 1-indexed position gopkg.in/yaml.v3 embeds in its
+// error messages: "yaml: line N: ..." for syntax errors, or " line N: ..."
+// within an "unmarshal errors:" block for type errors.
+var yamlLinePattern = regexp.MustCompile(`line (\d+):`)
+
+// leadingYAMLPosition matches yaml.v3's "yaml:" package prefix and any
+// immediately following "line N:" position.
+var leadingYAMLPosition = regexp.MustCompile(`^yaml: (line \d+: )?`)
+
+// newYAMLParseError converts a gopkg.in/yaml.v3 error into a ParseError,
+// lifting the line number out of the message so consumers receive it as
+// structured data instead of having to scrape the string themselves.
+func newYAMLParseError(err error) *ParseError {
+ msg := err.Error()
+ line := 0
+ if m := yamlLinePattern.FindStringSubmatch(msg); m != nil {
+ if n, convErr := strconv.Atoi(m[1]); convErr == nil {
+ line = n
+ }
+ }
+ return &ParseError{
+ Line: line,
+ Msg: leadingYAMLPosition.ReplaceAllString(msg, ""),
+ err: err,
+ }
+}
+
+// Version is the only supported lockfile schema version.
+const Version = "v0.0.1"
+
+// Path is the canonical repo-relative location of the dependency lockfile.
+const Path = ".github/workflows/actions.lock"
+
+// File is the parsed lockfile shape.
+//
+// # .github/workflows/actions.lock
+// version: v0.0.1
+// workflows:
+// .github/workflows/deploy.yml:
+// - actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+// dependencies:
+// actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5:
+// tag: v4.3.1
+// branch: main
+// commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5
+// owner_id: 44036562
+// repo_id: 197814629
+// uses:
+// - actions/cache@v4.0.0:sha1-...
+//
+// The Go field `Dependencies` maps to the YAML key `dependencies:` — the
+// lockfile's deduplicated action DAG. Each entry's `uses:` list names the
+// action's direct nested dependencies, reusing the same canonical pin keys.
+// Workflow entries hold the full transitive closure as a flat list of pin
+// keys for cold readability.
+type File struct {
+ Version string `yaml:"version"`
+ Dependencies map[string]Action `yaml:"dependencies"`
+ Workflows map[string][]string `yaml:"workflows"`
+
+ // node retains the parsed YAML tree so callers can resolve positions for
+ // their own diagnostics via Position/KeyPosition. It is nil on the
+ // zero-value File returned alongside an error. yaml.v3 ignores this
+ // unexported field during decoding.
+ node *yaml.Node
+}
+
+// Position returns the 1-indexed line and column of the value node reached by
+// following path as a sequence of mapping keys from the lockfile root (e.g.
+// Position("version") points at the version value). ok is false when the path
+// can't be resolved or no node tree was retained.
+func (f File) Position(path ...string) (line, col int, ok bool) {
+ v := f.valueNode(path)
+ if v == nil {
+ return 0, 0, false
+ }
+ return v.Line, v.Column, true
+}
+
+// KeyPosition is like Position but resolves the position of the final path
+// segment's *key* node rather than its value. It is the right anchor for map
+// entries whose key is the meaningful token (e.g. a dependency pin key or a
+// workflow path under "workflows").
+func (f File) KeyPosition(path ...string) (line, col int, ok bool) {
+ if len(path) == 0 {
+ return 0, 0, false
+ }
+ m := docMapping(f.node)
+ for _, key := range path[:len(path)-1] {
+ _, v := mappingEntry(m, key)
+ if v == nil {
+ return 0, 0, false
+ }
+ m = v
+ }
+ k, _ := mappingEntry(m, path[len(path)-1])
+ if k == nil {
+ return 0, 0, false
+ }
+ return k.Line, k.Column, true
+}
+
+// valueNode walks path from the lockfile root mapping, returning the value
+// node of the final segment, or nil when any segment is missing.
+func (f File) valueNode(path []string) *yaml.Node {
+ m := docMapping(f.node)
+ var v *yaml.Node
+ for _, key := range path {
+ _, v = mappingEntry(m, key)
+ if v == nil {
+ return nil
+ }
+ m = v
+ }
+ return v
+}
+
+// docMapping unwraps a document node to its top-level mapping, returning nil
+// when n is not a mapping (or is absent).
+func docMapping(n *yaml.Node) *yaml.Node {
+ if n == nil {
+ return nil
+ }
+ if n.Kind == yaml.DocumentNode {
+ if len(n.Content) == 0 {
+ return nil
+ }
+ n = n.Content[0]
+ }
+ if n.Kind != yaml.MappingNode {
+ return nil
+ }
+ return n
+}
+
+// mappingEntry returns the key and value nodes for key within a mapping node,
+// or (nil, nil) when m is not a mapping or the key is absent.
+func mappingEntry(m *yaml.Node, key string) (k, v *yaml.Node) {
+ if m == nil || m.Kind != yaml.MappingNode {
+ return nil, nil
+ }
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ return m.Content[i], m.Content[i+1]
+ }
+ }
+ return nil, nil
+}
+
+// LookupWorkflow returns the dependency closure for the given repo-relative
+// workflow key (e.g. ".github/workflows/deploy.yml"). The returned bool
+// reports whether the key was found.
+func (f File) LookupWorkflow(workflowKey string) ([]string, bool) {
+ w, ok := f.Workflows[workflowKey]
+ return w, ok
+}
+
+// Action carries the per-action metadata recorded in the lockfile under the
+// pin key.
+//
+// Tag is the discovered release/tag at the commit, if one exists. Optional.
+//
+// Branch is a branch that contains the pinned commit. Required: a commit not
+// on any branch is an impostor / fork-network signal, so Parse rejects an
+// Action without one. It is the authenticity check that SHA-only pinning
+// lacks.
+//
+// Commit holds the digest in algo-prefixed form (e.g. "sha1-..." or
+// "sha256-..."). Required.
+//
+// Uses lists the action's direct nested dependencies (composite action
+// `uses:` steps) as canonical pin keys. Empty for leaf actions; required for
+// composites, a condition Parse can't enforce structurally.
+type Action struct {
+ Tag string `yaml:"tag,omitempty"`
+ Branch string `yaml:"branch,omitempty"`
+ Commit string `yaml:"commit,omitempty"`
+ OwnerID int64 `yaml:"owner_id"`
+ RepoID int64 `yaml:"repo_id"`
+ Uses []string `yaml:"uses,omitempty"`
+}
+
+// Parse unmarshals YAML lockfile contents and verifies the version is
+// supported. It enforces structural validity — unknown top-level keys are
+// rejected and required fields must be present — but does not validate pin
+// integrity (e.g. whether a SHA actually matches the ref) or action
+// existence. That belongs to the consumer (e.g. gh-actions-pin's check
+// command).
+//
+// Action map keys and workflow dependency entries are canonicalized via
+// ParsePin so downstream lookups by canonical key (e.g. pin.String()) match
+// regardless of the source casing of owner/repo/algo/hex in the YAML.
+// Entries that do not parse as a valid pin are left untouched; consumers
+// can flag them via diagnostics. Workflow path keys are NOT canonicalized
+// — filesystem paths are case-sensitive on the platforms we run on.
+func Parse(contents []byte) (File, error) {
+ var root yaml.Node
+ if err := yaml.Unmarshal(contents, &root); err != nil {
+ return File{}, newYAMLParseError(err)
+ }
+ var f File
+ if err := root.Decode(&f); err != nil {
+ return File{}, newYAMLParseError(err)
+ }
+ // Retain the tree so semantic errors below (and consumers) can resolve
+ // precise line+column positions within the lockfile.
+ f.node = &root
+
+ if f.Version == "" {
+ // No version node to point at; anchor at the top of the document.
+ pe := &ParseError{Msg: "dependency lockfile version is required"}
+ if m := docMapping(f.node); m != nil {
+ pe.Line, pe.Column = m.Line, m.Column
+ }
+ return File{}, pe
+ }
+ if f.Version != Version {
+ msg := fmt.Sprintf("unsupported dependency lockfile version %q", f.Version)
+ var wrapped error
+ if isFutureVersion(f.Version, Version) {
+ msg = fmt.Sprintf(
+ "lockfile version %s is newer than this binary supports (%s); "+
+ "upgrade the tool that reads this lockfile to a build that supports %s",
+ f.Version, Version, f.Version,
+ )
+ wrapped = ErrFutureVersion
+ }
+ pe := &ParseError{Msg: msg, err: wrapped}
+ if l, c, ok := f.Position("version"); ok {
+ pe.Line, pe.Column = l, c
+ }
+ return File{}, pe
+ }
+ if pe := validateKnownFields(&f); pe != nil {
+ return File{}, pe
+ }
+ if conflictKey, err := canonicalizeActions(&f); err != nil {
+ pe := &ParseError{Msg: err.Error(), err: err}
+ if l, c, ok := f.KeyPosition("dependencies", conflictKey); ok {
+ pe.Line, pe.Column = l, c
+ }
+ return File{}, pe
+ }
+ canonicalizeWorkflowDependencies(&f)
+ return f, nil
+}
+
+// allowedFileKeys is the set of permitted top-level lockfile keys. It mirrors
+// the document-level properties declared in lockfile-v0.0.1.json.
+var allowedFileKeys = map[string]struct{}{
+ "version": {},
+ "workflows": {},
+ "dependencies": {},
+}
+
+// allowedActionKeys is the set of permitted keys within a dependency's Action
+// mapping. It mirrors the $defs/action properties in lockfile-v0.0.1.json.
+var allowedActionKeys = map[string]struct{}{
+ "tag": {},
+ "branch": {},
+ "commit": {},
+ "owner_id": {},
+ "repo_id": {},
+ "uses": {},
+}
+
+// requiredActionKeys lists the keys every dependency's Action mapping must
+// carry, in report order. It mirrors the $defs/action "required" list in
+// lockfile-v0.0.1.json. `tag` is optional (not every commit is a release) and
+// `uses` is required only for composite actions — a condition the lockfile
+// alone can't express — so neither appears here.
+var requiredActionKeys = []string{"branch", "commit", "owner_id", "repo_id"}
+
+// validateKnownFields enforces the schema's additionalProperties:false and
+// required rules on the lockfile's fixed-shape mappings — the document root and
+// each dependency's metadata block. A stray, misspelled, or missing key is a
+// positioned parse error rather than a silently dropped or defaulted field,
+// matching the stricter parsing the embedded schema describes. Map-valued
+// sections (workflow paths, dependency pin keys) carry arbitrary data keys and
+// are intentionally not constrained here.
+func validateKnownFields(f *File) *ParseError {
+ root := docMapping(f.node)
+ if root == nil {
+ return nil
+ }
+ for i := 0; i+1 < len(root.Content); i += 2 {
+ k := root.Content[i]
+ if _, ok := allowedFileKeys[k.Value]; !ok {
+ return &ParseError{Line: k.Line, Column: k.Column, Msg: fmt.Sprintf("unknown lockfile field %q", k.Value)}
+ }
+ }
+ _, deps := mappingEntry(root, "dependencies")
+ if deps == nil || deps.Kind != yaml.MappingNode {
+ return nil
+ }
+ 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
+ }
+ present := make(map[string]struct{}, len(action.Content)/2)
+ for j := 0; j+1 < len(action.Content); j += 2 {
+ ak := action.Content[j]
+ if _, ok := allowedActionKeys[ak.Value]; !ok {
+ return &ParseError{
+ Line: ak.Line,
+ Column: ak.Column,
+ Msg: fmt.Sprintf("unknown action field %q for dependency %q", ak.Value, pinKey.Value),
+ }
+ }
+ present[ak.Value] = struct{}{}
+ }
+ for _, req := range requiredActionKeys {
+ if _, ok := present[req]; !ok {
+ // The missing key has no node to point at; anchor the error on
+ // the dependency's pin key so callers can locate the entry.
+ return &ParseError{
+ Line: pinKey.Line,
+ Column: pinKey.Column,
+ Msg: fmt.Sprintf("missing required action field %q for dependency %q", req, pinKey.Value),
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// canonicalizeActions rewrites the Dependencies map so every key is the
+// canonical form of its pin (Pin.String()). A conflict between two
+// different source casings of the same pin is a parse error — the file
+// would be ambiguous about which Action metadata applies. On conflict it
+// returns the offending source key so callers can locate it in the YAML tree.
+func canonicalizeActions(f *File) (string, error) {
+ if len(f.Dependencies) == 0 {
+ return "", nil
+ }
+ out := make(map[string]Action, len(f.Dependencies))
+ for key, action := range f.Dependencies {
+ canonical := key
+ if pin, ok := ParsePin(key); ok {
+ canonical = pin.String()
+ }
+ // Canonicalize Uses entries too so cross-references resolve.
+ if len(action.Uses) > 0 {
+ canonUses := make([]string, len(action.Uses))
+ for i, u := range action.Uses {
+ if pin, ok := ParsePin(u); ok {
+ canonUses[i] = pin.String()
+ } else {
+ canonUses[i] = u
+ }
+ }
+ action.Uses = canonUses
+ }
+ if existing, dup := out[canonical]; dup {
+ if !equalAction(existing, action) {
+ return key, fmt.Errorf("duplicate action key %q after canonicalization with differing metadata", canonical)
+ }
+ continue
+ }
+ out[canonical] = action
+ }
+ f.Dependencies = out
+ return "", nil
+}
+
+func equalAction(a, b Action) bool {
+ if a.Tag != b.Tag || a.Branch != b.Branch || a.Commit != b.Commit ||
+ a.OwnerID != b.OwnerID || a.RepoID != b.RepoID {
+ return false
+ }
+ if len(a.Uses) != len(b.Uses) {
+ return false
+ }
+ for i := range a.Uses {
+ if a.Uses[i] != b.Uses[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// canonicalizeWorkflowDependencies rewrites every workflow's pin list to
+// canonical pin strings (Pin.String()) so lookups into the Dependencies map are
+// casing-agnostic. Unparseable entries are preserved verbatim for downstream
+// diagnostics to flag.
+func canonicalizeWorkflowDependencies(f *File) {
+ for path, deps := range f.Workflows {
+ if len(deps) == 0 {
+ continue
+ }
+ canonicalized := make([]string, len(deps))
+ for i, dep := range deps {
+ if pin, ok := ParsePin(dep); ok {
+ canonicalized[i] = pin.String()
+ } else {
+ canonicalized[i] = dep
+ }
+ }
+ f.Workflows[path] = canonicalized
+ }
+}
+
+// schemaVersionRE matches "vMAJOR.MINOR.PATCH" with an optional leading "v"
+// and no pre-release suffix. The lockfile schema version is a strict
+// dotted-triple — anything else is unknown rather than future.
+var schemaVersionRE = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
+
+// isFutureVersion reports whether actual is a well-formed schema version
+// strictly greater than supported. Used to distinguish "newer binary needed"
+// (friendly upgrade path) from "garbage/unknown version" (generic refusal).
+func isFutureVersion(actual, supported string) bool {
+ a, ok := parseSchemaVersion(actual)
+ if !ok {
+ return false
+ }
+ s, ok := parseSchemaVersion(supported)
+ if !ok {
+ return false
+ }
+ for i := 0; i < 3; i++ {
+ if a[i] != s[i] {
+ return a[i] > s[i]
+ }
+ }
+ return false
+}
+
+func parseSchemaVersion(v string) ([3]int, bool) {
+ m := schemaVersionRE.FindStringSubmatch(v)
+ if m == nil {
+ return [3]int{}, false
+ }
+ var out [3]int
+ for i := 0; i < 3; i++ {
+ n, err := strconv.Atoi(m[i+1])
+ if err != nil {
+ return [3]int{}, false
+ }
+ out[i] = n
+ }
+ return out, true
+}
diff --git a/go/pkg/lockfile/lockfile_test.go b/go/pkg/lockfile/lockfile_test.go
new file mode 100644
index 0000000..c38dc95
--- /dev/null
+++ b/go/pkg/lockfile/lockfile_test.go
@@ -0,0 +1,263 @@
+package lockfile
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParse_VersionRequired(t *testing.T) {
+ _, err := Parse([]byte(`dependencies: {}` + "\n"))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "version is required")
+}
+
+func TestParse_UnsupportedVersion(t *testing.T) {
+ // A version that isn't well-formed semver is rejected with the generic
+ // "unsupported" message — no upgrade-path hint, since we can't tell if
+ // the user is behind or just looking at garbage.
+ _, err := Parse([]byte("version: garbage\ndependencies: {}\n"))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported dependency lockfile version")
+ assert.False(t, errors.Is(err, ErrFutureVersion))
+}
+
+func TestParse_FutureVersion_ReturnsFriendlyError(t *testing.T) {
+ _, err := Parse([]byte("version: v0.0.999\ndependencies: {}\n"))
+ require.Error(t, err)
+ msg := err.Error()
+ assert.Contains(t, msg, "v0.0.999", "should name the lockfile version")
+ assert.Contains(t, msg, Version, "should name the supported version")
+ assert.Contains(t, msg, "upgrade", "should tell the user to upgrade")
+ // The library must stay tool-agnostic: ErrFutureVersion is consumed by
+ // external readers (Dependabot, actions-workflow-parser), so the message
+ // must not name a specific wrapping CLI. Consumers append their own
+ // upgrade instructions off errors.Is(err, ErrFutureVersion).
+ assert.NotContains(t, msg, "gh-actions-pin", "library message must not name a specific consumer tool")
+}
+
+func TestParse_FutureVersion_IsErrFutureVersion(t *testing.T) {
+ _, err := Parse([]byte("version: v9.0.0\ndependencies: {}\n"))
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrFutureVersion), "future-version error should match ErrFutureVersion sentinel")
+}
+
+func TestParse_WrongShapeReportsLine(t *testing.T) {
+ // A workflow value shaped as a mapping instead of the expected sequence of
+ // pin keys fails yaml type-decoding. Parse must surface the failing line as
+ // structured data (ParseError.Line) and strip yaml.v3's "yaml:" prefix from
+ // the reason so consumers don't misattribute the position to their own file.
+ yaml := `version: v0.0.1
+dependencies: {}
+workflows:
+ .github/workflows/ci.yml:
+ dependencies:
+ - actions/checkout@v6
+`
+ _, 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.Greater(t, pe.Line, 0, "expected a lockfile line number")
+ assert.NotContains(t, pe.Msg, "yaml:", "yaml package prefix must be stripped from the reason")
+}
+
+func TestParse_UnsupportedVersionReportsPosition(t *testing.T) {
+ // A semantic failure Parse detects itself must carry both line and column,
+ // resolved by walking the retained YAML node tree to the offending value.
+ yaml := `version: v9
+dependencies: {}
+`
+ _, 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.Equal(t, 1, pe.Line, "version value is on line 1")
+ assert.Greater(t, pe.Column, 0, "expected a column for a positioned semantic error")
+}
+
+func TestParse_DuplicateActionKeyReportsPosition(t *testing.T) {
+ // The conflicting key must be located in the source tree so the position
+ // points at a real offending dependency entry.
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8:
+ owner_id: 1234
+ repo_id: 5678
+ Actions/Checkout@v6:SHA1-8E8C483DB84B4BEE98B60C0593521ED34D9990E8:
+ owner_id: 9999
+ repo_id: 1
+`
+ _, 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.Greater(t, pe.Line, 0, "expected a line for the conflicting key")
+ assert.Greater(t, pe.Column, 0, "expected a column for the conflicting key")
+}
+
+func TestParse_PositionLookup(t *testing.T) {
+ // The retained node tree is exposed for consumer diagnostics via
+ // Position/KeyPosition.
+ yaml := `version: v0.0.1
+dependencies: {}
+workflows:
+ .github/workflows/ci.yml: []
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+
+ line, col, ok := f.Position("version")
+ require.True(t, ok)
+ assert.Equal(t, 1, line)
+ assert.Greater(t, col, 0)
+
+ kl, kc, ok := f.KeyPosition("workflows", ".github/workflows/ci.yml")
+ require.True(t, ok)
+ assert.Equal(t, 4, kl, "workflow key is on line 4")
+ assert.Greater(t, kc, 0)
+
+ _, _, ok = f.Position("nope")
+ assert.False(t, ok, "missing path resolves to ok=false")
+}
+
+func TestParse_CanonicalizesActionKeys(t *testing.T) {
+ const canonical = "actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8"
+ yaml := `version: v0.0.1
+dependencies:
+ Actions/Checkout@v6:SHA1-8E8C483DB84B4BEE98B60C0593521ED34D9990E8:
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1234
+ repo_id: 5678
+workflows:
+ .github/workflows/ci.yml:
+ - Actions/Checkout@v6:SHA1-8E8C483DB84B4BEE98B60C0593521ED34D9990E8
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+
+ // Action map key is canonicalized so a lookup by Pin.String() hits.
+ meta, ok := f.Dependencies[canonical]
+ require.True(t, ok, "expected canonical key %q in dependencies; got keys: %v", canonical, mapKeys(f.Dependencies))
+ assert.Equal(t, int64(1234), meta.OwnerID)
+ assert.Equal(t, int64(5678), meta.RepoID)
+
+ // Workflow dependency entries are canonicalized too.
+ wf, ok := f.Workflows[".github/workflows/ci.yml"]
+ require.True(t, ok)
+ require.Len(t, wf, 1)
+ assert.Equal(t, canonical, wf[0])
+}
+
+func TestParse_ConflictingActionKeyCasings(t *testing.T) {
+ // Two source-casings of the same pin with differing metadata is
+ // ambiguous and must be rejected.
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8:
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1234
+ repo_id: 5678
+ Actions/Checkout@v6:SHA1-8E8C483DB84B4BEE98B60C0593521ED34D9990E8:
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 9999
+ repo_id: 1
+`
+ _, err := Parse([]byte(yaml))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "duplicate action key")
+}
+
+func TestParse_DuplicateActionKeyCasingsSameMetadataOK(t *testing.T) {
+ // Same metadata on two casings collapses to one canonical entry.
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8:
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1234
+ repo_id: 5678
+ Actions/Checkout@v6:SHA1-8E8C483DB84B4BEE98B60C0593521ED34D9990E8:
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1234
+ repo_id: 5678
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+ assert.Len(t, f.Dependencies, 1)
+}
+
+func TestParse_UnparseableActionKeyPreserved(t *testing.T) {
+ // Garbage keys are preserved verbatim so structural diagnostics can
+ // surface them; Parse itself is not the validator.
+ yaml := `version: v0.0.1
+dependencies:
+ "not a pin":
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1
+ repo_id: 2
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+ _, ok := f.Dependencies["not a pin"]
+ assert.True(t, ok)
+}
+
+func TestParse_WorkflowPathKeyNotCanonicalized(t *testing.T) {
+ // File paths are case-sensitive on Linux; do not normalize them.
+ yaml := `version: v0.0.1
+dependencies: {}
+workflows:
+ .github/workflows/CI.yml: []
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+ _, ok := f.Workflows[".github/workflows/CI.yml"]
+ assert.True(t, ok)
+}
+
+func TestParse_TagAndBranchRoundTrip(t *testing.T) {
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8:
+ tag: v6
+ branch: main
+ commit: sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8
+ owner_id: 1234
+ repo_id: 5678
+ actions/internal@trunk:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
+ branch: trunk
+ commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ owner_id: 1
+ repo_id: 2
+workflows: {}
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+
+ withTag := f.Dependencies["actions/checkout@v6:sha1-8e8c483db84b4bee98b60c0593521ed34d9990e8"]
+ assert.Equal(t, "v6", withTag.Tag)
+ assert.Equal(t, "main", withTag.Branch)
+
+ branchOnly := f.Dependencies["actions/internal@trunk:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
+ assert.Equal(t, "", branchOnly.Tag)
+ assert.Equal(t, "trunk", branchOnly.Branch)
+}
+
+func mapKeys[V any](m map[string]V) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/go/pkg/lockfile/nwo.go b/go/pkg/lockfile/nwo.go
new file mode 100644
index 0000000..ad5c029
--- /dev/null
+++ b/go/pkg/lockfile/nwo.go
@@ -0,0 +1,33 @@
+package lockfile
+
+import "strings"
+
+// SplitNWO splits an owner/repo (Name-With-Owner) string into its two
+// components. It returns ok=false for inputs that don't carry both an
+// owner and a repo segment: the empty string, anything without a slash,
+// a leading slash ("/repo"), and a trailing slash without a repo
+// ("owner/").
+//
+// For inputs with extra path segments ("owner/repo/sub/..."), only the
+// first two segments are returned; the remainder is dropped. This
+// matches Dependency.OwnerRepo and the lockfile's repo-granularity
+// pin grammar (sub-action paths are graph traversal details, not pin
+// identity).
+//
+// SplitNWO does not validate the owner/repo character set — use
+// ParseActionRef when parsing a verbatim `uses:` value where stricter
+// charset rules apply.
+func SplitNWO(nwo string) (owner, repo string, ok bool) {
+ slashIdx := strings.IndexByte(nwo, '/')
+ if slashIdx <= 0 || slashIdx == len(nwo)-1 {
+ return "", "", false
+ }
+ owner = nwo[:slashIdx]
+ rest := nwo[slashIdx+1:]
+ if i := strings.IndexByte(rest, '/'); i > 0 {
+ repo = rest[:i]
+ } else {
+ repo = rest
+ }
+ return owner, repo, true
+}
diff --git a/go/pkg/lockfile/nwo_test.go b/go/pkg/lockfile/nwo_test.go
new file mode 100644
index 0000000..a789306
--- /dev/null
+++ b/go/pkg/lockfile/nwo_test.go
@@ -0,0 +1,32 @@
+package lockfile
+
+import "testing"
+
+func TestSplitNWO(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ owner string
+ repo string
+ ok bool
+ }{
+ {"empty", "", "", "", false},
+ {"no slash", "owner", "", "", false},
+ {"leading slash", "/repo", "", "", false},
+ {"trailing slash", "owner/", "", "", false},
+ {"only slash", "/", "", "", false},
+ {"basic", "actions/checkout", "actions", "checkout", true},
+ {"sub-path drops to repo", "actions/cache/save", "actions", "cache", true},
+ {"deep sub-path drops to repo", "actions/cache/restore/extra", "actions", "cache", true},
+ {"preserves casing", "Actions/Checkout", "Actions", "Checkout", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ owner, repo, ok := SplitNWO(tc.in)
+ if owner != tc.owner || repo != tc.repo || ok != tc.ok {
+ t.Fatalf("SplitNWO(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ tc.in, owner, repo, ok, tc.owner, tc.repo, tc.ok)
+ }
+ })
+ }
+}
diff --git a/go/pkg/lockfile/pin.go b/go/pkg/lockfile/pin.go
new file mode 100644
index 0000000..a2ec105
--- /dev/null
+++ b/go/pkg/lockfile/pin.go
@@ -0,0 +1,164 @@
+package lockfile
+
+import (
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+
+ "strings"
+)
+
+// Pin holds the parsed components of a dependency pin key.
+//
+// "OWNER/REPO@REF:ALGO-HEX"
+//
+// The pin identifies a downloaded action tarball at repo+SHA granularity —
+// matching the runner, which downloads `owner/repo@sha` once per ref and
+// reuses the tree for any sub-action path. Sub-action paths (e.g. the
+// `save` in `actions/cache/save@v4`) are graph traversal details, not pin
+// identity, and do not appear in this serialized form.
+type Pin struct {
+ NWO string // "actions/checkout"
+ Owner string // "actions"
+ Repo string // "checkout"
+ Ref string // "v4"
+ Algo string // "sha1"
+ Hex string // "34e114876b0b11c390a56381ad16ebd13914f8d5"
+}
+
+// Canonical returns a copy of p with all case-insensitive components
+// (owner, repo, algo, hex) normalized to lowercase. Ref preserves source
+// casing — git refs are case-sensitive.
+//
+// This is the single normalization point for the lockfile pin grammar:
+// String, IndexKey, and ParsePin all funnel through it, so callers never
+// need their own ToLower bookkeeping when handing pins through this package.
+func (p Pin) Canonical() Pin {
+ p.Owner = strings.ToLower(p.Owner)
+ p.Repo = strings.ToLower(p.Repo)
+ p.Algo = strings.ToLower(p.Algo)
+ p.Hex = strings.ToLower(p.Hex)
+ p.NWO = p.Owner + "/" + p.Repo
+ return p
+}
+
+// String returns the canonical pin form: "OWNER/REPO@REF:ALGO-HEX".
+// This doubles as the actions-map key in the lockfile.
+func (p Pin) String() string {
+ c := p.Canonical()
+ return c.NWO + "@" + c.Ref + ":" + c.Algo + "-" + c.Hex
+}
+
+// IndexKey returns the normalized lookup key for this pin without the digest:
+// "OWNER/REPO@REF".
+func (p Pin) IndexKey() string {
+ c := p.Canonical()
+ return c.NWO + "@" + c.Ref
+}
+
+// IndexKey builds the normalized lookup key for a dependency entry without
+// the digest: "OWNER/REPO@REF".
+func IndexKey(owner, repo, ref string) string {
+ return Pin{Owner: owner, Repo: repo, Ref: ref}.IndexKey()
+}
+
+// ParsePin parses a pin string of the canonical form:
+//
+// "OWNER/REPO@REF:ALGO-HEX"
+//
+// Returns ok=false if the string doesn't match the expected format,
+// including any sub-action path component (e.g. "owner/repo/sub@ref:...")
+// — the lockfile grammar is strictly repo-scoped, matching the runner's
+// tarball download identity.
+func ParsePin(s string) (Pin, bool) {
+ atIdx := strings.IndexByte(s, '@')
+ if atIdx <= 0 || atIdx == len(s)-1 {
+ return Pin{}, false
+ }
+ repoPath := s[:atIdx]
+ refHash := s[atIdx+1:]
+
+ // Sub-action paths are not part of the lockfile pin grammar: the runner
+ // downloads at repo+sha granularity. Reject any extra slashes in the
+ // repo portion so hand-edited lockfiles don't drift into a path-bearing
+ // format.
+ if strings.Count(repoPath, "/") != 1 {
+ return Pin{}, false
+ }
+ owner, repo, ok := SplitNWO(repoPath)
+ if !ok {
+ return Pin{}, false
+ }
+
+ colonIdx := strings.LastIndexByte(refHash, ':')
+ if colonIdx <= 0 || colonIdx == len(refHash)-1 {
+ return Pin{}, false
+ }
+ ref := refHash[:colonIdx]
+ if strings.ContainsRune(ref, ':') {
+ return Pin{}, false
+ }
+ hashSpec := refHash[colonIdx+1:]
+
+ dashIdx := strings.IndexByte(hashSpec, '-')
+ if dashIdx <= 0 || dashIdx == len(hashSpec)-1 {
+ return Pin{}, false
+ }
+ algo := strings.ToLower(hashSpec[:dashIdx])
+ hexDigest := strings.ToLower(hashSpec[dashIdx+1:])
+ if !isValidDigest(algo, hexDigest) {
+ return Pin{}, false
+ }
+
+ return Pin{
+ Owner: owner,
+ Repo: repo,
+ Ref: ref,
+ Algo: algo,
+ Hex: hexDigest,
+ }.Canonical(), true
+}
+
+// IsFullSha reports whether s looks like a full commit hash (SHA-1 or
+// SHA-256). Callers use this to distinguish bare-SHA `uses:` refs from
+// symbolic refs.
+func IsFullSha(s string) bool {
+ if len(s) != 40 && len(s) != 64 {
+ return false
+ }
+ for _, c := range s {
+ if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
+ return false
+ }
+ }
+ return true
+}
+
+// ShortSHA returns the first 12 characters of a SHA, or the full string
+// if shorter. Used for human-readable log and diagnostic output.
+func ShortSHA(s string) string {
+ if len(s) <= 12 {
+ return s
+ }
+ return s[:12]
+}
+
+func digestLength(algo string) (int, bool) {
+ switch algo {
+ case "sha1":
+ return sha1.Size * 2, true
+ case "sha256":
+ return sha256.Size * 2, true
+ default:
+ return 0, false
+ }
+}
+
+func isValidDigest(algo, digest string) bool {
+ expectedDigestLength, ok := digestLength(algo)
+ if !ok || len(digest) != expectedDigestLength {
+ return false
+ }
+ _, err := hex.DecodeString(digest)
+ return err == nil
+}
diff --git a/go/pkg/lockfile/pin_test.go b/go/pkg/lockfile/pin_test.go
new file mode 100644
index 0000000..d120441
--- /dev/null
+++ b/go/pkg/lockfile/pin_test.go
@@ -0,0 +1,130 @@
+package lockfile
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePin(t *testing.T) {
+ tests := []struct {
+ name string
+ entry string
+ want Pin
+ }{
+ {
+ name: "owner repo",
+ entry: "actions/checkout@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683",
+ want: Pin{
+ NWO: "actions/checkout",
+ Owner: "actions",
+ Repo: "checkout",
+ Ref: "v4",
+ Algo: "sha1",
+ Hex: "11bd71901bbe5b1630ceea73d27597364c9af683",
+ },
+ },
+ {
+ name: "sha256",
+ entry: "actions/checkout@v4:sha256-a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
+ want: Pin{
+ NWO: "actions/checkout",
+ Owner: "actions",
+ Repo: "checkout",
+ Ref: "v4",
+ Algo: "sha256",
+ Hex: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
+ },
+ },
+ {
+ // Monorepo sub-action tags (e.g. attest-build-provenance's
+ // predicate/) embed an '@' in the ref, producing a double-'@'
+ // key. The first '@' bounds the NWO and the last ':' bounds the
+ // digest, so the ref survives intact.
+ name: "ref containing at (monorepo sub-action tag)",
+ entry: "actions/attest-build-provenance@predicate@1.1.4:sha1-36fa7d009e22618ca7cd599486979b8150596c74",
+ want: Pin{
+ NWO: "actions/attest-build-provenance",
+ Owner: "actions",
+ Repo: "attest-build-provenance",
+ Ref: "predicate@1.1.4",
+ Algo: "sha1",
+ Hex: "36fa7d009e22618ca7cd599486979b8150596c74",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ParsePin(tt.entry)
+ require.True(t, ok)
+ assert.Equal(t, tt.want, got)
+ // Round-trip: serializing the parsed pin reproduces the entry.
+ assert.Equal(t, tt.entry, got.String())
+ })
+ }
+}
+
+func TestParsePin_Invalid(t *testing.T) {
+ tests := []struct {
+ name string
+ entry string
+ }{
+ {"empty", ""},
+ {"missing at", "actions/checkout:sha1-abc123"},
+ {"missing colon", "actions/checkout@v4sha1-abc123"},
+ {"missing algo dash", "actions/checkout@v4:sha1abc123"},
+ {"ref contains colon", "actions/checkout@refs/tags/v1:prod:sha1-11bd71901bbe5b1630ceea73d27597364c9af683"},
+ {"unsupported algo", "actions/checkout@v4:md5-098f6bcd4621d373cade4e832627b4f6"},
+ {"sha1 too short", "actions/checkout@v4:sha1-abc123"},
+ {"empty owner", "/checkout@v4:sha1-abc123"},
+ {"empty repo", "actions/@v4:sha1-abc123"},
+ {"sub-action path rejected", "actions/cache/save@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683"},
+ {"deep sub-action path rejected", "owner/repo/a/b@v1:sha1-11bd71901bbe5b1630ceea73d27597364c9af683"},
+ {"with metadata suffix (pins are pure identity)", "actions/checkout@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683;owner_id=1234"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ParsePin(tt.entry)
+ assert.False(t, ok)
+ assert.Equal(t, Pin{}, got)
+
+ })
+ }
+}
+
+func TestIndexKey(t *testing.T) {
+ assert.Equal(t, "actions/checkout@v4", IndexKey("actions", "checkout", "v4"))
+}
+
+func TestPin_IndexKey(t *testing.T) {
+ tests := []struct {
+ name string
+ pin Pin
+ want string
+ }{
+ {
+ name: "owner repo",
+ pin: Pin{Owner: "actions", Repo: "checkout", Ref: "v4"},
+ want: "actions/checkout@v4",
+ },
+ {
+ name: "mixed case is lowercased",
+ pin: Pin{Owner: "Actions", Repo: "Checkout", Ref: "v4"},
+ want: "actions/checkout@v4",
+ },
+ {
+ name: "empty ref",
+ pin: Pin{Owner: "actions", Repo: "checkout"},
+ want: "actions/checkout@",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, tt.pin.IndexKey())
+ })
+ }
+}
diff --git a/go/pkg/lockfile/schema.go b/go/pkg/lockfile/schema.go
new file mode 100644
index 0000000..73a5726
--- /dev/null
+++ b/go/pkg/lockfile/schema.go
@@ -0,0 +1,15 @@
+package lockfile
+
+//go:generate go run ./internal/cmd/genschema
+
+// Schema returns the embedded JSON Schema document for the supported lockfile
+// version. Callers can surface it for editor integration or external
+// validation. Parse checks the document's shape — known keys, required fields,
+// version — and canonicalizes pin keys, but does not reject entries whose keys
+// aren't canonical pins; they're preserved for consumer diagnostics. Note the
+// schema's pin pattern constrains pin *values* (the workflow and uses arrays),
+// not the dependencies map keys, so schema validation alone won't enforce that
+// every dependency key is a canonical pin either.
+func Schema() string {
+ return schemaV001
+}
diff --git a/go/pkg/lockfile/schema_gen.go b/go/pkg/lockfile/schema_gen.go
new file mode 100644
index 0000000..25f1178
--- /dev/null
+++ b/go/pkg/lockfile/schema_gen.go
@@ -0,0 +1,5 @@
+// Code generated by go generate; DO NOT EDIT.
+
+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-pin`.\",\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-34e1...).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+:(sha1-[0-9a-f]{40}|sha256-[0-9a-f]{64})$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"branch\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"tag\": {\n \"description\": \"The release or tag the commit was published as, if any. Optional: not every pinned commit corresponds to a tag.\",\n \"type\": \"string\"\n },\n \"branch\": {\n \"description\": \"A branch in the action's repository that contains the pinned commit. Required: it is the authenticity check. A legitimate release commit lives on a branch, while a commit that exists only as a dangling object — pushed to a fork or attached to a PR and never merged — belongs to no branch. Pinning by SHA alone can be tricked into trusting such an impostor commit; verifying the commit is reachable from a branch closes that gap.\",\n \"type\": \"string\"\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 },\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 },\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
new file mode 100644
index 0000000..bea61b2
--- /dev/null
+++ b/go/pkg/lockfile/schema_test.go
@@ -0,0 +1,139 @@
+package lockfile
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSchema_EmbeddedMatchesRootInvariant(t *testing.T) {
+ rootSchema, err := os.ReadFile("../../../schema/lockfile-v0.0.1.json")
+ if errors.Is(err, os.ErrNotExist) {
+ t.Skip("root schema invariant is not present in this module checkout")
+ }
+ require.NoError(t, err)
+ assert.JSONEq(t, string(rootSchema), Schema())
+}
+
+// TestSchema_EmbeddedMatchesEnforcement guards against drift between the
+// published JSON Schema and the keys Parse actually enforces. The schema file
+// is the contract; validateKnownFields is the engine. If they disagree, the
+// lockfile would either silently accept fields the schema forbids or reject
+// fields it permits.
+func TestSchema_EmbeddedMatchesEnforcement(t *testing.T) {
+ var doc struct {
+ Properties map[string]struct {
+ Const string `json:"const"`
+ } `json:"properties"`
+ Defs struct {
+ Action struct {
+ Required []string `json:"required"`
+ Properties map[string]json.RawMessage `json:"properties"`
+ } `json:"action"`
+ } `json:"$defs"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(Schema()), &doc), "embedded schema must be valid JSON")
+
+ require.Equal(t, Version, doc.Properties["version"].Const,
+ "schema version const must equal the supported Version")
+
+ for key := range doc.Properties {
+ _, ok := allowedFileKeys[key]
+ assert.Truef(t, ok, "schema declares top-level %q but enforcement does not allow it", key)
+ }
+ for key := range allowedFileKeys {
+ _, ok := doc.Properties[key]
+ assert.Truef(t, ok, "enforcement allows top-level %q but schema does not declare it", key)
+ }
+
+ for key := range doc.Defs.Action.Properties {
+ _, ok := allowedActionKeys[key]
+ assert.Truef(t, ok, "schema declares action field %q but enforcement does not allow it", key)
+ }
+ for key := range allowedActionKeys {
+ _, ok := doc.Defs.Action.Properties[key]
+ assert.Truef(t, ok, "enforcement allows action field %q but schema does not declare it", key)
+ }
+
+ assert.ElementsMatch(t, doc.Defs.Action.Required, requiredActionKeys,
+ "schema action.required must match the keys enforcement requires")
+}
+
+func TestParse_UnknownTopLevelFieldRejected(t *testing.T) {
+ yaml := `version: v0.0.1
+dependencies: {}
+typo_section: {}
+`
+ _, 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, `unknown lockfile field "typo_section"`)
+ assert.Equal(t, 3, pe.Line, "unknown key is on line 3")
+ assert.Greater(t, pe.Column, 0, "expected a column anchored on the offending key")
+}
+
+func TestParse_UnknownActionFieldRejected(t *testing.T) {
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5:
+ owner_id: 1
+ repo_id: 2
+ flavor: spicy
+`
+ _, 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, `unknown action field "flavor"`)
+ assert.Contains(t, pe.Msg, "actions/checkout@v4", "message should name the offending dependency")
+ assert.Equal(t, 6, pe.Line, "unknown action key is on line 6")
+ assert.Greater(t, pe.Column, 0, "expected a column anchored on the offending key")
+}
+
+func TestParse_MissingRequiredActionFieldRejected(t *testing.T) {
+ // owner_id/repo_id/commit present, but branch (required) is absent.
+ yaml := `version: v0.0.1
+dependencies:
+ actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5:
+ 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 "branch"`)
+ assert.Contains(t, pe.Msg, "actions/checkout@v4", "message should name the offending dependency")
+ assert.Equal(t, 3, pe.Line, "error anchors on the dependency's pin key")
+ assert.Greater(t, pe.Column, 0, "expected a column anchored on the pin key")
+}
+
+func TestParse_KnownFieldsAccepted(t *testing.T) {
+ yaml := `version: v0.0.1
+workflows:
+ .github/workflows/ci.yml:
+ - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5
+dependencies:
+ actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5:
+ tag: v4
+ branch: main
+ commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5
+ owner_id: 1
+ repo_id: 2
+ uses:
+ - actions/cache@v4:sha1-0000000000000000000000000000000000000000
+`
+ f, err := Parse([]byte(yaml))
+ require.NoError(t, err)
+ assert.Len(t, f.Dependencies, 1)
+ assert.Contains(t, f.Workflows, ".github/workflows/ci.yml")
+}
diff --git a/go/pkg/lockfile/testdata b/go/pkg/lockfile/testdata
new file mode 120000
index 0000000..647805e
--- /dev/null
+++ b/go/pkg/lockfile/testdata
@@ -0,0 +1 @@
+../../../testdata
\ No newline at end of file
diff --git a/go/pkg/lockfile/uses.go b/go/pkg/lockfile/uses.go
new file mode 100644
index 0000000..c10dde9
--- /dev/null
+++ b/go/pkg/lockfile/uses.go
@@ -0,0 +1,327 @@
+package lockfile
+
+import "strings"
+
+// ActionRef is a parsed `uses:` reference to a repository action.
+// It captures only the components the lockfile grammar cares about:
+// owner, repo, optional sub-action path, ref string, and the original
+// raw value for diagnostics.
+//
+// ParseActionRef is the only constructor; consumers should treat zero
+// values as invalid.
+type ActionRef struct {
+ Owner string // e.g. "actions"
+ Repo string // e.g. "checkout"
+ Path string // e.g. "save" for actions/cache/save@v4
+ Ref string // tag, branch, or full SHA as written after `@`
+ Raw string // original `uses:` string (post-trim)
+}
+
+// NWO returns owner/repo (Name With Owner). Zero-value ActionRefs return
+// the empty string.
+func (a ActionRef) NWO() string {
+ if a.Owner == "" && a.Repo == "" {
+ return ""
+ }
+ return a.Owner + "/" + a.Repo
+}
+
+// FullName returns owner/repo or owner/repo/path. Used for human-facing
+// display and for graph traversal where distinct sub-paths must be
+// treated as distinct nodes.
+func (a ActionRef) FullName() string {
+ if a.Path != "" {
+ return a.Owner + "/" + a.Repo + "/" + a.Path
+ }
+ return a.Owner + "/" + a.Repo
+}
+
+// ParseActionRef parses a `uses:` string into an ActionRef. It returns
+// nil for any input that is not a repository action — expression-based
+// refs, local paths, docker images, reusable workflow files, or any
+// input whose owner/repo/path/ref components are unsafe to hand to the
+// downstream URL/GraphQL builders (control characters, traversal tokens,
+// or quote/whitespace metacharacters in the ref).
+//
+// The returned pointer is non-nil iff the input names a real repository
+// action (composite or javascript) at owner/repo[/path]@ref.
+func ParseActionRef(uses string) *ActionRef {
+ parsed := splitUsesRef(uses)
+ if parsed == nil {
+ return nil
+ }
+ // Reject anything that lives under .github/workflows/ as a YAML file —
+ // directly or nested. Nothing valid as a repository action lives there;
+ // the direct-child form is a reusable workflow (use
+ // ParseReusableWorkflowRef), and a nested form is malformed either way.
+ if isWorkflowFile(parsed.Path) {
+ return nil
+ }
+ return &ActionRef{
+ Owner: parsed.Owner,
+ Repo: parsed.Repo,
+ Path: parsed.Path,
+ Ref: parsed.Ref,
+ Raw: parsed.Raw,
+ }
+}
+
+// usesRef is the unexported carrier for the components splitUsesRef extracts
+// before classification. It deliberately is NOT ActionRef: a freshly split ref
+// may turn out to be a reusable workflow, so handing back an ActionRef (whose
+// contract is "a repository action") would be a lie until the caller has run
+// the workflow-file check.
+type usesRef struct {
+ Owner string
+ Repo string
+ Path string
+ Ref string
+ Raw string
+}
+
+// splitUsesRef performs the parse shared by ParseActionRef and
+// ParseReusableWorkflowRef: prefilter the input, split at the FIRST `@`
+// (so a ref that itself contains `@` — e.g. a branch named "release@2024"
+// in owner/repo/.github/workflows/ci.yml@release@2024 — keeps the whole
+// "release@2024" as the ref), then validate the ref and the
+// owner/repo[/path] segments against the security boundary.
+//
+// It returns the parsed components, or nil if the input is not a valid
+// owner/repo[/path]@ref shape. It does NOT classify the result as action vs
+// reusable workflow; that is left to the caller.
+func splitUsesRef(uses string) *usesRef {
+ uses = strings.TrimSpace(uses)
+
+ if uses == "" || containsControlChars(uses) {
+ return nil
+ }
+ if strings.HasPrefix(uses, "./") {
+ return nil
+ }
+ if strings.HasPrefix(uses, "docker://") {
+ return nil
+ }
+ if strings.Contains(uses, "${") {
+ return nil
+ }
+
+ atParts := strings.SplitN(uses, "@", 2)
+ if len(atParts) != 2 || atParts[1] == "" {
+ return nil
+ }
+ ref := atParts[1]
+ if !isValidRef(ref) {
+ return nil
+ }
+
+ segments := strings.SplitN(atParts[0], "/", 3)
+ if len(segments) < 2 || segments[0] == "" || segments[1] == "" {
+ return nil
+ }
+ if !isValidSegment(segments[0]) || !isValidSegment(segments[1]) {
+ return nil
+ }
+
+ parsed := &usesRef{
+ Owner: segments[0],
+ Repo: segments[1],
+ Ref: ref,
+ Raw: uses,
+ }
+ if len(segments) == 3 {
+ if !isValidPath(segments[2]) {
+ return nil
+ }
+ parsed.Path = segments[2]
+ }
+
+ return parsed
+}
+
+// ReusableWorkflowRef is a parsed `uses:` reference to a reusable workflow
+// file — the owner/repo/.github/workflows/.yml@ref shape that
+// ParseActionRef deliberately rejects. It carries the same components as
+// ActionRef; Path is the in-repo workflow file path (e.g.
+// ".github/workflows/release.yml"), and Ref is the full ref as written
+// after the FIRST `@`, so a ref containing `@` survives intact.
+//
+// ParseReusableWorkflowRef is the only constructor; treat zero values as
+// invalid.
+type ReusableWorkflowRef struct {
+ Owner string // e.g. "octo"
+ Repo string // e.g. "workflows"
+ Path string // e.g. ".github/workflows/release.yml"
+ Ref string // tag, branch, or full SHA as written after `@`
+ Raw string // original `uses:` string (post-trim)
+}
+
+// NWO returns owner/repo (Name With Owner). Zero-value refs return the
+// empty string.
+func (r ReusableWorkflowRef) NWO() string {
+ if r.Owner == "" && r.Repo == "" {
+ return ""
+ }
+ return r.Owner + "/" + r.Repo
+}
+
+// FullName returns owner/repo/path — the fully-qualified reusable workflow
+// identity.
+func (r ReusableWorkflowRef) FullName() string {
+ return r.Owner + "/" + r.Repo + "/" + r.Path
+}
+
+// ParseReusableWorkflowRef parses the *remote* reusable-workflow `uses:` shape
+// that ParseActionRef rejects: owner/repo/.github/workflows/.yml@ref. It
+// returns nil for anything that is not a remote reusable workflow — repository
+// actions, expression refs, docker images, or any input whose components are
+// unsafe for the downstream URL/GraphQL builders.
+//
+// It deliberately rejects LOCAL reusable workflows (./.github/workflows/...);
+// those have no owner/repo and a different resolution path. Use
+// IsLocalReusableWorkflow for that shape. It also rejects nested paths such as
+// .github/workflows/sub/ci.yml: GitHub reusable workflows live directly under
+// .github/workflows/, so only a single file segment is accepted.
+//
+// It is the mirror of ParseActionRef for the reusable shape, and shares the
+// same first-`@` split and security validation. Downstream consumers that
+// must derive a reusable workflow's repository and file path (e.g. to locate
+// that repo's detached lockfile) should use this rather than hand-rolling the
+// split: a naive last-`@` split mis-parses refs that contain `@`.
+//
+// The returned pointer is non-nil iff the input names a reusable workflow
+// file at owner/repo/.github/workflows/.{yml,yaml}@ref.
+func ParseReusableWorkflowRef(uses string) *ReusableWorkflowRef {
+ parsed := splitUsesRef(uses)
+ if parsed == nil {
+ return nil
+ }
+ if !isReusableWorkflow(parsed.Path) {
+ return nil
+ }
+ return &ReusableWorkflowRef{
+ Owner: parsed.Owner,
+ Repo: parsed.Repo,
+ Path: parsed.Path,
+ Ref: parsed.Ref,
+ Raw: parsed.Raw,
+ }
+}
+
+// isValidSegment enforces the GitHub character set for owner names, repository
+// names, and action path segments. GitHub allows alphanumerics, hyphens,
+// underscores, and periods; reject anything else to keep these values safe for
+// use in URL paths and GraphQL string literals without per-call escaping bugs.
+// The whole-segment values "." and ".." are rejected too — they aren't valid
+// segments anyway.
+func isValidSegment(s string) bool {
+ if s == "" || s == "." || s == ".." {
+ return false
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= 'A' && r <= 'Z':
+ case r >= '0' && r <= '9':
+ case r == '-' || r == '_' || r == '.':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// isValidRef gates the ref (the part after @). Git refs are permissive —
+// slashes, dots, plus signs, even an embedded @ (foo/bar@a@b -> ref "a@b") —
+// so this is a denylist, not an allowlist. It rejects what cannot survive
+// interpolation: whitespace, quotes, backtick, backslash, and the ".."
+// sequence (which git itself forbids, so no valid ref is lost). It does NOT
+// make the ref URL-path-safe; refs hold slashes. Escape downstream.
+func isValidRef(ref string) bool {
+ if ref == "" {
+ return false
+ }
+ if strings.Contains(ref, "..") {
+ return false
+ }
+ for _, r := range ref {
+ switch r {
+ case ' ', '\t', '\n', '\r', '\v', '\f', '"', '\'', '`', '\\':
+ return false
+ }
+ }
+ return true
+}
+
+func containsControlChars(s string) bool {
+ for _, c := range s {
+ if c <= 0x1F || c == 0x7F {
+ return true
+ }
+ }
+ return false
+}
+
+func isYAMLFile(path string) bool {
+ return strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml")
+}
+
+// isValidPath validates the subdirectory path segment of a uses: reference.
+// Each segment is validated by isValidSegment, which enforces the GitHub
+// character set and rejects the "." / ".." traversal tokens. This extends the
+// security boundary from owner/repo into the path component.
+func isValidPath(p string) bool {
+ if p == "" {
+ return false
+ }
+ for _, seg := range strings.Split(p, "/") {
+ if !isValidSegment(seg) {
+ return false
+ }
+ }
+ return true
+}
+
+// isWorkflowFile reports whether a uses: path points at a YAML file anywhere
+// under .github/workflows/ (directly or nested). ParseActionRef uses it to
+// reject such paths wholesale: a repository action never lives there. The
+// direct-child form is a reusable workflow; a nested form is malformed. This
+// is intentionally broader than isReusableWorkflow.
+//
+// Anchor on prefix: substring matching would misclassify composite actions
+// whose nested folder happens to contain that segment (e.g.
+// tools/.github/workflows/).
+func isWorkflowFile(path string) bool {
+ if path == "" {
+ return false
+ }
+ if !strings.HasPrefix(path, ".github/workflows/") {
+ return false
+ }
+ return isYAMLFile(path)
+}
+
+// isReusableWorkflow reports whether a uses: path names a reusable workflow
+// file: a single YAML file directly under .github/workflows/. GitHub reusable
+// workflows live there and nowhere deeper, so a nested path like
+// .github/workflows/sub/ci.yml is NOT a reusable workflow. This is the strict
+// classifier ParseReusableWorkflowRef accepts; ParseActionRef rejects the
+// broader isWorkflowFile set, so the two parsers never both accept an input.
+func isReusableWorkflow(path string) bool {
+ const prefix = ".github/workflows/"
+ if !strings.HasPrefix(path, prefix) {
+ return false
+ }
+ name := path[len(prefix):]
+ if name == "" || strings.Contains(name, "/") {
+ return false
+ }
+ return isYAMLFile(name)
+}
+
+// IsLocalReusableWorkflow reports whether a `./...`-prefixed local
+// `uses:` value names a reusable workflow file (rather than a local
+// composite action directory). Exposed for consumers that walk
+// workflows themselves and need to distinguish the two shapes.
+func IsLocalReusableWorkflow(localPath string) bool {
+ return isYAMLFile(localPath)
+}
diff --git a/go/pkg/lockfile/uses_test.go b/go/pkg/lockfile/uses_test.go
new file mode 100644
index 0000000..d42f1d5
--- /dev/null
+++ b/go/pkg/lockfile/uses_test.go
@@ -0,0 +1,215 @@
+package lockfile
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseActionRef(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantNil bool
+ wantNWO string
+ wantPath string
+ wantRef string
+ }{
+ {name: "simple action", input: "actions/checkout@v4", wantNWO: "actions/checkout", wantRef: "v4"},
+ {name: "path action", input: "actions/cache/save@v4", wantNWO: "actions/cache", wantPath: "save", wantRef: "v4"},
+ {name: "SHA ref", input: "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", wantNWO: "actions/checkout", wantRef: "11bd71901bbe5b1630ceea73d27597364c9af683"},
+ {name: "local path action", input: "./local-action", wantNil: true},
+ {name: "docker action", input: "docker://alpine:3.18", wantNil: true},
+ {name: "expression-based ref", input: "${{ matrix.action }}", wantNil: true},
+ {name: "no ref", input: "actions/checkout", wantNil: true},
+ {name: "empty ref", input: "actions/checkout@", wantNil: true},
+ {name: "single segment", input: "checkout@v4", wantNil: true},
+ {name: "reusable workflow yml", input: "owner/repo/.github/workflows/called.yml@v1", wantNil: true},
+ {name: "reusable workflow yaml", input: "owner/repo/.github/workflows/called.yaml@main", wantNil: true},
+ {name: "path action that is not a reusable workflow", input: "owner/repo/some/path@v1", wantNWO: "owner/repo", wantPath: "some/path", wantRef: "v1"},
+ {name: "whitespace trimmed", input: " actions/checkout@v4 ", wantNWO: "actions/checkout", wantRef: "v4"},
+ {name: "empty owner segment", input: "/repo@v1", wantNil: true},
+ {name: "empty name segment", input: "owner/@v1", wantNil: true},
+ {name: "leading slash both empty", input: "/@v1", wantNil: true},
+ {name: "owner with newline injection", input: "actions\n/checkout@v1", wantNil: true},
+ {name: "owner with quote", input: `actions"/checkout@v1`, wantNil: true},
+ {name: "owner with space", input: "actions /checkout@v1", wantNil: true},
+ {name: "control char tab embedded", input: "actions/check\tout@v1", wantNil: true},
+ {name: "nested folder containing reusable workflow path is not reusable", input: "owner/repo/tools/.github/workflows/x.yml@v1", wantNWO: "owner/repo", wantPath: "tools/.github/workflows/x.yml", wantRef: "v1"},
+ {name: "path with space", input: "owner/repo/bad path@v1", wantNil: true},
+ {name: "path with quotes", input: `owner/repo/bad"path@v1`, wantNil: true},
+ {name: "path dotdot traversal", input: "owner/repo/../etc@v1", wantNil: true},
+ {name: "path single dot segment", input: "owner/repo/./foo@v1", wantNil: true},
+ {name: "path empty segment double slash", input: "owner/repo/a//b@v1", wantNil: true},
+ {name: "ref containing @", input: "foo/bar@a@b", wantNWO: "foo/bar", wantRef: "a@b"},
+ {name: "dotdot owner segment", input: "../repo@v1", wantNil: true},
+ {name: "dotdot repo segment", input: "foo/..@v1", wantNil: true},
+ {name: "dot repo segment", input: "foo/.@v1", wantNil: true},
+ {name: "dot owner segment", input: "./repo@v1", wantNil: true},
+ {name: "ref with double quote", input: `a/b@v1"inj`, wantNil: true},
+ {name: "ref with space", input: "a/b@v1 space", wantNil: true},
+ {name: "ref with single quote", input: "a/b@v1'inj", wantNil: true},
+ {name: "ref with backtick", input: "a/b@v1`inj", wantNil: true},
+ {name: "ref with backslash", input: `a/b@v1\inj`, wantNil: true},
+ {name: "ref with dotdot traversal", input: "a/b@heads/../../x", wantNil: true},
+ {name: "ref with consecutive dots", input: "a/b@v1..v2", wantNil: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ParseActionRef(tt.input)
+ if tt.wantNil {
+ require.Nil(t, got)
+ return
+ }
+ require.NotNil(t, got)
+ assert.Equal(t, tt.wantNWO, got.NWO())
+ assert.Equal(t, tt.wantPath, got.Path)
+ assert.Equal(t, tt.wantRef, got.Ref)
+ })
+ }
+}
+
+func TestActionRefNWO(t *testing.T) {
+ assert.Equal(t, "actions/checkout", ActionRef{Owner: "actions", Repo: "checkout"}.NWO())
+ assert.Equal(t, "", ActionRef{}.NWO())
+}
+
+func TestActionRefFullName(t *testing.T) {
+ assert.Equal(t, "actions/checkout", ActionRef{Owner: "actions", Repo: "checkout"}.FullName())
+ assert.Equal(t, "actions/cache/save", ActionRef{Owner: "actions", Repo: "cache", Path: "save"}.FullName())
+}
+
+func TestParseReusableWorkflowRef(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantNil bool
+ wantNWO string
+ wantPath string
+ wantRef string
+ }{
+ {name: "simple reusable yml", input: "octo/repo/.github/workflows/ci.yml@v1", wantNWO: "octo/repo", wantPath: ".github/workflows/ci.yml", wantRef: "v1"},
+ {name: "reusable yaml extension", input: "octo/repo/.github/workflows/ci.yaml@main", wantNWO: "octo/repo", wantPath: ".github/workflows/ci.yaml", wantRef: "main"},
+ {name: "reusable pinned to sha", input: "octo/repo/.github/workflows/ci.yml@11bd71901bbe5b1630ceea73d27597364c9af683", wantNWO: "octo/repo", wantPath: ".github/workflows/ci.yml", wantRef: "11bd71901bbe5b1630ceea73d27597364c9af683"},
+ // The bug this helper exists to prevent: a ref that itself contains
+ // `@`. A naive last-`@` split would mis-derive the path; the first-`@`
+ // split keeps the whole ref intact.
+ {name: "ref containing at sign", input: "octo/repo/.github/workflows/ci.yml@release@2024", wantNWO: "octo/repo", wantPath: ".github/workflows/ci.yml", wantRef: "release@2024"},
+ {name: "ref containing slash", input: "octo/repo/.github/workflows/ci.yml@feature/foo", wantNWO: "octo/repo", wantPath: ".github/workflows/ci.yml", wantRef: "feature/foo"},
+ // Non-reusable shapes return nil.
+ {name: "repository action is not reusable", input: "actions/checkout@v4", wantNil: true},
+ {name: "path action is not reusable", input: "actions/cache/save@v4", wantNil: true},
+ {name: "non-yaml file in workflows dir", input: "octo/repo/.github/workflows/ci.txt@v1", wantNil: true},
+ {name: "nested workflows path not at prefix", input: "octo/repo/tools/.github/workflows/ci.yml@v1", wantNil: true},
+ {name: "nested under workflows dir", input: "octo/repo/.github/workflows/sub/ci.yml@v1", wantNil: true},
+ {name: "no path", input: "octo/repo@v1", wantNil: true},
+ // Security boundary still applies.
+ {name: "local reusable workflow", input: "./.github/workflows/ci.yml@v1", wantNil: true},
+ {name: "expression ref", input: "${{ matrix.wf }}@v1", wantNil: true},
+ {name: "ref injection double quote", input: `octo/repo/.github/workflows/ci.yml@v1"inj`, wantNil: true},
+ {name: "ref injection space", input: "octo/repo/.github/workflows/ci.yml@v1 x", wantNil: true},
+ {name: "path traversal in workflow path", input: "octo/repo/.github/workflows/../../x.yml@v1", wantNil: true},
+ {name: "empty ref", input: "octo/repo/.github/workflows/ci.yml@", wantNil: true},
+ {name: "control char in path", input: "octo/repo/.github/workflows/ci\t.yml@v1", wantNil: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ParseReusableWorkflowRef(tt.input)
+ if tt.wantNil {
+ require.Nil(t, got)
+ return
+ }
+ require.NotNil(t, got)
+ assert.Equal(t, tt.wantNWO, got.NWO())
+ assert.Equal(t, tt.wantPath, got.Path)
+ assert.Equal(t, tt.wantRef, got.Ref)
+ })
+ }
+}
+
+func TestReusableWorkflowRefNames(t *testing.T) {
+ r := ReusableWorkflowRef{Owner: "octo", Repo: "repo", Path: ".github/workflows/ci.yml", Ref: "v1"}
+ assert.Equal(t, "octo/repo", r.NWO())
+ assert.Equal(t, "octo/repo/.github/workflows/ci.yml", r.FullName())
+ assert.Equal(t, "", ReusableWorkflowRef{}.NWO())
+}
+
+func TestIsReusableWorkflow(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ want bool
+ }{
+ {name: "reusable workflow yml", path: ".github/workflows/called.yml", want: true},
+ {name: "reusable workflow yaml", path: ".github/workflows/called.yaml", want: true},
+ {name: "regular path action", path: "save", want: false},
+ {name: "no path", path: "", want: false},
+ {name: "nested under workflows dir", path: ".github/workflows/sub/called.yml", want: false},
+ {name: "non-yaml under workflows dir", path: ".github/workflows/called.txt", want: false},
+ {name: "workflows dir prefix only", path: ".github/workflows/", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, isReusableWorkflow(tt.path))
+ })
+ }
+}
+
+// TestActionAndReusableParsersAreMutuallyExclusive locks in the security
+// invariant: no single uses: string is accepted by both ParseActionRef and
+// ParseReusableWorkflowRef.
+func TestActionAndReusableParsersAreMutuallyExclusive(t *testing.T) {
+ inputs := []string{
+ "actions/checkout@v4",
+ "actions/cache/save@v4",
+ "octo/repo/.github/workflows/ci.yml@v1",
+ "octo/repo/.github/workflows/ci.yaml@main",
+ "octo/repo/.github/workflows/ci.yml@release@2024",
+ "octo/repo/.github/workflows/sub/ci.yml@v1",
+ "octo/repo/.github/workflows/ci.txt@v1",
+ "./local@v1",
+ "docker://alpine:3.18",
+ "${{ matrix.x }}@v1",
+ "owner/repo/tools/.github/workflows/x.yml@v1",
+ }
+ for _, in := range inputs {
+ t.Run(in, func(t *testing.T) {
+ a := ParseActionRef(in)
+ r := ParseReusableWorkflowRef(in)
+ if a != nil && r != nil {
+ t.Fatalf("both parsers accepted %q (action=%+v reusable=%+v)", in, a, r)
+ }
+ })
+ }
+}
+
+func TestIsFullSha(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ {name: "valid lowercase sha", input: "11bd71901bbe5b1630ceea73d27597364c9af683", want: true},
+ {name: "valid uppercase sha", input: "11BD71901BBE5B1630CEEA73D27597364C9AF683", want: true},
+ {name: "valid mixed case sha", input: "11bd71901BBE5b1630ceea73d27597364C9AF683", want: true},
+ {name: "too short", input: "11bd71901bbe5b1630ceea73d2759736", want: false},
+ {name: "too long", input: "11bd71901bbe5b1630ceea73d27597364c9af683aa", want: false},
+ {name: "tag ref", input: "v4", want: false},
+ {name: "branch ref", input: "main", want: false},
+ {name: "empty", input: "", want: false},
+ {name: "non-hex chars", input: "ggbd71901bbe5b1630ceea73d27597364c9af683", want: false},
+ {name: "sha256 length", input: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", want: true},
+ {name: "41 chars not valid", input: "11bd71901bbe5b1630ceea73d27597364c9af683a", want: false},
+ {name: "63 chars not valid", input: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsFullSha(tt.input))
+ })
+ }
+}
diff --git a/go/pkg/lockfile/version.go b/go/pkg/lockfile/version.go
new file mode 100644
index 0000000..734bcab
--- /dev/null
+++ b/go/pkg/lockfile/version.go
@@ -0,0 +1,152 @@
+package lockfile
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+)
+
+// SemVer holds parsed semantic version components.
+//
+// API stability: the type and its comparison helpers (Greater, Narrows,
+// UpgradeOver, MajorTag, MinorTag, IsFull) are part of the exported surface
+// because the tag recommendation engine in downstream consumers relies on
+// them. Their semantics are deliberately non-strict-semver (see below) and are
+// committed to as-is; they are not internal helpers despite the
+// recommendation-engine flavor.
+//
+// GitHub Actions has no first-class version scheme — a uses: ref can be
+// any git ref (tag, branch, SHA, or even "main"). In practice most
+// action authors follow semver-ish conventions, but the ecosystem
+// diverges from strict semver in ways that golang.org/x/mod/semver
+// cannot handle: bare versions without a "v" prefix ("2.0.0"), partial
+// versions ("v4", "v4.2"), and arbitrary suffixes all appear in the
+// wild. x/mod/semver rejects bare and partial tags, and doesn't expose
+// individual components — we need Major/Minor/Patch to compute
+// MajorTag, MinorTag, and IsFull for the tag recommendation engine.
+type SemVer struct {
+ Prefix string // "v" or ""
+ Major int
+ Minor int
+ Patch int
+ Rest string // anything after patch (e.g. "-beta.1")
+ Raw string
+}
+
+var versionRE = regexp.MustCompile(`^(v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$`)
+
+// ParseSemVer parses a version tag into its components. Returns false if the
+// tag doesn't look like a version (or is a full SHA that happens to start with
+// a digit).
+func ParseSemVer(tag string) (SemVer, bool) {
+ if IsFullSha(tag) {
+ return SemVer{}, false
+ }
+ m := versionRE.FindStringSubmatch(tag)
+ if m == nil {
+ return SemVer{}, false
+ }
+ major, err := strconv.Atoi(m[2])
+ if err != nil {
+ return SemVer{}, false
+ }
+ minor := 0
+ if m[3] != "" {
+ if minor, err = strconv.Atoi(m[3]); err != nil {
+ return SemVer{}, false
+ }
+ }
+ patch := 0
+ if m[4] != "" {
+ if patch, err = strconv.Atoi(m[4]); err != nil {
+ return SemVer{}, false
+ }
+ }
+ return SemVer{
+ Prefix: m[1],
+ Major: major,
+ Minor: minor,
+ Patch: patch,
+ Rest: m[5],
+ Raw: tag,
+ }, true
+}
+
+// MajorTag returns the major-only tag string (e.g. "v4").
+func (s SemVer) MajorTag() string { return fmt.Sprintf("%s%d", s.Prefix, s.Major) }
+
+// MinorTag returns the major.minor tag string (e.g. "v4.2").
+func (s SemVer) MinorTag() string { return fmt.Sprintf("%s%d.%d", s.Prefix, s.Major, s.Minor) }
+
+// Greater reports whether s should be preferred over o: higher
+// major.minor.patch wins; on a tie a stable version beats a pre-release, a
+// v-prefixed tag beats the same bare version, then a lexicographic compare of
+// the raw tags provides a deterministic final tie-break.
+func (s SemVer) Greater(o SemVer) bool {
+ sv := [3]int{s.Major, s.Minor, s.Patch}
+ ov := [3]int{o.Major, o.Minor, o.Patch}
+ for i := 0; i < 3; i++ {
+ if sv[i] != ov[i] {
+ return sv[i] > ov[i]
+ }
+ }
+ if s.IsStable() != o.IsStable() {
+ return s.IsStable()
+ }
+ if (s.Prefix == "v") != (o.Prefix == "v") {
+ return s.Prefix == "v"
+ }
+ return s.Raw > o.Raw
+}
+
+// IsStable returns true if the tag has no pre-release suffix or trailing junk.
+func (s SemVer) IsStable() bool { return s.Rest == "" }
+
+// IsFull returns true if the version has all three components
+// (major.minor.patch) and no pre-release suffix. Tags like "v4" or "v4.2"
+// return false.
+func (s SemVer) IsFull() bool {
+ return s.Rest == "" && s.Raw != s.MajorTag() && s.Raw != s.MinorTag()
+}
+
+// IsMutable reports whether this version is a partial (major-only or
+// major.minor) tag that should be narrowed to a specific patch version.
+func (s SemVer) IsMutable() bool { return !s.IsFull() }
+
+// IsMajorOnly reports whether the raw tag is a bare major version (e.g. "v4").
+func (s SemVer) IsMajorOnly() bool { return s.Raw == s.MajorTag() }
+
+// Narrows reports whether s is a more specific patch version of other.
+// e.g. other="v4", s="v4.1.0" → true; other="v4.2", s="v4.2.1" → true.
+func (s SemVer) Narrows(other SemVer) bool {
+ if !s.IsFull() || s.Major != other.Major {
+ return false
+ }
+ if !other.IsMajorOnly() && other.Minor != s.Minor {
+ return false
+ }
+ return true
+}
+
+// UpgradeOver reports whether s represents a real version upgrade over other.
+// Returns false for noops where other is already at or more specific than s.
+func (s SemVer) UpgradeOver(other SemVer) bool {
+ if s.Rest != "" {
+ return false
+ }
+ if s.Major < other.Major {
+ return false
+ }
+ if s.Major == other.Major {
+ if s.IsMajorOnly() {
+ return false
+ }
+ if s.Minor < other.Minor {
+ return false
+ }
+ if s.Minor == other.Minor && s.Patch <= other.Patch {
+ return false
+ }
+ }
+ return true
+}
diff --git a/go/pkg/lockfile/version_test.go b/go/pkg/lockfile/version_test.go
new file mode 100644
index 0000000..b214c31
--- /dev/null
+++ b/go/pkg/lockfile/version_test.go
@@ -0,0 +1,134 @@
+package lockfile
+
+import "testing"
+
+func TestParseSemver_RejectsOverflow(t *testing.T) {
+ overflow := []string{
+ "99999999999999999999.0.0",
+ "v1.99999999999999999999.0",
+ "v1.0.99999999999999999999",
+ }
+ for _, tag := range overflow {
+ if _, ok := ParseSemVer(tag); ok {
+ t.Errorf("ParseSemVer(%q) = ok; want rejected", tag)
+ }
+ }
+}
+
+func TestVersion_Greater(t *testing.T) {
+ tests := []struct {
+ name string
+ a, b string
+ want bool // a.Greater(b)
+ }{
+ {"higher patch", "v1.2.4", "v1.2.3", true},
+ {"lower patch", "v1.2.3", "v1.2.4", false},
+ {"higher major beats lower v-prefix mismatch", "2.0.0", "v1.0.0", true},
+ {"bare equals v on version, v wins", "1.2.3", "v1.2.3", false},
+ {"v beats bare on tie", "v1.2.3", "1.2.3", true},
+ {"stable beats prerelease", "v1.2.3", "v1.2.3-rc.1", true},
+ {"prerelease loses to stable", "v1.2.3-rc.1", "v1.2.3", false},
+ {"partial v1 vs full v1.0.0 tie, lexical raw", "v1", "v1.0.0", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ a, ok := ParseSemVer(tt.a)
+ if !ok {
+ t.Fatalf("ParseSemVer(%q) failed", tt.a)
+ }
+ b, ok := ParseSemVer(tt.b)
+ if !ok {
+ t.Fatalf("ParseSemVer(%q) failed", tt.b)
+ }
+ if got := a.Greater(b); got != tt.want {
+ t.Errorf("%q.Greater(%q) = %v; want %v", tt.a, tt.b, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSemVer_IsMutable(t *testing.T) {
+ cases := []struct {
+ ref string
+ want bool
+ }{
+ {"v4", true},
+ {"v4.2", true},
+ {"v1", true},
+ {"v4.2.1", false},
+ {"v1.0.0", false},
+ // SHAs aren't parsed as SemVer at all.
+ {"main", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.ref, func(t *testing.T) {
+ sv, ok := ParseSemVer(tc.ref)
+ if !ok {
+ if tc.want {
+ t.Fatalf("ParseSemVer(%q) failed, expected mutable", tc.ref)
+ }
+ return
+ }
+ if got := sv.IsMutable(); got != tc.want {
+ t.Errorf("SemVer(%q).IsMutable() = %v, want %v", tc.ref, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSemVer_IsMajorOnly(t *testing.T) {
+ cases := []struct {
+ ref string
+ want bool
+ }{
+ {"v4", true},
+ {"v12", true},
+ {"v4.2", false},
+ {"v4.2.1", false},
+ }
+ for _, tc := range cases {
+ sv, _ := ParseSemVer(tc.ref)
+ if got := sv.IsMajorOnly(); got != tc.want {
+ t.Errorf("SemVer(%q).IsMajorOnly() = %v, want %v", tc.ref, got, tc.want)
+ }
+ }
+}
+
+func TestSemVer_Narrows(t *testing.T) {
+ cases := []struct {
+ mutable, narrowed string
+ want bool
+ }{
+ {"v4", "v4.1.0", true},
+ {"v4.2", "v4.2.1", true},
+ {"v4", "v5.0.0", false},
+ {"v4.2", "v4.3.0", false},
+ }
+ for _, tc := range cases {
+ mv, _ := ParseSemVer(tc.mutable)
+ nv, _ := ParseSemVer(tc.narrowed)
+ if got := nv.Narrows(mv); got != tc.want {
+ t.Errorf("SemVer(%q).Narrows(%q) = %v, want %v", tc.narrowed, tc.mutable, got, tc.want)
+ }
+ }
+}
+
+func TestSemVer_UpgradeOver(t *testing.T) {
+ cases := []struct {
+ latest, current string
+ want bool
+ }{
+ {"v4.1.0", "v4.0.0", true},
+ {"v5.0.0", "v4.2.1", true},
+ {"v4.0.0", "v4.0.0", false}, // same
+ {"v4", "v4.0.0", false}, // mutable latest — noop
+ {"v3", "v4.0.0", false}, // downgrade
+ }
+ for _, tc := range cases {
+ lat, _ := ParseSemVer(tc.latest)
+ cur, _ := ParseSemVer(tc.current)
+ if got := lat.UpgradeOver(cur); got != tc.want {
+ t.Errorf("SemVer(%q).UpgradeOver(%q) = %v, want %v", tc.latest, tc.current, got, tc.want)
+ }
+ }
+}
diff --git a/pkg/actionmeta/actionmeta_test.go b/pkg/actionmeta/actionmeta_test.go
deleted file mode 100644
index e3b1ddf..0000000
--- a/pkg/actionmeta/actionmeta_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package actionmeta
-
-import (
- "testing"
-)
-
-func TestParseNodeAction(t *testing.T) {
- content := `
-name: 'Simple Node'
-runs:
- using: 'node20'
- main: 'index.js'
-`
- meta, err := Parse(content)
- if err != nil {
- t.Fatal(err)
- }
- if meta.Execution != ExecNode {
- t.Errorf("expected node, got %s", meta.Execution)
- }
- if len(meta.NestedUses) != 0 {
- t.Errorf("expected 0 nested uses, got %d", len(meta.NestedUses))
- }
-}
-
-func TestParseCompositeAction(t *testing.T) {
- content := `
-name: 'Composite'
-runs:
- using: 'composite'
- steps:
- - uses: actions/checkout@v4
- - run: echo hi
- shell: bash
- - uses: owner/other@v1
-`
- meta, err := Parse(content)
- if err != nil {
- t.Fatal(err)
- }
- if meta.Execution != ExecComposite {
- t.Errorf("expected composite, got %s", meta.Execution)
- }
- if len(meta.NestedUses) != 2 {
- t.Fatalf("expected 2 nested uses, got %d", len(meta.NestedUses))
- }
- if meta.NestedUses[0] != "actions/checkout@v4" {
- t.Errorf("nested[0] = %q", meta.NestedUses[0])
- }
- if meta.NestedUses[1] != "owner/other@v1" {
- t.Errorf("nested[1] = %q", meta.NestedUses[1])
- }
-}
-
-func TestParseDockerAction(t *testing.T) {
- content := `
-name: 'Docker Action'
-runs:
- using: 'docker'
- image: 'Dockerfile'
-`
- meta, err := Parse(content)
- if err != nil {
- t.Fatal(err)
- }
- if meta.Execution != ExecDocker {
- t.Errorf("expected docker, got %s", meta.Execution)
- }
-}
diff --git a/pkg/lockfile/lockfile.go b/pkg/lockfile/lockfile.go
deleted file mode 100644
index aa79bbe..0000000
--- a/pkg/lockfile/lockfile.go
+++ /dev/null
@@ -1,440 +0,0 @@
-// Package workflow handles parsing and modifying workflow YAML files.
-// Extracts action references (uses:) and manages the dependencies: section.
-//
-// Terminology aligns with the runner codebase (actions/runner):
-// - ActionRef ~ RepositoryPathReference (owner, repo, path, ref)
-// - Dependency ~ ActionDownloadInfo (nwo, ref, resolved sha)
-// - ExecComposite ~ ActionExecutionType.Composite
-package lockfile
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
-
- "gopkg.in/yaml.v3"
-)
-
-// ActionRef represents a parsed uses: reference to a repository action.
-// Corresponds to RepositoryPathReference in the runner SDK.
-type ActionRef struct {
- Owner string // e.g. "actions"
- Repo string // e.g. "checkout"
- Path string // e.g. "save" (for actions/cache/save@v4), empty for root actions
- Ref string // e.g. "v4" -- tag, branch, or full SHA
- Raw string // original uses: string
-}
-
-// NWO returns owner/repo (Name With Owner).
-func (a ActionRef) NWO() string {
- if a.Owner == "" && a.Repo == "" {
- return ""
- }
- return a.Owner + "/" + a.Repo
-}
-
-// FullName returns owner/repo or owner/repo/path.
-// Used for deduplication during recursive resolution.
-func (a ActionRef) FullName() string {
- if a.Path != "" {
- return a.Owner + "/" + a.Repo + "/" + a.Path
- }
- return a.Owner + "/" + a.Repo
-}
-
-// Dependency represents a pinned dependency entry in the dependencies: section.
-// Corresponds to ActionDownloadInfo in the runner SDK.
-type Dependency struct {
- // OPEN QUESTION: should the NWO include the "github.com/" prefix in the
- // serialized format? The v0.2 design doc uses it (e.g. "github.com/actions/checkout@v4:sha1-...")
- // but it's redundant for github.com-only resolution. Left as-is for now
- // pending format finalization.
- NWO string // owner/repo (e.g. "actions/checkout")
- Ref string // resolved ref as given in uses:
- SHA string // full commit hash
- HashAlgo string // "sha1" or "sha256"
-}
-
-// Key returns the dependency key for deduplication.
-func (d Dependency) Key() string {
- return d.NWO + "@" + d.Ref
-}
-
-// String formats as the YAML list entry.
-// Format: github.com/owner/repo@ref:sha1-HASH (or :sha256-HASH)
-func (d Dependency) String() string {
- algo := d.HashAlgo
- if algo == "" {
- algo = detectHashAlgo(d.SHA)
- }
- return fmt.Sprintf("github.com/%s@%s:%s-%s", d.NWO, d.Ref, algo, d.SHA)
-}
-
-// ParseDependencyString parses a dependency entry string back into a Dependency.
-// Accepts both "sha1-" and "sha256-" prefixed hashes.
-func ParseDependencyString(s string) (Dependency, error) {
- s = strings.TrimPrefix(s, "github.com/")
-
- var sha string
- var algo string
- var nwoRefPart string
-
- if idx := strings.Index(s, ":sha256-"); idx >= 0 {
- nwoRefPart = s[:idx]
- sha = s[idx+len(":sha256-"):]
- algo = "sha256"
- } else if idx := strings.Index(s, ":sha1-"); idx >= 0 {
- nwoRefPart = s[:idx]
- sha = s[idx+len(":sha1-"):]
- algo = "sha1"
- } else {
- return Dependency{}, fmt.Errorf("invalid dependency format (expected :sha1- or :sha256-): %q", s)
- }
-
- nwoRef := strings.SplitN(nwoRefPart, "@", 2)
- if len(nwoRef) != 2 {
- return Dependency{}, fmt.Errorf("invalid dependency nwo@ref: %q", nwoRefPart)
- }
-
- return Dependency{
- NWO: nwoRef[0],
- Ref: nwoRef[1],
- SHA: sha,
- HashAlgo: algo,
- }, nil
-}
-
-// detectHashAlgo guesses the hash algorithm from the hash length.
-func detectHashAlgo(hash string) string {
- if len(hash) == 64 {
- return "sha256"
- }
- return "sha1"
-}
-
-// ParseActionRef parses a uses: string into an ActionRef.
-// Only handles repository actions (owner/repo@ref and owner/repo/path@ref).
-// Returns nil for local path actions (./), docker actions (docker://),
-// expression-based refs (${{), and reusable workflow refs (.github/workflows/).
-func ParseActionRef(uses string) *ActionRef {
- uses = strings.TrimSpace(uses)
-
- // Skip local path actions
- if strings.HasPrefix(uses, "./") {
- return nil
- }
- // Skip docker actions
- if strings.HasPrefix(uses, "docker://") {
- return nil
- }
- // Skip expression-based uses: (can't statically resolve)
- if strings.Contains(uses, "${") {
- return nil
- }
-
- // Must have exactly one @
- atParts := strings.SplitN(uses, "@", 2)
- if len(atParts) != 2 || atParts[1] == "" {
- return nil
- }
- ref := atParts[1]
-
- // Split the nwo/path part
- segments := strings.SplitN(atParts[0], "/", 3)
- if len(segments) < 2 {
- return nil
- }
-
- ar := &ActionRef{
- Owner: segments[0],
- Repo: segments[1],
- Ref: ref,
- Raw: uses,
- }
- if len(segments) == 3 {
- ar.Path = segments[2]
- }
-
- // Reusable workflow references resolve the same way as actions.
- // Keep the path so the dependency key distinguishes them.
-
- return ar
-}
-
-// File represents a parsed workflow file with its raw content.
-type File struct {
- Path string
- Content []byte
- root yaml.Node
-}
-
-// Load reads and parses a workflow file.
-func Load(path string) (*File, error) {
- content, err := os.ReadFile(path)
- if err != nil {
- return nil, fmt.Errorf("reading workflow: %w", err)
- }
-
- f := &File{
- Path: path,
- Content: content,
- }
-
- if err := yaml.Unmarshal(content, &f.root); err != nil {
- return nil, fmt.Errorf("parsing workflow YAML: %w", err)
- }
-
- return f, nil
-}
-
-// ExtractActionRefs finds all uses: references to repository actions in the workflow.
-// Deduplicates by NWO@ref (path actions with same repo share a single resolution).
-// Returns the refs, local action paths (./), and any warnings about unpinnable entries.
-func (f *File) ExtractActionRefs() ([]ActionRef, []string, []string) {
- var refs []ActionRef
- var warnings []string
- var localPaths []string
- seen := make(map[string]bool)
- seenLocal := make(map[string]bool)
-
- walkYAML(&f.root, func(key, value string) {
- if key == "uses" {
- value = strings.TrimSpace(value)
- if strings.Contains(value, "${") {
- warnings = append(warnings, fmt.Sprintf("can't pin expression-based uses: %s", value))
- return
- }
- if strings.HasPrefix(value, "./") {
- if !seenLocal[value] {
- seenLocal[value] = true
- localPaths = append(localPaths, value)
- }
- return
- }
- ar := ParseActionRef(value)
- if ar != nil {
- dedupKey := ar.NWO() + "@" + ar.Ref
- if !seen[dedupKey] {
- seen[dedupKey] = true
- refs = append(refs, *ar)
- }
- }
- }
- })
-
- return refs, localPaths, warnings
-}
-
-// ReadDependencies extracts the current dependencies: section from the workflow.
-// Rejects duplicate keys and entries with control characters (injection defense).
-func (f *File) ReadDependencies() ([]Dependency, error) {
- var deps []Dependency
- seen := make(map[string]bool)
-
- doc := docNode(&f.root)
- if doc == nil {
- return nil, nil
- }
-
- for i := 0; i < len(doc.Content)-1; i += 2 {
- if doc.Content[i].Value == "dependencies" {
- seq := doc.Content[i+1]
- if seq.Kind != yaml.SequenceNode {
- return nil, fmt.Errorf("dependencies: must be a sequence")
- }
- for _, item := range seq.Content {
- if strings.ContainsAny(item.Value, "\n\r\t") {
- return nil, fmt.Errorf("dependency entry contains control characters (possible injection)")
- }
- d, err := ParseDependencyString(item.Value)
- if err != nil {
- return nil, fmt.Errorf("parsing dependency entry: %w", err)
- }
- if seen[d.Key()] {
- return nil, fmt.Errorf("duplicate dependency entry for %s", d.Key())
- }
- seen[d.Key()] = true
- deps = append(deps, d)
- }
- return deps, nil
- }
- }
-
- return nil, nil
-}
-
-// WriteDependencies writes the workflow file back with an updated dependencies: section.
-// Output is deterministic (sorted entries).
-func (f *File) WriteDependencies(deps []Dependency) ([]byte, error) {
- content := string(f.Content)
-
- sort.Slice(deps, func(i, j int) bool {
- return deps[i].String() < deps[j].String()
- })
-
- var sb strings.Builder
- sb.WriteString("\n# Automatically generated and managed by: `actions-lockfile pin `\n")
- sb.WriteString("dependencies:\n")
- for _, d := range deps {
- sb.WriteString(" - " + d.String() + "\n")
- }
-
- content = removeDependenciesSection(content)
- content = strings.TrimRight(content, "\n") + "\n"
- content += sb.String()
-
- return []byte(content), nil
-}
-
-// removeDependenciesSection strips an existing dependencies: block from the YAML string.
-func removeDependenciesSection(content string) string {
- re := regexp.MustCompile(`(?m)^\n?# Automatically generated and managed by:.*\ndependencies:\n(?: - .*\n)*`)
- content = re.ReplaceAllString(content, "")
-
- re2 := regexp.MustCompile(`(?m)^dependencies:\n(?: - .*\n)*`)
- content = re2.ReplaceAllString(content, "")
-
- return content
-}
-
-// walkYAML walks a YAML node tree, calling fn for each scalar key-value pair.
-func walkYAML(node *yaml.Node, fn func(key, value string)) {
- if node == nil {
- return
- }
-
- switch node.Kind {
- case yaml.DocumentNode:
- for _, child := range node.Content {
- walkYAML(child, fn)
- }
- case yaml.MappingNode:
- for i := 0; i < len(node.Content)-1; i += 2 {
- key := node.Content[i]
- val := node.Content[i+1]
- if key.Kind == yaml.ScalarNode && val.Kind == yaml.ScalarNode {
- fn(key.Value, val.Value)
- }
- walkYAML(val, fn)
- }
- case yaml.SequenceNode:
- for _, child := range node.Content {
- walkYAML(child, fn)
- }
- }
-}
-
-// docNode returns the root mapping node from a document.
-func docNode(root *yaml.Node) *yaml.Node {
- if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
- return root.Content[0]
- }
- if root.Kind == yaml.MappingNode {
- return root
- }
- return nil
-}
-
-// ExtractLocalCompositeRefs reads action.yml files from local paths relative
-// to the workflow file's directory, and returns any repository action refs
-// found in their steps. This discovers transitive deps from ./local composites.
-func ExtractLocalCompositeRefs(workflowPath string, localPaths []string) ([]ActionRef, []string) {
- var refs []ActionRef
- var warnings []string
- seen := make(map[string]bool)
-
- // The workflow file is at e.g. .github/workflows/ci.yml
- // Local paths are relative to the repo root (./my-action)
- // We need the repo root to resolve them
- repoRoot := findRepoRoot(workflowPath)
- if repoRoot == "" {
- if len(localPaths) > 0 {
- warnings = append(warnings, "can't resolve local action paths: not in a git repository")
- }
- return nil, warnings
- }
-
- for _, localPath := range localPaths {
- // Strip ./ prefix
- relPath := strings.TrimPrefix(localPath, "./")
- actionDir := filepath.Join(repoRoot, relPath)
-
- // Try action.yml then action.yaml
- var actionContent []byte
- var err error
- actionContent, err = os.ReadFile(filepath.Join(actionDir, "action.yml"))
- if err != nil {
- actionContent, err = os.ReadFile(filepath.Join(actionDir, "action.yaml"))
- if err != nil {
- warnings = append(warnings, fmt.Sprintf("can't read action file for %s: %v", localPath, err))
- continue
- }
- }
-
- // Parse the action.yml to find uses: refs
- meta, err := parseActionYAMLForUses(actionContent)
- if err != nil {
- warnings = append(warnings, fmt.Sprintf("can't parse action file for %s: %v", localPath, err))
- continue
- }
-
- for _, uses := range meta {
- ar := ParseActionRef(uses)
- if ar != nil {
- dedupKey := ar.NWO() + "@" + ar.Ref
- if !seen[dedupKey] {
- seen[dedupKey] = true
- refs = append(refs, *ar)
- }
- }
- }
- }
-
- return refs, warnings
-}
-
-// findRepoRoot walks up from the given path to find the .git directory.
-func findRepoRoot(startPath string) string {
- absPath, err := filepath.Abs(filepath.Dir(startPath))
- if err != nil {
- return ""
- }
- for {
- if _, err := os.Stat(filepath.Join(absPath, ".git")); err == nil {
- return absPath
- }
- parent := filepath.Dir(absPath)
- if parent == absPath {
- return ""
- }
- absPath = parent
- }
-}
-
-// parseActionYAMLForUses extracts uses: values from a composite action YAML.
-func parseActionYAMLForUses(content []byte) ([]string, error) {
- var action struct {
- Runs struct {
- Using string `yaml:"using"`
- Steps []struct {
- Uses string `yaml:"uses"`
- } `yaml:"steps"`
- } `yaml:"runs"`
- }
- if err := yaml.Unmarshal(content, &action); err != nil {
- return nil, err
- }
- if action.Runs.Using != "composite" {
- return nil, nil
- }
- var uses []string
- for _, step := range action.Runs.Steps {
- if step.Uses != "" {
- uses = append(uses, step.Uses)
- }
- }
- return uses, nil
-}
diff --git a/pkg/lockfile/lockfile_test.go b/pkg/lockfile/lockfile_test.go
deleted file mode 100644
index eddf6a6..0000000
--- a/pkg/lockfile/lockfile_test.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package lockfile
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestParseActionRef(t *testing.T) {
- tests := []struct {
- input string
- wantNil bool
- wantNWO string
- wantPath string
- wantRef string
- }{
- {"actions/checkout@v4", false, "actions/checkout", "", "v4"},
- {"actions/cache/save@v4", false, "actions/cache", "save", "v4"},
- {"actions/cache/restore@v4", false, "actions/cache", "restore", "v4"},
- {"org/repo@11bd71901bbe5b1630ceea73d27597364c9af683", false, "org/repo", "", "11bd71901bbe5b1630ceea73d27597364c9af683"},
- {"./local-action", true, "", "", ""},
- {"docker://alpine:3.19", true, "", "", ""},
- {"invalid", true, "", "", ""},
- {"", true, "", "", ""},
- {"${{ matrix.action }}@v4", true, "", "", ""}, // expression-based
- {"org/repo/.github/workflows/build.yml@main", false, "org/repo", ".github/workflows/build.yml", "main"}, // reusable workflow (pinned like actions)
- {"github/go-linter/install-only@abc123", false, "github/go-linter", "install-only", "abc123"}, // path action
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- got := ParseActionRef(tt.input)
- if tt.wantNil {
- if got != nil {
- t.Errorf("ParseActionRef(%q) = %+v, want nil", tt.input, got)
- }
- return
- }
- if got == nil {
- t.Fatalf("ParseActionRef(%q) = nil, want non-nil", tt.input)
- }
- if got.NWO() != tt.wantNWO {
- t.Errorf("NWO = %q, want %q", got.NWO(), tt.wantNWO)
- }
- if got.Path != tt.wantPath {
- t.Errorf("Path = %q, want %q", got.Path, tt.wantPath)
- }
- if got.Ref != tt.wantRef {
- t.Errorf("Ref = %q, want %q", got.Ref, tt.wantRef)
- }
- })
- }
-}
-
-func TestParseDependencyString(t *testing.T) {
- tests := []struct {
- input string
- wantNWO string
- wantRef string
- wantSHA string
- wantAlgo string
- wantErr bool
- }{
- {
- "github.com/actions/checkout@v4:sha1-11bd71901bbe5b1630ceea73d27597364c9af683",
- "actions/checkout", "v4", "11bd71901bbe5b1630ceea73d27597364c9af683", "sha1", false,
- },
- {
- "github.com/actions/cache@v4:sha1-abcdef1234567890abcdef1234567890abcdef12",
- "actions/cache", "v4", "abcdef1234567890abcdef1234567890abcdef12", "sha1", false,
- },
- {
- "github.com/actions/checkout@v5:sha256-abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
- "actions/checkout", "v5", "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", "sha256", false,
- },
- {"garbage", "", "", "", "", true},
- {"github.com/no-sha-here@v1", "", "", "", "", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- got, err := ParseDependencyString(tt.input)
- if tt.wantErr {
- if err == nil {
- t.Errorf("expected error, got nil")
- }
- return
- }
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if got.NWO != tt.wantNWO {
- t.Errorf("NWO = %q, want %q", got.NWO, tt.wantNWO)
- }
- if got.Ref != tt.wantRef {
- t.Errorf("Ref = %q, want %q", got.Ref, tt.wantRef)
- }
- if got.SHA != tt.wantSHA {
- t.Errorf("SHA = %q, want %q", got.SHA, tt.wantSHA)
- }
- if got.HashAlgo != tt.wantAlgo {
- t.Errorf("HashAlgo = %q, want %q", got.HashAlgo, tt.wantAlgo)
- }
- })
- }
-}
-
-func TestDependencyRoundtrip(t *testing.T) {
- // SHA-1 roundtrip
- d1 := Dependency{NWO: "actions/checkout", Ref: "v4", SHA: "abc123"}
- s1 := d1.String()
- got1, err := ParseDependencyString(s1)
- if err != nil {
- t.Fatalf("sha1 roundtrip parse error: %v", err)
- }
- if got1.NWO != d1.NWO || got1.Ref != d1.Ref || got1.SHA != d1.SHA || got1.HashAlgo != "sha1" {
- t.Errorf("sha1 roundtrip mismatch: %+v", got1)
- }
-
- // SHA-256 roundtrip
- d2 := Dependency{NWO: "actions/checkout", Ref: "v5", SHA: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", HashAlgo: "sha256"}
- s2 := d2.String()
- got2, err := ParseDependencyString(s2)
- if err != nil {
- t.Fatalf("sha256 roundtrip parse error: %v", err)
- }
- if got2.NWO != d2.NWO || got2.Ref != d2.Ref || got2.SHA != d2.SHA || got2.HashAlgo != "sha256" {
- t.Errorf("sha256 roundtrip mismatch: %+v", got2)
- }
- if !strings.Contains(s2, ":sha256-") {
- t.Errorf("sha256 string should contain :sha256-, got %q", s2)
- }
-}
-
-func TestExtractActionRefs(t *testing.T) {
- wf, err := Load(filepath.Join("..", "..", "testdata", "workflows", "mixed.yml"))
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
-
- refs, _, _ := wf.ExtractActionRefs()
-
- wantNWOs := map[string]bool{
- "actions/checkout": false,
- "actions/setup-node": false,
- }
-
- for _, ref := range refs {
- if _, ok := wantNWOs[ref.NWO()]; ok {
- wantNWOs[ref.NWO()] = true
- } else {
- t.Errorf("unexpected ref: %s", ref.NWO())
- }
- }
-
- for nwo, found := range wantNWOs {
- if !found {
- t.Errorf("missing expected ref: %s", nwo)
- }
- }
-}
-
-func TestReadWriteDependencies(t *testing.T) {
- dir := t.TempDir()
- src := filepath.Join("..", "..", "testdata", "workflows", "basic.yml")
- content, err := os.ReadFile(src)
- if err != nil {
- t.Fatalf("reading source: %v", err)
- }
- tmp := filepath.Join(dir, "test.yml")
- if err := os.WriteFile(tmp, content, 0644); err != nil {
- t.Fatalf("writing temp: %v", err)
- }
-
- wf, err := Load(tmp)
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
-
- deps, err := wf.ReadDependencies()
- if err != nil {
- t.Fatalf("ReadDependencies: %v", err)
- }
- if len(deps) != 0 {
- t.Fatalf("expected 0 deps, got %d", len(deps))
- }
-
- newDeps := []Dependency{
- {NWO: "actions/checkout", Ref: "v4", SHA: "abc123"},
- {NWO: "actions/setup-go", Ref: "v5", SHA: "def456"},
- }
- output, err := wf.WriteDependencies(newDeps)
- if err != nil {
- t.Fatalf("WriteDependencies: %v", err)
- }
- if err := os.WriteFile(tmp, output, 0644); err != nil {
- t.Fatalf("writing output: %v", err)
- }
-
- wf2, err := Load(tmp)
- if err != nil {
- t.Fatalf("Load after write: %v", err)
- }
- got, err := wf2.ReadDependencies()
- if err != nil {
- t.Fatalf("ReadDependencies after write: %v", err)
- }
- if len(got) != 2 {
- t.Fatalf("expected 2 deps, got %d", len(got))
- }
-
- // Re-pin should be idempotent
- output2, err := wf2.WriteDependencies(newDeps)
- if err != nil {
- t.Fatalf("WriteDependencies second time: %v", err)
- }
- if err := os.WriteFile(tmp, output2, 0644); err != nil {
- t.Fatalf("writing output2: %v", err)
- }
- wf3, err := Load(tmp)
- if err != nil {
- t.Fatalf("Load after second write: %v", err)
- }
- got2, err := wf3.ReadDependencies()
- if err != nil {
- t.Fatalf("ReadDependencies after second write: %v", err)
- }
- if len(got2) != 2 {
- t.Fatalf("expected 2 deps after re-pin, got %d", len(got2))
- }
-}
-
-func TestExtractPathActions(t *testing.T) {
- wf, err := Load(filepath.Join("..", "..", "testdata", "workflows", "path-action.yml"))
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
-
- refs, _, _ := wf.ExtractActionRefs()
- if len(refs) != 2 {
- t.Fatalf("expected 2 deduplicated refs, got %d: %+v", len(refs), refs)
- }
-}
-
-func TestReadTamperedDependencies(t *testing.T) {
- wf, err := Load(filepath.Join("..", "..", "testdata", "workflows", "tampered.yml"))
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
-
- deps, err := wf.ReadDependencies()
- if err != nil {
- t.Fatalf("ReadDependencies: %v", err)
- }
- if len(deps) != 2 {
- t.Fatalf("expected 2 deps, got %d", len(deps))
- }
-}
-
-func TestRejectDuplicateDeps(t *testing.T) {
- dir := t.TempDir()
- tmp := filepath.Join(dir, "dup.yml")
- content := `name: dup
-on: push
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: cli/cli@v2.50.0
-
-dependencies:
- - github.com/cli/cli@v2.50.0:sha1-aaaa
- - github.com/cli/cli@v2.50.0:sha1-bbbb
-`
- if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
- t.Fatal(err)
- }
- wf, err := Load(tmp)
- if err != nil {
- t.Fatal(err)
- }
- _, err = wf.ReadDependencies()
- if err == nil {
- t.Fatal("expected error for duplicate dependency keys, got nil")
- }
- if !strings.Contains(err.Error(), "duplicate") {
- t.Fatalf("expected duplicate error, got: %v", err)
- }
-}
diff --git a/pkg/pin/pin.go b/pkg/pin/pin.go
deleted file mode 100644
index 93047ee..0000000
--- a/pkg/pin/pin.go
+++ /dev/null
@@ -1,184 +0,0 @@
-// Package pin implements the `actions-lockfile pin` command.
-package pin
-
-import (
- "fmt"
- "os"
- "sort"
-
- "github.com/github/actions-lockfile/pkg/resolver"
- "github.com/github/actions-lockfile/pkg/lockfile"
-)
-
-// Options controls pin behavior.
-type Options struct {
- DryRun bool // resolve and print without writing
- Diff bool // show what changed vs existing deps
-}
-
-// Run pins all repository action refs in a workflow file.
-func Run(path string, token string, opts Options) error {
- // Load and parse the workflow
- wf, err := lockfile.Load(path)
- if err != nil {
- return fmt.Errorf("loading workflow: %w", err)
- }
-
- // Check for existing dependencies section
- existingDeps, err := wf.ReadDependencies()
- if err != nil {
- return fmt.Errorf("reading existing dependencies: %w", err)
- }
-
- // Extract action references
- refs, localPaths, warnings := wf.ExtractActionRefs()
- for _, w := range warnings {
- fmt.Fprintf(os.Stderr, "warning: %s\n", w)
- }
- // Discover transitive deps from local composite actions
- if len(localPaths) > 0 {
- localRefs, localWarnings := lockfile.ExtractLocalCompositeRefs(path, localPaths)
- for _, w := range localWarnings {
- fmt.Fprintf(os.Stderr, "warning: %s\n", w)
- }
- refs = append(refs, localRefs...)
- }
-
- if len(refs) == 0 {
- fmt.Fprintf(os.Stderr, "No repository action references found in %s\n", path)
- return nil
- }
-
- fmt.Fprintf(os.Stderr, "Resolving %d action reference(s)...\n", len(refs))
- for _, ref := range refs {
- fmt.Fprintf(os.Stderr, " %s@%s\n", ref.NWO(), ref.Ref)
- }
-
- // Resolve all refs to SHAs, recursing into composite actions
- client := resolver.New(token)
- deps, err := client.ResolveAllRecursive(refs)
- if err != nil {
- return fmt.Errorf("resolving actions: %w", err)
- }
-
- // If there's an existing dependencies section, validate consistency
- if len(existingDeps) > 0 {
- oldByKey := make(map[string]lockfile.Dependency)
- for _, d := range existingDeps {
- oldByKey[d.Key()] = d
- }
- newByKey := make(map[string]lockfile.Dependency)
- for _, d := range deps {
- newByKey[d.Key()] = d
- }
-
- // Fail on SHA changes (possible force-pushed tags)
- var shaChanges []string
- for _, d := range deps {
- if old, ok := oldByKey[d.Key()]; ok && old.SHA != d.SHA {
- shaChanges = append(shaChanges,
- fmt.Sprintf(" %s: %s -> %s", d.Key(), old.SHA[:12], d.SHA[:12]))
- }
- }
- if len(shaChanges) > 0 {
- fmt.Fprintf(os.Stderr, "error: SHA changed for pinned dependencies (tag may have been force-pushed):\n")
- for _, c := range shaChanges {
- fmt.Fprintf(os.Stderr, "%s\n", c)
- }
- return fmt.Errorf("%d dependency SHA(s) changed since last pin -- investigate before updating", len(shaChanges))
- }
-
- var staleErrors []string
- for _, existing := range existingDeps {
- if _, ok := newByKey[existing.Key()]; !ok {
- staleErrors = append(staleErrors,
- fmt.Sprintf(" %s: was in dependencies but not discoverable from current uses: refs", existing.Key()))
- }
- }
-
- if len(staleErrors) > 0 {
- fmt.Fprintf(os.Stderr, "error: existing dependencies section has entries that can't be resolved:\n")
- for _, e := range staleErrors {
- fmt.Fprintf(os.Stderr, "%s\n", e)
- }
- return fmt.Errorf("stale entries in existing dependencies: section -- remove them manually or pass --allow-partial")
- }
- }
-
- // Show diff if requested
- if opts.Diff && len(existingDeps) > 0 {
- showDiff(existingDeps, deps)
- }
-
- // Dry run -- print what would be written and exit
- if opts.DryRun {
- fmt.Fprintf(os.Stderr, "Resolved %d dependencies (dry run, not writing):\n", len(deps))
- sort.Slice(deps, func(i, j int) bool { return deps[i].String() < deps[j].String() })
- for _, d := range deps {
- fmt.Fprintf(os.Stderr, " %s\n", d.String())
- }
- return nil
- }
-
- // Write updated workflow with dependencies section
- output, err := wf.WriteDependencies(deps)
- if err != nil {
- return fmt.Errorf("writing dependencies: %w", err)
- }
-
- if err := os.WriteFile(path, output, 0644); err != nil {
- return fmt.Errorf("writing file: %w", err)
- }
-
- fmt.Fprintf(os.Stderr, "Pinned %d dependencies in %s\n", len(deps), path)
- for _, d := range deps {
- fmt.Fprintf(os.Stderr, " %s\n", d.String())
- }
-
- return nil
-}
-
-func showDiff(old, new []lockfile.Dependency) {
- oldMap := make(map[string]lockfile.Dependency)
- for _, d := range old {
- oldMap[d.Key()] = d
- }
- newMap := make(map[string]lockfile.Dependency)
- for _, d := range new {
- newMap[d.Key()] = d
- }
-
- // Added
- for _, d := range new {
- if _, ok := oldMap[d.Key()]; !ok {
- fmt.Fprintf(os.Stderr, " \033[32m+ %s\033[0m\n", d.String())
- }
- }
-
- // Changed SHA
- for _, d := range new {
- if o, ok := oldMap[d.Key()]; ok && o.SHA != d.SHA {
- fmt.Fprintf(os.Stderr, " \033[33m~ %s\033[0m\n", d.Key())
- fmt.Fprintf(os.Stderr, " \033[31m- sha1-%s\033[0m\n", o.SHA)
- fmt.Fprintf(os.Stderr, " \033[32m+ sha1-%s\033[0m\n", d.SHA)
- }
- }
-
- // Removed
- for _, d := range old {
- if _, ok := newMap[d.Key()]; !ok {
- fmt.Fprintf(os.Stderr, " \033[31m- %s\033[0m\n", d.String())
- }
- }
-
- // Unchanged count
- unchanged := 0
- for _, d := range new {
- if o, ok := oldMap[d.Key()]; ok && o.SHA == d.SHA {
- unchanged++
- }
- }
- if unchanged > 0 {
- fmt.Fprintf(os.Stderr, " \033[2m%d unchanged\033[0m\n", unchanged)
- }
-}
diff --git a/pkg/resolver/resolver.go b/pkg/resolver/resolver.go
deleted file mode 100644
index 1008176..0000000
--- a/pkg/resolver/resolver.go
+++ /dev/null
@@ -1,401 +0,0 @@
-// Package resolver handles resolving action refs to commit SHAs via GitHub's GraphQL API.
-package resolver
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
-
- "github.com/github/actions-lockfile/pkg/actionmeta"
- "github.com/github/actions-lockfile/pkg/lockfile"
-)
-
-// DefaultMaxRecursionDepth matches Constants.CompositeActionsMaxDepth in the runner.
-const DefaultMaxRecursionDepth = 10
-
-// Client resolves action refs to commit SHAs via GitHub's GraphQL API.
-type Client struct {
- token string
- httpClient *http.Client
- apiURL string
- MaxRecursionDepth int // configurable, defaults to DefaultMaxRecursionDepth
-}
-
-// New creates a new resolver client.
-func New(token string) *Client {
- return &Client{
- token: token,
- httpClient: http.DefaultClient,
- apiURL: "https://api.github.com/graphql",
- MaxRecursionDepth: DefaultMaxRecursionDepth,
- }
-}
-
-// ResolveAll resolves a batch of action refs to their commit SHAs.
-// Does NOT recurse into composite actions.
-func (c *Client) ResolveAll(refs []lockfile.ActionRef) ([]lockfile.Dependency, error) {
- if len(refs) == 0 {
- return nil, nil
- }
-
- query, aliasMap := buildResolveQuery(refs)
- resp, err := c.doGraphQL(query)
- if err != nil {
- return nil, err
- }
-
- return parseResolveResponse(resp, refs, aliasMap)
-}
-
-// ResolveAllRecursive resolves action refs and recursively discovers transitive
-// dependencies from composite actions by reading their action.yml via GraphQL.
-func (c *Client) ResolveAllRecursive(refs []lockfile.ActionRef) ([]lockfile.Dependency, error) {
- seen := make(map[string]bool) // nwo@ref -> already resolved
- var allDeps []lockfile.Dependency
-
- pending := refs
- depth := 0
-
- for len(pending) > 0 {
- if depth >= c.MaxRecursionDepth {
- return allDeps, fmt.Errorf("composite action recursion exceeded max depth %d", c.MaxRecursionDepth)
- }
-
- // Deduplicate against already-resolved (by full name including path)
- var toResolve []lockfile.ActionRef
- for _, ref := range pending {
- key := ref.FullName() + "@" + ref.Ref
- if !seen[key] {
- seen[key] = true
- toResolve = append(toResolve, ref)
- }
- }
-
- if len(toResolve) == 0 {
- break
- }
-
- // Resolve this batch + fetch action.yml for each
- deps, actionYMLs, err := c.resolveWithActionYML(toResolve)
- if err != nil {
- return allDeps, err
- }
- allDeps = append(allDeps, deps...)
-
- // Parse action.yml for each resolved dep, collect nested uses: from composites
- var nextPending []lockfile.ActionRef
- for i := range deps {
- yml := actionYMLs[i]
- if yml == "" {
- continue
- }
-
- meta, err := actionmeta.Parse(yml)
- if err != nil {
- continue
- }
-
- if meta.Execution != actionmeta.ExecComposite {
- continue
- }
-
- for _, uses := range meta.NestedUses {
- ar := lockfile.ParseActionRef(uses)
- if ar == nil {
- continue
- }
- nextPending = append(nextPending, *ar)
- }
- }
-
- pending = nextPending
- depth++
- }
-
- // Deduplicate final dependency list by NWO@ref (path actions share the same repo SHA)
- seenDeps := make(map[string]bool)
- var dedupDeps []lockfile.Dependency
- for _, d := range allDeps {
- key := d.Key()
- if !seenDeps[key] {
- seenDeps[key] = true
- dedupDeps = append(dedupDeps, d)
- }
- }
-
- return dedupDeps, nil
-}
-
-// MaxBatchSize is the maximum number of action refs per GraphQL query.
-// Matches the internal ResolveActionsBatchSize.
-const MaxBatchSize = 20
-
-// resolveWithActionYML resolves refs and also fetches action.yml content for each.
-// Returns deps and the corresponding action.yml content (empty string if not found).
-// Batches into groups of MaxBatchSize to stay within GraphQL node limits.
-func (c *Client) resolveWithActionYML(refs []lockfile.ActionRef) ([]lockfile.Dependency, []string, error) {
- if len(refs) <= MaxBatchSize {
- return c.resolveWithActionYMLBatch(refs)
- }
-
- var allDeps []lockfile.Dependency
- var allYMLs []string
- for i := 0; i < len(refs); i += MaxBatchSize {
- end := i + MaxBatchSize
- if end > len(refs) {
- end = len(refs)
- }
- deps, ymls, err := c.resolveWithActionYMLBatch(refs[i:end])
- if err != nil {
- return allDeps, allYMLs, err
- }
- allDeps = append(allDeps, deps...)
- allYMLs = append(allYMLs, ymls...)
- }
- return allDeps, allYMLs, nil
-}
-
-func (c *Client) resolveWithActionYMLBatch(refs []lockfile.ActionRef) ([]lockfile.Dependency, []string, error) {
- query, aliasMap := buildResolveWithFileQuery(refs)
- resp, err := c.doGraphQL(query)
- if err != nil {
- return nil, nil, err
- }
-
- return parseResolveWithFileResponse(resp, refs, aliasMap)
-}
-
-type graphqlRequest struct {
- Query string `json:"query"`
-}
-
-type graphqlResponse struct {
- Data map[string]json.RawMessage `json:"data"`
- Errors []graphqlError `json:"errors"`
-}
-
-type graphqlError struct {
- Message string `json:"message"`
- Type string `json:"type"`
- Path []string `json:"path"`
-}
-
-type repoResponse struct {
- NameWithOwner string `json:"nameWithOwner"`
- Object *struct {
- OID string `json:"oid"`
- File *struct {
- Object *struct {
- Text string `json:"text"`
- } `json:"object"`
- } `json:"file"`
- FileYAML *struct {
- Object *struct {
- Text string `json:"text"`
- } `json:"object"`
- } `json:"fileYaml"`
- } `json:"object"`
-}
-
-func buildResolveQuery(refs []lockfile.ActionRef) (string, map[string]int) {
- aliasMap := make(map[string]int)
- var sb strings.Builder
- sb.WriteString("query {\n")
-
- for i, ref := range refs {
- alias := fmt.Sprintf("a%d", i)
- aliasMap[alias] = i
- escapedRef := strings.ReplaceAll(ref.Ref, `"`, `\"`)
- sb.WriteString(fmt.Sprintf(" %s: repository(owner: %q, name: %q) {\n", alias, ref.Owner, ref.Repo))
- sb.WriteString(" nameWithOwner\n")
- sb.WriteString(fmt.Sprintf(" object(expression: %q) {\n", escapedRef))
- sb.WriteString(" ... on Commit { oid }\n")
- sb.WriteString(" }\n")
- sb.WriteString(" }\n")
- }
-
- sb.WriteString("}\n")
- return sb.String(), aliasMap
-}
-
-func buildResolveWithFileQuery(refs []lockfile.ActionRef) (string, map[string]int) {
- aliasMap := make(map[string]int)
- var sb strings.Builder
- sb.WriteString("query {\n")
-
- for i, ref := range refs {
- alias := fmt.Sprintf("a%d", i)
- aliasMap[alias] = i
- escapedRef := strings.ReplaceAll(ref.Ref, `"`, `\"`)
-
- // Determine the action.yml path based on whether this is a path action
- ymlPath := "action.yml"
- yamlPath := "action.yaml"
- if ref.Path != "" {
- ymlPath = ref.Path + "/action.yml"
- yamlPath = ref.Path + "/action.yaml"
- }
-
- sb.WriteString(fmt.Sprintf(" %s: repository(owner: %q, name: %q) {\n", alias, ref.Owner, ref.Repo))
- sb.WriteString(" nameWithOwner\n")
- sb.WriteString(fmt.Sprintf(" object(expression: %q) {\n", escapedRef))
- sb.WriteString(" ... on Commit {\n")
- sb.WriteString(" oid\n")
- sb.WriteString(fmt.Sprintf(" file: file(path: %q) {\n", ymlPath))
- sb.WriteString(" object { ... on Blob { text } }\n")
- sb.WriteString(" }\n")
- sb.WriteString(fmt.Sprintf(" fileYaml: file(path: %q) {\n", yamlPath))
- sb.WriteString(" object { ... on Blob { text } }\n")
- sb.WriteString(" }\n")
- sb.WriteString(" }\n")
- sb.WriteString(" }\n")
- sb.WriteString(" }\n")
- }
-
- sb.WriteString("}\n")
- return sb.String(), aliasMap
-}
-
-func (c *Client) doGraphQL(query string) (*graphqlResponse, error) {
- reqBody, err := json.Marshal(graphqlRequest{Query: query})
- if err != nil {
- return nil, fmt.Errorf("marshaling request: %w", err)
- }
-
- req, err := http.NewRequest("POST", c.apiURL, bytes.NewReader(reqBody))
- if err != nil {
- return nil, fmt.Errorf("creating request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- if c.token != "" {
- req.Header.Set("Authorization", "Bearer "+c.token)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("executing request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("reading response: %w", err)
- }
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("GraphQL API returned %d: %s", resp.StatusCode, string(body))
- }
-
- var gqlResp graphqlResponse
- if err := json.Unmarshal(body, &gqlResp); err != nil {
- return nil, fmt.Errorf("parsing response: %w", err)
- }
-
- // Only fail on top-level errors (not field-scoped)
- for _, e := range gqlResp.Errors {
- if len(e.Path) == 0 {
- return nil, fmt.Errorf("GraphQL error: %s", e.Message)
- }
- }
-
- return &gqlResp, nil
-}
-
-func parseResolveResponse(resp *graphqlResponse, refs []lockfile.ActionRef, aliasMap map[string]int) ([]lockfile.Dependency, error) {
- var deps []lockfile.Dependency
- var errs []string
-
- for alias, idx := range aliasMap {
- ref := refs[idx]
- raw, ok := resp.Data[alias]
- if !ok {
- errs = append(errs, fmt.Sprintf("%s@%s: not found in response", ref.NWO(), ref.Ref))
- continue
- }
-
- if string(raw) == "null" {
- errs = append(errs, fmt.Sprintf("%s@%s: repository not found or not accessible", ref.NWO(), ref.Ref))
- continue
- }
-
- var repo repoResponse
- if err := json.Unmarshal(raw, &repo); err != nil {
- errs = append(errs, fmt.Sprintf("%s@%s: failed to parse: %v", ref.NWO(), ref.Ref, err))
- continue
- }
-
- if repo.Object == nil || repo.Object.OID == "" {
- errs = append(errs, fmt.Sprintf("%s@%s: ref %q does not exist", ref.NWO(), ref.Ref, ref.Ref))
- continue
- }
-
- deps = append(deps, lockfile.Dependency{
- NWO: ref.NWO(),
- Ref: ref.Ref,
- SHA: repo.Object.OID,
- })
- }
-
- if len(errs) > 0 {
- return deps, fmt.Errorf("resolution errors:\n %s", strings.Join(errs, "\n "))
- }
-
- return deps, nil
-}
-
-func parseResolveWithFileResponse(resp *graphqlResponse, refs []lockfile.ActionRef, aliasMap map[string]int) ([]lockfile.Dependency, []string, error) {
- var deps []lockfile.Dependency
- var ymls []string
- var errs []string
-
- for alias, idx := range aliasMap {
- ref := refs[idx]
- raw, ok := resp.Data[alias]
- if !ok {
- errs = append(errs, fmt.Sprintf("%s@%s: not found in response", ref.NWO(), ref.Ref))
- continue
- }
-
- if string(raw) == "null" {
- errs = append(errs, fmt.Sprintf("%s@%s: repository not found or not accessible", ref.NWO(), ref.Ref))
- continue
- }
-
- var repo repoResponse
- if err := json.Unmarshal(raw, &repo); err != nil {
- errs = append(errs, fmt.Sprintf("%s@%s: failed to parse: %v", ref.NWO(), ref.Ref, err))
- continue
- }
-
- if repo.Object == nil || repo.Object.OID == "" {
- errs = append(errs, fmt.Sprintf("%s@%s: ref %q does not exist", ref.NWO(), ref.Ref, ref.Ref))
- continue
- }
-
- dep := lockfile.Dependency{
- NWO: ref.NWO(),
- Ref: ref.Ref,
- SHA: repo.Object.OID,
- }
- deps = append(deps, dep)
-
- // Extract action.yml or action.yaml content (prefer .yml)
- var yml string
- if repo.Object.File != nil && repo.Object.File.Object != nil {
- yml = repo.Object.File.Object.Text
- } else if repo.Object.FileYAML != nil && repo.Object.FileYAML.Object != nil {
- yml = repo.Object.FileYAML.Object.Text
- }
- ymls = append(ymls, yml)
- }
-
- if len(errs) > 0 {
- return deps, ymls, fmt.Errorf("resolution errors:\n %s", strings.Join(errs, "\n "))
- }
-
- return deps, ymls, nil
-}
diff --git a/pkg/resolver/resolver_test.go b/pkg/resolver/resolver_test.go
deleted file mode 100644
index 2725fd3..0000000
--- a/pkg/resolver/resolver_test.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package resolver
-
-// TODO: Add tests for the resolver package. Requires either:
-// - A mock GraphQL server (preferred for unit tests)
-// - Integration test flag with real GITHUB_TOKEN (for e2e)
-//
-// Test cases needed:
-// - ResolveAll: single action, multiple actions, non-existent repo, non-existent ref
-// - ResolveAllRecursive: composite action with nested uses:, depth limiting, cycle detection
-// - GraphQL error handling: partial failures, SAML errors, rate limiting
-// - action.yml vs action.yaml fallback
-// - Deduplication: same NWO@ref from different paths, same NWO from different composite levels
diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go
deleted file mode 100644
index 3dc0cb8..0000000
--- a/pkg/validate/validate.go
+++ /dev/null
@@ -1,118 +0,0 @@
-// Package validate implements the `actions-lockfile validate` command.
-package validate
-
-import (
- "fmt"
- "strings"
- "os"
-
- "github.com/github/actions-lockfile/pkg/resolver"
- "github.com/github/actions-lockfile/pkg/lockfile"
-)
-
-// Result holds the validation outcome.
-type Result struct {
- Valid bool
- Errors []string
- Warnings []string
-}
-
-// Run validates that pinned dependencies in a workflow file still match live resolution.
-func Run(path string, token string) (*Result, error) {
- result := &Result{Valid: true}
-
- // Load and parse the workflow
- wf, err := lockfile.Load(path)
- if err != nil {
- return nil, fmt.Errorf("loading workflow: %w", err)
- }
-
- // Read existing dependencies
- existingDeps, err := wf.ReadDependencies()
- if err != nil {
- return nil, fmt.Errorf("reading dependencies: %w", err)
- }
-
- if len(existingDeps) == 0 {
- return nil, fmt.Errorf("no dependencies: section found in %s -- run `actions-lockfile pin` first", path)
- }
-
- // Extract action references from the workflow
- refs, localPaths, parseWarnings := wf.ExtractActionRefs()
- result.Warnings = append(result.Warnings, parseWarnings...)
-
- // Discover transitive deps from local composite actions
- if len(localPaths) > 0 {
- localRefs, localWarnings := lockfile.ExtractLocalCompositeRefs(path, localPaths)
- result.Warnings = append(result.Warnings, localWarnings...)
- refs = append(refs, localRefs...)
- }
-
- // Check that every uses: ref has a corresponding dependency entry
- depsByNWO := make(map[string]lockfile.Dependency)
- for _, d := range existingDeps {
- depsByNWO[d.Key()] = d
- }
-
- for _, ref := range refs {
- key := ref.NWO() + "@" + ref.Ref
- if _, ok := depsByNWO[key]; !ok {
- result.Valid = false
- result.Errors = append(result.Errors,
- fmt.Sprintf("MISSING: %s@%s is used in workflow but not in dependencies:", ref.NWO(), ref.Ref))
- }
- }
-
- // Re-resolve all refs (with composite recursion) and compare
- fmt.Fprintf(os.Stderr, "Re-resolving %d action reference(s) (with composite recursion)...\n", len(refs))
- client := resolver.New(token)
- liveDeps, err := client.ResolveAllRecursive(refs)
- if err != nil {
- return nil, fmt.Errorf("resolving actions: %w", err)
- }
-
- liveByKey := make(map[string]lockfile.Dependency)
- for _, d := range liveDeps {
- liveByKey[d.Key()] = d
- }
-
- // Compare each existing dependency against live resolution
- for _, existing := range existingDeps {
- live, ok := liveByKey[existing.Key()]
- if !ok {
- // Dependency in lockfile but not discoverable from uses: refs
- // This could be a stale entry or an injected dependency.
- // Fail by default -- if it's a legitimate transitive dep,
- // re-pinning will rediscover it.
- result.Valid = false
- result.Errors = append(result.Errors,
- fmt.Sprintf("STALE: %s is in dependencies: but not discoverable from workflow uses: refs", existing.Key()))
- continue
- }
-
- if existing.SHA != live.SHA {
- result.Valid = false
- // Sanitize SHAs in error output (prevent log injection)
- safeStoredSHA := sanitizeForLog(existing.SHA)
- safeLiveSHA := sanitizeForLog(live.SHA)
- result.Errors = append(result.Errors,
- fmt.Sprintf("TAMPERED: %s -- pinned sha1-%s but resolved to sha1-%s",
- existing.Key(), safeStoredSHA, safeLiveSHA))
- }
- }
-
- return result, nil
-}
-
-// sanitizeForLog removes control characters from strings before including in log output.
-func sanitizeForLog(s string) string {
- var b strings.Builder
- for _, r := range s {
- if r == '\n' || r == '\r' || r == '\t' {
- b.WriteRune('?')
- } else {
- b.WriteRune(r)
- }
- }
- return b.String()
-}
diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go
deleted file mode 100644
index bcbf81e..0000000
--- a/pkg/validate/validate_test.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package validate
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/github/actions-lockfile/pkg/pin"
-)
-
-// Integration test -- requires GITHUB_TOKEN or gh auth.
-// Tests the full pin-then-validate cycle and tamper detection.
-func TestPinThenValidate(t *testing.T) {
- if os.Getenv("GITHUB_TOKEN") == "" {
- // Try gh auth
- if _, err := os.Stat("/usr/local/bin/gh"); err != nil {
- t.Skip("skipping integration test: no GITHUB_TOKEN and gh not found")
- }
- }
-
- token := os.Getenv("GITHUB_TOKEN")
- if token == "" {
- t.Skip("skipping: GITHUB_TOKEN required for integration tests")
- }
-
- // Copy basic workflow to temp dir
- dir := t.TempDir()
- src := filepath.Join("..", "..", "testdata", "workflows", "basic.yml")
- content, err := os.ReadFile(src)
- if err != nil {
- t.Fatalf("reading source: %v", err)
- }
- tmp := filepath.Join(dir, "test.yml")
- if err := os.WriteFile(tmp, content, 0644); err != nil {
- t.Fatalf("writing temp: %v", err)
- }
-
- // Pin
- if err := pin.Run(tmp, token, pin.Options{}); err != nil {
- t.Fatalf("pin failed: %v", err)
- }
-
- // Validate -- should pass
- result, err := Run(tmp, token)
- if err != nil {
- t.Fatalf("validate failed: %v", err)
- }
- if !result.Valid {
- t.Errorf("expected valid, got invalid: %v", result.Errors)
- }
-}
-
-func TestTamperDetection(t *testing.T) {
- token := os.Getenv("GITHUB_TOKEN")
- if token == "" {
- t.Skip("skipping: GITHUB_TOKEN required for integration tests")
- }
-
- src := filepath.Join("..", "..", "testdata", "workflows", "tampered.yml")
-
- result, err := Run(src, token)
- if err != nil {
- t.Fatalf("validate failed: %v", err)
- }
- if result.Valid {
- t.Error("expected validation to fail for tampered workflow, but it passed")
- }
-
- // Should have TAMPERED errors
- foundTamper := false
- for _, e := range result.Errors {
- if len(e) > 8 && e[:8] == "TAMPERED" {
- foundTamper = true
- }
- }
- if !foundTamper {
- t.Errorf("expected TAMPERED error, got: %v", result.Errors)
- }
-}
diff --git a/schema/lockfile-v0.0.1.json b/schema/lockfile-v0.0.1.json
new file mode 100644
index 0000000..f71312f
--- /dev/null
+++ b/schema/lockfile-v0.0.1.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://gh.io/actions-lockfile/v0.0.1.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-pin`.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["version"],
+ "properties": {
+ "version": {
+ "description": "Lockfile schema version. Only v0.0.1 is supported.",
+ "const": "v0.0.1"
+ },
+ "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:ALGO-HEX (e.g. actions/checkout@v4:sha1-34e1...).",
+ "type": "string",
+ "pattern": "^[^/@:]+/[^/@:]+@[^:]+:(sha1-[0-9a-f]{40}|sha256-[0-9a-f]{64})$"
+ },
+ "action": {
+ "description": "Resolved metadata for a single pinned action.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["branch", "commit", "owner_id", "repo_id"],
+ "properties": {
+ "tag": {
+ "description": "The release or tag the commit was published as, if any. Optional: not every pinned commit corresponds to a tag.",
+ "type": "string"
+ },
+ "branch": {
+ "description": "A branch in the action's repository that contains the pinned commit. Required: it is the authenticity check. A legitimate release commit lives on a branch, while a commit that exists only as a dangling object — pushed to a fork or attached to a PR and never merged — belongs to no branch. Pinning by SHA alone can be tricked into trusting such an impostor commit; verifying the commit is reachable from a branch closes that gap.",
+ "type": "string"
+ },
+ "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). 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"
+ },
+ "repo_id": {
+ "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.",
+ "type": "integer"
+ },
+ "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" }
+ }
+ }
+ }
+ }
+}
diff --git a/script/release b/script/release
new file mode 100755
index 0000000..8e89af1
--- /dev/null
+++ b/script/release
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+#
+# Cut a release of the Go sub-module by creating a path-prefixed tag
+# (go/vX.Y.Z) and a matching GitHub Release.
+#
+# Usage:
+# script/release
+#
+# The next version is computed from the latest go/vX.Y.Z tag and the chosen
+# bump. This script is the single source of truth for the release procedure:
+# CI invokes it from .github/workflows/release.yml, and a maintainer can run
+# it locally (set RELEASE_DRY_RUN=1 to preview without tagging or pushing).
+#
+# Environment:
+# RELEASE_DRY_RUN=1 Compute and print the next version, then stop before
+# mutating anything (no tag, no push, no release).
+#
+# Requires: git, go, make, gh (authenticated, for the non-dry-run path).
+
+set -euo pipefail
+
+MODULE_PATH="github.com/github/actions-lockfile/go"
+TAG_PREFIX="go/v"
+
+die() {
+ echo "error: $*" >&2
+ exit 1
+}
+
+bump="${1:-}"
+case "$bump" in
+ patch | minor | major) ;;
+ *) die "usage: script/release " ;;
+esac
+
+# Run from the repository root regardless of the caller's cwd.
+cd "$(dirname "$0")/.."
+
+# 1. The release commit must already contain generated output. Regenerate and
+# fail if anything under go/ drifts — we tag HEAD, not the working tree, so
+# a dirty tree means the tag would ship stale generated code.
+make generate
+if [ -n "$(git status --porcelain -- go/)" ]; then
+ echo "go/ tree is dirty after 'make generate':" >&2
+ git --no-pager status --porcelain -- go/ >&2
+ die "commit regenerated output before releasing"
+fi
+
+# 2. Gate on a green build at the exact commit being tagged.
+make lint
+make test
+
+# 3. Confirm the module path matches what consumers import.
+actual_module="$(cd go && go list -m)"
+[ "$actual_module" = "$MODULE_PATH" ] ||
+ die "module path is '$actual_module', expected '$MODULE_PATH'"
+
+# 4. Find the latest plain release tag (ignore prereleases like go/v1.2.0-rc.1).
+latest=""
+while IFS= read -r t; do
+ [ -n "$t" ] || continue
+ if printf '%s' "${t#"$TAG_PREFIX"}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ latest="$t"
+ break
+ fi
+done < <(git tag --list "${TAG_PREFIX}*" --sort=-v:refname)
+
+if [ -z "$latest" ]; then
+ base="0.0.0"
+ echo "No existing ${TAG_PREFIX}* release tag; basing bump on v0.0.0."
+else
+ base="${latest#"$TAG_PREFIX"}"
+fi
+
+major="${base%%.*}"
+rest="${base#*.}"
+minor="${rest%%.*}"
+patch="${rest#*.}"
+
+case "$bump" in
+ major) major=$((major + 1)); minor=0; patch=0 ;;
+ minor) minor=$((minor + 1)); patch=0 ;;
+ patch) patch=$((patch + 1)) ;;
+esac
+
+# 5. Go requires a /vN suffix on the module path for major versions >= 2.
+# Until the module path carries that suffix, refuse to mint such a tag.
+if [ "$major" -ge 2 ]; then
+ die "v${major}.x requires the module path to end in /v${major} (it is '$MODULE_PATH'); update go.mod and this script before a major bump"
+fi
+
+version="v${major}.${minor}.${patch}"
+tag="${TAG_PREFIX}${major}.${minor}.${patch}"
+
+git rev-parse -q --verify "refs/tags/${tag}" >/dev/null &&
+ die "tag ${tag} already exists"
+
+echo "Releasing ${tag} (module version ${version}) at $(git rev-parse --short HEAD)"
+
+if [ "${RELEASE_DRY_RUN:-}" = "1" ]; then
+ echo "RELEASE_DRY_RUN=1 set; stopping before tag/push/release."
+ exit 0
+fi
+
+# 6. Tag HEAD and push. Use -c so we don't mutate the caller's git identity.
+git \
+ -c user.name="github-actions[bot]" \
+ -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
+ tag -a "$tag" -m "$tag"
+git push origin "refs/tags/${tag}"
+
+# 7. Cut the GitHub Release from the tag we just pushed.
+gh release create "$tag" --title "$tag" --verify-tag --generate-notes
+
+# 8. Best-effort: prime the Go module proxy so `go get ...@${version}` resolves
+# immediately instead of on first external fetch.
+curl -fsSL "https://proxy.golang.org/${MODULE_PATH}/@v/${version}.info" >/dev/null ||
+ echo "warning: proxy warm failed (non-fatal); the proxy will fetch on first use." >&2
+
+echo "Released ${tag}."
diff --git a/testdata/composite_action.yml b/testdata/composite_action.yml
new file mode 100644
index 0000000..b427fb8
--- /dev/null
+++ b/testdata/composite_action.yml
@@ -0,0 +1,7 @@
+name: composite
+runs:
+ using: composite
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/cache@v4
+ - run: echo "hello"
diff --git a/testdata/node_action.yml b/testdata/node_action.yml
new file mode 100644
index 0000000..5a749a0
--- /dev/null
+++ b/testdata/node_action.yml
@@ -0,0 +1,4 @@
+name: node action
+runs:
+ using: node20
+ main: index.js
diff --git a/testdata/real-world/astral-sh-uv-ci.yml b/testdata/real-world/astral-sh-uv-ci.yml
deleted file mode 100644
index a1838f4..0000000
--- a/testdata/real-world/astral-sh-uv-ci.yml
+++ /dev/null
@@ -1,428 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- workflow_dispatch:
-
-permissions: {}
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: true
-
-jobs:
- plan:
- runs-on: depot-ubuntu-24.04
- outputs:
- test-code: ${{ steps.plan.outputs.test_code }}
- check-schema: ${{ steps.plan.outputs.check_schema }}
- build-release-binaries: ${{ steps.plan.outputs.build_release_binaries }}
- run-checks: ${{ steps.plan.outputs.run_checks }}
- test-publish: ${{ steps.plan.outputs.test_publish }}
- test-windows-trampoline: ${{ steps.plan.outputs.test_windows_trampoline }}
- save-rust-cache: ${{ steps.plan.outputs.save_rust_cache }}
- run-bench: ${{ steps.plan.outputs.run_bench }}
- test-smoke: ${{ steps.plan.outputs.test_smoke }}
- test-ecosystem: ${{ steps.plan.outputs.test_ecosystem }}
- test-integration: ${{ steps.plan.outputs.test_integration }}
- test-system: ${{ steps.plan.outputs.test_system }}
- test-macos: ${{ steps.plan.outputs.test_macos }}
- build-docker: ${{ steps.plan.outputs.build_docker }}
- push-docker: ${{ steps.plan.outputs.push_docker }}
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: "Plan"
- id: plan
- shell: bash
- env:
- GH_REF: ${{ github.ref }}
- HAS_SKIP_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:skip') }}
- HAS_INTEGRATION_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:integration') }}
- HAS_SYSTEM_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:system') }}
- HAS_EXTENDED_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:extended') }}
- HAS_MACOS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:macos') }}
- HAS_PUBLISH_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'test:publish') }}
- HAS_BUILD_SKIP_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:skip') }}
- HAS_BUILD_SKIP_DOCKER_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:skip-docker') }}
- HAS_BUILD_SKIP_RELEASE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:skip-release') }}
- HAS_BUILD_RELEASE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:release') }}
- HAS_BUILD_PUSH_DOCKER_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:push-docker') }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: |
- [[ "$GH_REF" == "refs/heads/main" ]] && on_main_branch=1
- [[ "$HAS_SKIP_LABEL" == "true" ]] && has_skip_label=1
- [[ "$HAS_INTEGRATION_LABEL" == "true" ]] && has_integration_label=1
- [[ "$HAS_SYSTEM_LABEL" == "true" ]] && has_system_label=1
- [[ "$HAS_EXTENDED_LABEL" == "true" ]] && has_extended_label=1
- [[ "$HAS_MACOS_LABEL" == "true" ]] && has_macos_label=1
- [[ "$HAS_PUBLISH_LABEL" == "true" ]] && has_publish_label=1
- [[ "$HAS_BUILD_SKIP_LABEL" == "true" ]] && has_build_skip_label=1
- [[ "$HAS_BUILD_SKIP_DOCKER_LABEL" == "true" ]] && has_build_skip_docker_label=1
- [[ "$HAS_BUILD_SKIP_RELEASE_LABEL" == "true" ]] && has_build_skip_release_label=1
- [[ "$HAS_BUILD_RELEASE_LABEL" == "true" ]] && has_build_release_label=1
- [[ "$HAS_BUILD_PUSH_DOCKER_LABEL" == "true" ]] && has_build_push_docker_label=1
-
- # Detect changed files
- while IFS= read -r file; do
- [[ -z "$file" ]] && continue
- [[ "$file" =~ \.rs$ ]] && rust_code_changed=1
- [[ "$file" == "Cargo.toml" || "$file" == "Cargo.lock" || "$file" =~ ^crates/.*/Cargo\.toml$ ]] && rust_deps_changed=1
- [[ "$file" == "rust-toolchain.toml" || "$file" =~ ^\.cargo/ ]] && rust_config_changed=1
- [[ "$file" == "pyproject.toml" || "$file" =~ ^crates/.*/pyproject\.toml$ ]] && python_config_changed=1
- [[ "$file" =~ ^\.github/workflows/.*\.yml$ ]] && workflow_changed=1
- [[ "$file" == ".github/workflows/build-release-binaries.yml" || "$file" == ".github/workflows/release.yml" ]] && release_workflow_changed=1
- [[ "$file" == "scripts/check_uv_wheel_contents.py" || "$file" == "scripts/patch-dist-manifest-checksums.py" ]] && release_build_changed=1
- [[ "$file" == ".github/workflows/ci.yml" ]] && ci_workflow_changed=1
- [[ "$file" == "uv.schema.json" ]] && schema_changed=1
- [[ "$file" =~ ^crates/uv-publish/ || "$file" =~ ^scripts/publish/ || "$file" == "crates/uv/src/commands/publish.rs" ]] && publish_code_changed=1
- [[ "$file" == ".github/workflows/test-windows-trampolines.yml" ]] && trampoline_workflow_changed=1
- [[ "$file" =~ ^crates/uv-trampoline/ || "$file" =~ ^crates/uv-trampoline-builder/ ]] && trampoline_code_changed=1
- [[ "$file" == "scripts/build-trampolines.sh" || "$file" == "scripts/check-trampoline-version-consistency.py" ]] && trampoline_scripts_changed=1
- [[ "$file" =~ ^crates/uv-build/ ]] && uv_build_changed=1
- [[ "$file" == "Dockerfile" ]] && dockerfile_changed=1
- [[ "$file" == ".github/workflows/build-docker.yml" ]] && docker_workflow_changed=1
- [[ "$file" == ".github/workflows/bench.yml" ]] && bench_workflow_changed=1
- [[ "$file" == ".github/workflows/test-integration.yml" || "$file" =~ ^test/integration/ || "$file" == "scripts/check_registry.py" || "$file" == "scripts/check_cache_compat.py" || "$file" == "scripts/registries-test.py" ]] && integration_changed=1
- [[ "$file" == ".github/workflows/test-system.yml" ]] && system_workflow_changed=1
- [[ "$file" == "scripts/check_system_python.py" || "$file" == "scripts/check_embedded_python.py" ]] && system_test_changed=1
- [[ "$file" =~ ^docs/ || "$file" =~ ^mkdocs.*\.yml$ || "$file" =~ \.md$ || "$file" =~ ^bin/ || "$file" =~ ^assets/ ]] && continue
- any_code_changed=1
- done <<< "$(git diff --name-only "${BASE_SHA:-origin/main}...HEAD")"
-
- # Derived groups
- [[ $rust_code_changed || $rust_deps_changed || $rust_config_changed ]] && any_rust_changed=1
- [[ $python_config_changed || $rust_deps_changed || $rust_config_changed || $uv_build_changed || $release_workflow_changed ]] && release_build_changed=1
- [[ $publish_code_changed || $ci_workflow_changed ]] && publish_changed=1
- [[ $rust_deps_changed || $rust_config_changed || $workflow_changed ]] && cache_relevant_changed=1
- [[ $python_config_changed || $rust_deps_changed || $rust_config_changed || $dockerfile_changed || $docker_workflow_changed ]] && docker_build_changed=1
-
- # Decisions
- [[ ! $has_skip_label && ($any_code_changed || $on_main_branch) ]] && test_code=1
- [[ $schema_changed ]] && check_schema=1
- [[ ! $has_skip_label && ! $has_build_skip_label && ! $has_build_skip_release_label && ($release_build_changed || $has_build_release_label) ]] && build_release_binaries=1
- [[ ! $has_skip_label ]] && run_checks=1
- [[ $publish_changed || $has_publish_label || $has_extended_label || $on_main_branch ]] && test_publish=1
- [[ ! $has_skip_label && ($trampoline_code_changed || $trampoline_scripts_changed || $trampoline_workflow_changed || $rust_deps_changed || $on_main_branch) ]] && test_windows_trampoline=1
- [[ $on_main_branch || $cache_relevant_changed ]] && save_rust_cache=1
- [[ ! $has_skip_label && ($any_rust_changed || $bench_workflow_changed || $on_main_branch) ]] && run_bench=1
- [[ ! $has_skip_label ]] && test_smoke=1
- [[ ! $has_skip_label ]] && test_ecosystem=1
- [[ $has_integration_label || $has_extended_label || $on_main_branch || $integration_changed ]] && test_integration=1
- [[ $has_system_label || $has_extended_label || $on_main_branch || $system_workflow_changed || $system_test_changed ]] && test_system=1
- [[ $has_macos_label || $has_extended_label || $on_main_branch || $build_release_binaries ]] && test_macos=1
- [[ ! $has_build_skip_label && ! $has_build_skip_docker_label && ($docker_build_changed || $has_build_push_docker_label) ]] && build_docker=1
- [[ $has_build_push_docker_label ]] && push_docker=1
-
- # Output (convert 1/empty to true/false for GHA)
- out() { [[ "$2" ]] && echo "$1=true" || echo "$1=false"; }
- {
- out test_code "$test_code"
- out check_schema "$check_schema"
- out build_release_binaries "$build_release_binaries"
- out run_checks "$run_checks"
- out test_publish "$test_publish"
- out test_windows_trampoline "$test_windows_trampoline"
- out save_rust_cache "$save_rust_cache"
- out run_bench "$run_bench"
- out test_smoke "$test_smoke"
- out test_ecosystem "$test_ecosystem"
- out test_integration "$test_integration"
- out test_system "$test_system"
- out test_macos "$test_macos"
- out build_docker "$build_docker"
- out push_docker "$push_docker"
- } >> "$GITHUB_OUTPUT"
-
- check-fmt:
- uses: ./.github/workflows/check-fmt.yml
-
- check-lint:
- needs: plan
- uses: ./.github/workflows/check-lint.yml
- with:
- code-changed: ${{ needs.plan.outputs.test-code }}
- save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}
-
- check-docs:
- needs: plan
- if: ${{ needs.plan.outputs.run-checks == 'true' }}
- uses: ./.github/workflows/check-docs.yml
- secrets: inherit
-
- check-zizmor:
- needs: plan
- if: ${{ needs.plan.outputs.run-checks == 'true' }}
- uses: ./.github/workflows/check-zizmor.yml
- permissions:
- contents: read
- security-events: write
-
- check-publish:
- needs: plan
- if: ${{ needs.plan.outputs.test-code == 'true' }}
- uses: ./.github/workflows/check-publish.yml
-
- check-release:
- needs: plan
- if: ${{ needs.plan.outputs.run-checks == 'true' }}
- uses: ./.github/workflows/check-release.yml
-
- check-generated-files:
- needs: plan
- if: ${{ needs.plan.outputs.test-code == 'true' }}
- uses: ./.github/workflows/check-generated-files.yml
- with:
- schema-changed: ${{ needs.plan.outputs.check-schema }}
- save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}
-
- test:
- needs: plan
- if: ${{ needs.plan.outputs.test-code == 'true' }}
- uses: ./.github/workflows/test.yml
- with:
- save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}
- test-macos: ${{ needs.plan.outputs.test-macos }}
-
- test-windows-trampolines:
- needs: plan
- if: ${{ needs.plan.outputs.test-windows-trampoline == 'true' }}
- uses: ./.github/workflows/test-windows-trampolines.yml
-
- build-dev-binaries:
- needs: plan
- if: ${{ needs.plan.outputs.test-code == 'true' }}
- uses: ./.github/workflows/build-dev-binaries.yml
- with:
- save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}
-
- test-smoke:
- needs:
- - plan
- - build-dev-binaries
- if: ${{ needs.plan.outputs.test-smoke == 'true' }}
- uses: ./.github/workflows/test-smoke.yml
- with:
- sha: ${{ github.sha }}
-
- test-integration:
- needs:
- - plan
- - build-dev-binaries
- if: ${{ needs.plan.outputs.test-integration == 'true' }}
- uses: ./.github/workflows/test-integration.yml
- secrets: inherit
- permissions:
- id-token: write
- with:
- sha: ${{ github.sha }}
-
- test-system:
- needs:
- - plan
- - build-dev-binaries
- if: ${{ needs.plan.outputs.test-system == 'true' }}
- uses: ./.github/workflows/test-system.yml
- with:
- sha: ${{ github.sha }}
-
- test-ecosystem:
- needs:
- - plan
- - build-dev-binaries
- if: ${{ needs.plan.outputs.test-ecosystem == 'true' }}
- uses: ./.github/workflows/test-ecosystem.yml
- with:
- sha: ${{ github.sha }}
-
- build-release-binaries:
- needs: plan
- if: ${{ needs.plan.outputs.build-release-binaries == 'true' }}
- uses: ./.github/workflows/build-release-binaries.yml
- secrets: inherit
-
- build-docker:
- needs: plan
- if: ${{ needs.plan.outputs.build-docker == 'true' }}
- uses: ./.github/workflows/build-docker.yml
- with:
- push-dev: ${{ needs.plan.outputs.push-docker == 'true' }}
- secrets: inherit
- permissions:
- contents: read
- id-token: write
- packages: write
- attestations: write
-
- bench:
- needs: plan
- if: ${{ needs.plan.outputs.run-bench == 'true' }}
- uses: ./.github/workflows/bench.yml
- secrets: inherit
- with:
- save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }}
-
- # This job cannot be moved into a reusable workflow because it includes coverage for uploading
- # attestations and PyPI does not support attestations in reusable workflows.
- test-publish:
- name: "test uv publish"
- timeout-minutes: 20
- needs:
- - plan
- - build-dev-binaries
- runs-on: ubuntu-latest
- # Only the main repository is a trusted publisher
- if: ${{ github.repository == 'astral-sh/uv' && github.event.pull_request.head.repo.fork != true && needs.plan.outputs.test-publish == 'true' }}
- environment:
- name: uv-test-publish
- deployment: false
- env:
- # No dbus in GitHub Actions
- PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
- PYTHON_VERSION: 3.12
- permissions:
- # For trusted publishing
- id-token: write
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- with:
- python-version: "${{ env.PYTHON_VERSION }}"
-
- - name: "Download binary"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: uv-linux-libc-${{ github.sha }}
-
- - name: "Prepare binary"
- run: chmod +x ./uv
-
- - name: "Build astral-test-pypa-gh-action"
- shell: bash -eo pipefail {0}
- run: |
- # Build a yet unused version of `astral-test-pypa-gh-action`
- mkdir astral-test-pypa-gh-action
- cd astral-test-pypa-gh-action
- ../uv init --package --no-workspace
- # Get the latest patch version
- patch_version=$(curl https://test.pypi.org/simple/astral-test-pypa-gh-action/?format=application/vnd.pypi.simple.v1+json | jq --raw-output '[.files[].filename | select(endswith(".tar.gz"))] | last' | grep -oP '(?<=astral_test_pypa_gh_action-0\.1\.)\d+(?=\.tar\.gz)')
- # Set the current version to one higher (which should be unused)
- sed -i "s/0.1.0/0.1.$((patch_version + 1))/g" pyproject.toml
- ../uv build
-
- - name: "Publish astral-test-pypa-gh-action"
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
- with:
- # With this GitHub action, we can't do as rigid checks as with our custom Python script, so we publish more
- # leniently
- skip-existing: "true"
- verbose: "true"
- repository-url: "https://test.pypi.org/legacy/"
- packages-dir: "astral-test-pypa-gh-action/dist"
-
- - name: "Request GitLab OIDC tokens for impersonation"
- uses: digital-blueprint/gitlab-pipeline-trigger-action@c59b56e9d2688ab42c1304322ac8831a4ef6f7d2 # v1.4.0
- with:
- host: gitlab.com
- id: astral-test-publish/astral-test-gitlab-pypi-tp
- ref: main
- trigger_token: ${{ secrets.GITLAB_TEST_PUBLISH_TRIGGER_TOKEN }}
- access_token: ${{ secrets.GITLAB_TEST_PUBLISH_ACCESS_TOKEN }}
- download_artifacts: true
- fail_if_no_artifacts: true
- download_path: ./gitlab-artifacts
-
- - name: "Load GitLab OIDC tokens from GitLab job artifacts"
- id: load-gitlab-oidc-token
- run: |
- # we expect ./gitlab-artifacts/*/artifacts/pypi-id-token to exist
- pypi_id_token_file=$(find ./gitlab-artifacts -type f -name pypi-id-token | head -n 1)
- if [ -z "${pypi_id_token_file}" ]; then
- echo "No pypi-id-token file found in GitLab artifacts"
- exit 1
- fi
- GITLAB_PYPI_OIDC_TOKEN=$(cat "${pypi_id_token_file}")
-
- # we expect ./gitlab-artifacts/*/artifacts/pyx-id-token to exist
- pyx_id_token_file=$(find ./gitlab-artifacts -type f -name pyx-id-token | head -n 1)
- if [ -z "${pyx_id_token_file}" ]; then
- echo "No pyx-id-token file found in GitLab artifacts"
- exit 1
- fi
- GITLAB_PYX_OIDC_TOKEN=$(cat "${pyx_id_token_file}")
-
- # Add secret masks for the tokens.
- echo "::add-mask::$GITLAB_PYPI_OIDC_TOKEN"
- echo "::add-mask::$GITLAB_PYX_OIDC_TOKEN"
-
- echo "GITLAB_PYPI_OIDC_TOKEN=${GITLAB_PYPI_OIDC_TOKEN}" >> "${GITHUB_OUTPUT}"
- echo "GITLAB_PYX_OIDC_TOKEN=${GITLAB_PYX_OIDC_TOKEN}" >> "${GITHUB_OUTPUT}"
-
- - name: "Add password to keyring"
- run: |
- # `keyrings.alt` contains the plaintext keyring
- ./uv tool install --with keyrings.alt keyring
- echo $UV_TEST_PUBLISH_KEYRING | keyring set https://test.pypi.org/legacy/?astral-test-keyring __token__
- env:
- UV_TEST_PUBLISH_KEYRING: ${{ secrets.UV_TEST_PUBLISH_KEYRING }}
-
- - name: "Add password to uv text store"
- run: |
- ./uv auth login https://test.pypi.org/legacy/?astral-test-text-store --token ${UV_TEST_PUBLISH_TEXT_STORE}
- env:
- UV_TEST_PUBLISH_TEXT_STORE: ${{ secrets.UV_TEST_PUBLISH_TEXT_STORE }}
-
- - name: "Publish test packages"
- # `-p 3.12` prefers the python we just installed over the one locked in `.python_version`.
- run: ./uv run --no-project -p "${PYTHON_VERSION}" scripts/publish/test_publish.py --uv ./uv all
- env:
- RUST_LOG: uv=debug,uv_publish=trace
- UV_TEST_PUBLISH_TOKEN: ${{ secrets.UV_TEST_PUBLISH_TOKEN }}
- UV_TEST_PUBLISH_PASSWORD: ${{ secrets.UV_TEST_PUBLISH_PASSWORD }}
- UV_TEST_PUBLISH_GITLAB_PAT: ${{ secrets.UV_TEST_PUBLISH_GITLAB_PAT }}
- UV_TEST_PUBLISH_CODEBERG_TOKEN: ${{ secrets.UV_TEST_PUBLISH_CODEBERG_TOKEN }}
- UV_TEST_PUBLISH_CLOUDSMITH_TOKEN: ${{ secrets.UV_TEST_PUBLISH_CLOUDSMITH_TOKEN }}
- UV_TEST_PUBLISH_PYX_TOKEN: ${{ secrets.UV_TEST_PUBLISH_PYX_TOKEN }}
- UV_TEST_PUBLISH_PYTHON_VERSION: ${{ env.PYTHON_VERSION }}
- UV_TEST_PUBLISH_GITLAB_PYPI_OIDC_TOKEN: ${{ steps.load-gitlab-oidc-token.outputs.GITLAB_PYPI_OIDC_TOKEN }}
- UV_TEST_PUBLISH_GITLAB_PYX_OIDC_TOKEN: ${{ steps.load-gitlab-oidc-token.outputs.GITLAB_PYX_OIDC_TOKEN }}
-
- required-checks-passed:
- name: "all required jobs passed"
- if: always()
- needs:
- - check-fmt
- - check-lint
- - check-docs
- - check-generated-files
- - test
- - build-dev-binaries
- runs-on: ubuntu-slim
- steps:
- - name: "Check required jobs passed"
- run: |
- failing=$(echo "$NEEDS_JSON" | jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | "\(.key): \(.value.result)"')
- if [ -n "$failing" ]; then
- echo "$failing"
- exit 1
- fi
- env:
- NEEDS_JSON: ${{ toJSON(needs) }}
-
-# Automatically generated and managed by: `actions-lockfile pin `
-dependencies:
- - github.com/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd
- - github.com/actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c:sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
- - github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065:sha1-a26af69be951a213d495a4c3e4e4022e16d87065
- - github.com/actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405:sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405
- - github.com/digital-blueprint/gitlab-pipeline-trigger-action@c59b56e9d2688ab42c1304322ac8831a4ef6f7d2:sha1-c59b56e9d2688ab42c1304322ac8831a4ef6f7d2
- - github.com/pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e:sha1-ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
diff --git a/testdata/real-world/cli-cli-deployment.yml b/testdata/real-world/cli-cli-deployment.yml
deleted file mode 100644
index e958952..0000000
--- a/testdata/real-world/cli-cli-deployment.yml
+++ /dev/null
@@ -1,438 +0,0 @@
-name: Deployment
-run-name: ${{ inputs.tag_name }} / ${{ inputs.environment }}
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref_name }}
- cancel-in-progress: true
-
-permissions:
- attestations: write
- contents: write
- id-token: write
-
-on:
- workflow_dispatch:
- inputs:
- tag_name:
- required: true
- type: string
- environment:
- default: production
- type: environment
- platforms:
- default: "linux,macos,windows"
- type: string
- release:
- description: "Whether to create a GitHub Release"
- type: boolean
- default: true
-
-jobs:
- validate-tag-name:
- runs-on: ubuntu-latest
- steps:
- - name: Validate tag name format
- run: |
- if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "Invalid tag name format. Must be in the form v1.2.3"
- exit 1
- fi
- linux:
- needs: validate-tag-name
- runs-on: ubuntu-latest
- environment: ${{ inputs.environment }}
- if: contains(inputs.platforms, 'linux')
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version-file: 'go.mod'
- - name: Install GoReleaser
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
- with:
- # The version is pinned not only for security purposes, but also to avoid breaking
- # our scripts, which rely on the specific file names generated by GoReleaser.
- version: v2.13.1
- install-only: true
- # We temporarily create a tag on HEAD to make the right version embedded
- # in the built binaries, BUT we don't push it to the remote.
- - name: Create temporary tag
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: git tag "$TAG_NAME"
- - name: Build release binaries
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: script/release --local "$TAG_NAME" --platform linux
- - name: Generate web manual pages
- run: |
- go run ./cmd/gen-docs --website --doc-path dist/manual
- tar -czvf dist/manual.tar.gz -C dist -- manual
- - uses: actions/upload-artifact@v7
- with:
- name: linux
- if-no-files-found: error
- retention-days: 7
- path: |
- dist/*.tar.gz
- dist/*.rpm
- dist/*.deb
-
- macos:
- needs: validate-tag-name
- runs-on: macos-latest
- environment: ${{ inputs.environment }}
- if: contains(inputs.platforms, 'macos')
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version-file: 'go.mod'
- - name: Configure macOS signing
- if: inputs.environment == 'production'
- env:
- APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
- APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }}
- APPLE_APPLICATION_CERT_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }}
- run: |
- keychain="$RUNNER_TEMP/buildagent.keychain"
- keychain_password="password1"
-
- security create-keychain -p "$keychain_password" "$keychain"
- security default-keychain -s "$keychain"
- security unlock-keychain -p "$keychain_password" "$keychain"
-
- base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12"
- security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign
- security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain"
- rm "$RUNNER_TEMP/cert.p12"
- - name: Install GoReleaser
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
- with:
- # The version is pinned not only for security purposes, but also to avoid breaking
- # our scripts, which rely on the specific file names generated by GoReleaser.
- version: v2.13.1
- install-only: true
- # We temporarily create a tag on HEAD to make the right version embedded
- # in the built binaries, BUT we don't push it to the remote.
- - name: Create temporary tag
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: git tag "$TAG_NAME"
- - name: Build release binaries
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
- run: script/release --local "$TAG_NAME" --platform macos
- - name: Notarize macOS archives
- if: inputs.environment == 'production'
- env:
- APPLE_ID: ${{ vars.APPLE_ID }}
- APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
- APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
- run: |
- shopt -s failglob
- script/sign dist/gh_*_macOS_*.zip
- - name: Build universal macOS pkg installer
- if: inputs.environment != 'production'
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: script/pkgmacos "$TAG_NAME"
- - name: Build & notarize universal macOS pkg installer
- if: inputs.environment == 'production'
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- APPLE_DEVELOPER_INSTALLER_ID: ${{ vars.APPLE_DEVELOPER_INSTALLER_ID }}
- run: |
- shopt -s failglob
- script/pkgmacos "$TAG_NAME"
- - uses: actions/upload-artifact@v7
- with:
- name: macos
- if-no-files-found: error
- retention-days: 7
- path: |
- dist/*.tar.gz
- dist/*.zip
- dist/*.pkg
-
- windows:
- needs: validate-tag-name
- runs-on: windows-2022
- environment: ${{ inputs.environment }}
- if: contains(inputs.platforms, 'windows')
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version-file: 'go.mod'
- - name: Install GoReleaser
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
- with:
- # The version is pinned not only for security purposes, but also to avoid breaking
- # our scripts, which rely on the specific file names generated by GoReleaser.
- version: v2.13.1
- install-only: true
- - name: Install Azure Code Signing Client
- shell: pwsh
- env:
- ACS_DIR: ${{ runner.temp }}\acs
- ACS_ZIP: ${{ runner.temp }}\acs.zip
- CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
- run: |
- # Download Azure Code Signing client containing the DLL needed for signtool in script/sign
- Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -OutFile $Env:ACS_ZIP -Verbose
- Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
-
- # Generate metadata file for signtool, used in signing box .exe and .msi
- @{
- CertificateProfileName = "GitHubInc"
- CodeSigningAccountName = "GitHubInc"
- CorrelationId = $Env:CORRELATION_ID
- Endpoint = "https://wus3.codesigning.azure.net/"
- } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
-
- # We temporarily create a tag on HEAD to make the right version embedded
- # in the built binaries, BUT we don't push it to the remote.
- - name: Create temporary tag
- shell: bash
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: git tag "$TAG_NAME"
- - name: Authenticate to Azure for code signing
- if: inputs.environment == 'production'
- uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
- with:
- client-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
- tenant-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
- allow-no-subscriptions: true
- # Azure Code Signing authenticates via OIDC (azure/login above). AZURE_CLIENT_ID and AZURE_TENANT_ID
- # are still passed so DefaultAzureCredential can identify the service principal.
- - name: Build release binaries
- shell: bash
- env:
- AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
- AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
- DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
- METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
- TAG_NAME: ${{ inputs.tag_name }}
- run: script/release --local "$TAG_NAME" --platform windows
- - name: Set up MSBuild
- id: setupmsbuild
- uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57
- - name: Build MSI
- shell: bash
- env:
- MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
- run: |
- for ZIP_FILE in dist/gh_*_windows_*.zip; do
- MSI_NAME="$(basename "$ZIP_FILE" ".zip")"
- MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)"
- case "$MSI_NAME" in
- *_386 )
- source_dir="$PWD/dist/windows_windows_386_sse2"
- platform="x86"
- ;;
- *_amd64 )
- source_dir="$PWD/dist/windows_windows_amd64_v1"
- platform="x64"
- ;;
- *_arm64 )
- source_dir="$PWD/dist/windows_windows_arm64_v8.0"
- platform="arm64"
- ;;
- * )
- printf "unsupported architecture: %s\n" "$MSI_NAME" >&2
- exit 1
- ;;
- esac
- "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
- done
- - name: Sign .msi release binaries
- if: inputs.environment == 'production'
- shell: pwsh
- env:
- AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
- AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
- DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
- METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
- run: |
- Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
- .\script\sign.ps1 $_.FullName
- }
- - uses: actions/upload-artifact@v7
- with:
- name: windows
- if-no-files-found: error
- retention-days: 7
- path: |
- dist/*.zip
- dist/*.msi
-
- release:
- runs-on: ubuntu-latest
- needs: [linux, macos, windows]
- environment: ${{ inputs.environment }}
- if: inputs.release
- steps:
- - name: Checkout cli/cli
- uses: actions/checkout@v6
- - name: Merge built artifacts
- uses: actions/download-artifact@v8
- - name: Checkout documentation site
- uses: actions/checkout@v6
- with:
- repository: github/cli.github.com
- path: site
- fetch-depth: 0
- token: ${{ secrets.SITE_DEPLOY_PAT }}
- - name: Update site man pages
- env:
- GIT_COMMITTER_NAME: cli automation
- GIT_AUTHOR_NAME: cli automation
- GIT_COMMITTER_EMAIL: noreply@github.com
- GIT_AUTHOR_EMAIL: noreply@github.com
- TAG_NAME: ${{ inputs.tag_name }}
- run: |
- git -C site rm 'manual/gh*.md' 2>/dev/null || true
- tar -xzvf linux/manual.tar.gz -C site
- git -C site add 'manual/gh*.md'
- sed -i.bak -E "s/(assign version = )\".+\"/\1\"${TAG_NAME#v}\"/" site/index.html
- rm -f site/index.html.bak
- git -C site add index.html
- git -C site diff --quiet --cached || git -C site commit -m "gh ${TAG_NAME#v}"
- - name: Prepare release assets
- env:
- TAG_NAME: ${{ inputs.tag_name }}
- run: |
- shopt -s failglob
- rm -rf dist
- mkdir dist
- mv -v {linux,macos,windows}/gh_* dist/
- - name: Install packaging dependencies
- run: sudo apt-get install -y rpm reprepro
- - name: Set up GPG
- if: inputs.environment == 'production'
- env:
- GPG_PUBKEY: ${{ secrets.GPG_PUBKEY }}
- GPG_KEY: ${{ secrets.GPG_KEY }}
- GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
- GPG_KEYGRIP: ${{ secrets.GPG_KEYGRIP }}
- run: |
- base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes
- base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes
- echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
- gpg-connect-agent RELOADAGENT /bye
- base64 -d <<<"$GPG_PASSPHRASE" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP"
- - name: Sign RPMs
- if: inputs.environment == 'production'
- run: |
- cp script/rpmmacros ~/.rpmmacros
- rpmsign --addsign dist/*.rpm
- - name: Attest release artifacts
- if: inputs.environment == 'production'
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
- with:
- subject-path: "dist/gh_*"
- create-storage-record: false # (default: true)
- - name: Run createrepo
- env:
- GPG_SIGN: ${{ inputs.environment == 'production' }}
- run: |
- mkdir -p site/packages/rpm
- cp dist/*.rpm site/packages/rpm/
- ./script/createrepo.sh
- cp -r dist/repodata site/packages/rpm/
- pushd site/packages/rpm
- [ "$GPG_SIGN" = "false" ] || gpg --yes --detach-sign --armor repodata/repomd.xml
- popd
- - name: Run reprepro
- env:
- GPG_SIGN: ${{ inputs.environment == 'production' }}
- # We are no longer adding to the distribution list.
- # All apt distributions should use "stable" according to our install documentation.
- # In the future we will remove legacy distributions listed here.
- RELEASES: "cosmic eoan disco groovy focal stable oldstable testing sid unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling"
- run: |
- mkdir -p upload
- [ "$GPG_SIGN" = "true" ] || sed -i.bak '/^SignWith:/d' script/distributions
- for release in $RELEASES; do
- for file in dist/*.deb; do
- reprepro --confdir="+b/script" includedeb "$release" "$file"
- done
- done
- cp -a dists/ pool/ upload/
- mkdir -p site/packages
- cp -a upload/* site/packages/
- - name: Create the release
- env:
- # In non-production environments, the assets will not have been signed
- DO_PUBLISH: ${{ inputs.environment == 'production' }}
- TAG_NAME: ${{ inputs.tag_name }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- shopt -s failglob
- pushd dist
- shasum -a 256 gh_* > checksums.txt
- mv checksums.txt gh_${TAG_NAME#v}_checksums.txt
- popd
- release_args=(
- "$TAG_NAME"
- --title "GitHub CLI ${TAG_NAME#v}"
- --target "$GITHUB_SHA"
- --generate-notes
- )
- if [[ $TAG_NAME == *-* ]]; then
- release_args+=( --prerelease )
- fi
- guard="echo"
- [ "$DO_PUBLISH" = "false" ] || guard=""
- script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" --
- - name: Publish site
- env:
- DO_PUBLISH: ${{ inputs.environment == 'production' && !contains(inputs.tag_name, '-') }}
- TAG_NAME: ${{ inputs.tag_name }}
- GIT_COMMITTER_NAME: cli automation
- GIT_AUTHOR_NAME: cli automation
- GIT_COMMITTER_EMAIL: noreply@github.com
- GIT_AUTHOR_EMAIL: noreply@github.com
- working-directory: ./site
- run: |
- git add packages
- git commit -m "Add rpm and deb packages for $TAG_NAME"
- if [ "$DO_PUBLISH" = "true" ]; then
- git push
- else
- git log --oneline @{upstream}..
- git diff --name-status @{upstream}..
- fi
- - name: Bump homebrew-core formula
- uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769
- if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
- with:
- formula-name: gh
- formula-path: Formula/g/gh.rb
- tag-name: ${{ inputs.tag_name }}
- push-to: williammartin/homebrew-core
- env:
- COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}
-
-# Automatically generated and managed by: `actions-lockfile pin `
-dependencies:
- - github.com/actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32:sha1-a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32
- - github.com/actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26:sha1-59d89421af93a897026c735860bf21b6eb4f7b26
- - github.com/actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd
- - github.com/actions/download-artifact@v8:sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
- - github.com/actions/setup-go@v6:sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c
- - github.com/actions/upload-artifact@v7:sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
- - github.com/azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43:sha1-532459ea530d8321f2fb9bb10d1e0bcf23869a43
- - github.com/goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29:sha1-ec59f474b9834571250b370d4735c50f8e2d1e29
- - github.com/microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57:sha1-30375c66a4eea26614e0d39710365f22f8b0af57
- - github.com/mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769:sha1-ccf2332299a883f6af50a1d2d41e5df7904dd769
diff --git a/testdata/real-world/docker-build-push-action-ci.yml b/testdata/real-world/docker-build-push-action-ci.yml
deleted file mode 100644
index d880086..0000000
--- a/testdata/real-world/docker-build-push-action-ci.yml
+++ /dev/null
@@ -1,1555 +0,0 @@
-name: ci
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- workflow_dispatch:
- inputs:
- buildx-version:
- description: 'Buildx version or Git context'
- default: 'latest'
- required: false
- buildkit-image:
- description: 'BuildKit image'
- default: 'moby/buildkit:buildx-stable-1'
- required: false
- schedule:
- - cron: '0 10 * * *'
- push:
- branches:
- - 'master'
- - 'releases/v*'
- pull_request:
-
-env:
- BUILDX_VERSION: edge
- BUILDKIT_IMAGE: moby/buildkit:latest
-
-jobs:
- minimal:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
-
- git-context:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push
- id: docker_build
- uses: ./action
- with:
- file: ./test/Dockerfile
- builder: ${{ steps.buildx.outputs.name }}
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
- -
- name: Check digest
- run: |
- if [ -z "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
-
- git-context-secret:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push
- id: docker_build
- uses: ./action
- with:
- file: ./test/Dockerfile
- builder: ${{ steps.buildx.outputs.name }}
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- secrets: |
- GIT_AUTH_TOKEN=${{ github.token }}
- "MYSECRET=aaaaaaaa
- bbbbbbb
- ccccccccc"
- FOO=bar
- "EMPTYLINE=aaaa
-
- bbbb
- ccc"
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
- -
- name: Check digest
- run: |
- if [ -z "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
-
- path-context:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push
- id: docker_build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- builder: ${{ steps.buildx.outputs.name }}
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
- -
- name: Check digest
- run: |
- if [ -z "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
-
- example:
- runs-on: ubuntu-latest
- env:
- DOCKER_IMAGE: localhost:5000/name/app
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Docker meta
- id: meta
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
- with:
- images: ${{ env.DOCKER_IMAGE }}
- tags: |
- type=schedule
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=semver,pattern={{major}}
- type=sha
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and export to Docker client
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- load: true
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- -
- name: Build and push to local registry
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- -
- name: Inspect image
- run: |
- docker image inspect ${{ env.DOCKER_IMAGE }}:${{ steps.meta.outputs.version }}
- -
- name: Check manifest
- if: github.event_name != 'pull_request'
- run: |
- docker buildx imagetools inspect ${{ env.DOCKER_IMAGE }}:${{ steps.meta.outputs.version }} --format '{{json .}}'
-
- error:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Stop docker
- run: |
- sudo systemctl stop docker docker.socket
- -
- name: Build
- id: docker_build
- continue-on-error: true
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- -
- name: Check
- run: |
- if [ "${{ steps.docker_build.outcome }}" != "failure" ] || [ "${{ steps.docker_build.conclusion }}" != "success" ]; then
- echo "::error::Should have failed"
- exit 1
- fi
-
- error-buildx:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- id: docker_build
- continue-on-error: true
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- push: true
- tags: localhost:5000/name/app:latest
- -
- name: Check
- run: |
- if [ "${{ steps.docker_build.outcome }}" != "failure" ] || [ "${{ steps.docker_build.conclusion }}" != "success" ]; then
- echo "::error::Should have failed"
- exit 1
- fi
-
- docker-driver:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Build
- id: docker_build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- push: true
- tags: localhost:5000/name/app:latest
-
- export-docker:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- load: true
- tags: myimage:latest
- -
- name: Inspect
- run: |
- docker image inspect myimage:latest
-
- secret:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: .
- file: ./test/secret.Dockerfile
- secrets: |
- MYSECRET=foo
- INVALID_SECRET=
-
- secret-envs:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- env:
- ENV_SECRET: foo
- with:
- context: .
- file: ./test/secret.Dockerfile
- secret-envs: |
- MYSECRET=ENV_SECRET
- INVALID_SECRET=
-
- network:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: List networks
- run: docker network ls
- -
- name: Build
- uses: ./
- with:
- context: ./test
- tags: name/app:latest
- network: host
-
- shm-size:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/shmsize.Dockerfile
- tags: name/app:latest
- shm-size: 2g
-
- ulimit:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/ulimit.Dockerfile
- tags: name/app:latest
- ulimit: |
- nofile=1024:1024
- nproc=3
-
- cgroup-parent:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/cgroup.Dockerfile
- tags: name/app:latest
- cgroup-parent: foo
-
- add-hosts:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/addhost.Dockerfile
- tags: name/app:latest
- add-hosts: |
- docker:10.180.0.1
- foo:10.0.0.1
-
- no-cache-filters:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/nocachefilter.Dockerfile
- no-cache-filters: build
- tags: name/app:latest
- cache-from: type=gha,scope=nocachefilter
- cache-to: type=gha,scope=nocachefilter,mode=max
-
- attests-compat:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- include:
- - buildx: edge
- buildkit: moby/buildkit:latest
- - buildx: latest
- buildkit: moby/buildkit:buildx-stable-1
- - buildx: latest
- buildkit: moby/buildkit:v0.10.6
- - buildx: v0.9.1
- buildkit: moby/buildkit:buildx-stable-1
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ matrix.buildx }}
- driver-opts: |
- network=host
- image=${{ matrix.buildkit }}
- -
- name: Build
- uses: ./
- with:
- context: ./test/go
- file: ./test/go/Dockerfile
- outputs: type=cacheonly
-
- provenance:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- include:
- - target: image
- output: type=image,name=localhost:5000/name/app:latest,push=true
- attr: mode=max
- - target: image
- output: type=image,name=localhost:5000/name/app:latest,push=true
- attr: ''
- - target: binary
- output: /tmp/buildx-build
- attr: mode=max
- - target: binary
- output: /tmp/buildx-build
- attr: ''
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test/go
- file: ./test/go/Dockerfile
- target: ${{ matrix.target }}
- outputs: ${{ matrix.output }}
- provenance: ${{ matrix.attr }}
- -
- name: Inspect Provenance
- if: matrix.target == 'image'
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:latest --format '{{json .Provenance}}'
- -
- name: Check output folder
- if: matrix.target == 'binary'
- run: |
- tree /tmp/buildx-build
- -
- name: Print local Provenance
- if: matrix.target == 'binary'
- run: |
- cat /tmp/buildx-build/provenance.json | jq
-
- sbom:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- include:
- - target: image
- output: type=image,name=localhost:5000/name/app:latest,push=true
- - target: binary
- output: /tmp/buildx-build
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test/go
- file: ./test/go/Dockerfile
- target: ${{ matrix.target }}
- outputs: ${{ matrix.output }}
- sbom: true
- cache-from: type=gha,scope=attests-${{ matrix.target }}
- cache-to: type=gha,scope=attests-${{ matrix.target }},mode=max
- -
- name: Inspect SBOM
- if: matrix.target == 'image'
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:latest --format '{{json .SBOM}}'
- -
- name: Check output folder
- if: matrix.target == 'binary'
- run: |
- tree /tmp/buildx-build
- -
- name: Print local SBOM
- if: matrix.target == 'binary'
- run: |
- cat /tmp/buildx-build/sbom.spdx.json | jq
-
- multi:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- dockerfile:
- - multi
- - multi-sudo
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push
- id: docker_build
- uses: ./
- with:
- context: ./test
- file: ./test/${{ matrix.dockerfile }}.Dockerfile
- builder: ${{ steps.buildx.outputs.name }}
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
- -
- name: Check digest
- run: |
- if [ -z "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
-
- digest:
- runs-on: ubuntu-latest
- env:
- DOCKER_IMAGE: localhost:5000/name/app
- strategy:
- fail-fast: false
- matrix:
- driver:
- - docker
- - docker-container
- load:
- - true
- - false
- push:
- - true
- - false
- exclude:
- - driver: docker
- load: true
- push: true
- - driver: docker-container
- load: true
- push: true
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver: ${{ matrix.driver }}
- driver-opts: |
- network=host
- -
- name: Build
- id: docker_build
- uses: ./
- with:
- context: ./test
- load: ${{ matrix.load }}
- push: ${{ matrix.push }}
- tags: ${{ env.DOCKER_IMAGE }}:latest
- platforms: ${{ matrix.platforms }}
- -
- name: Docker images
- run: |
- docker image ls --no-trunc
- -
- name: Check digest
- run: |
- if [[ "${{ matrix.driver }}" = "docker-container" ]] && [[ "${{ matrix.load }}" = "false" ]] && [[ "${{ matrix.push }}" = "false" ]]; then
- if [ -n "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should be empty"
- exit 1
- fi
- elif [[ "${{ matrix.push }}" = "true" ]] && [[ -z "${{ steps.docker_build.outputs.digest }}" ]]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
- -
- name: Check manifest
- if: ${{ matrix.push }}
- run: |
- set -x
- docker buildx imagetools inspect ${{ env.DOCKER_IMAGE }}@${{ steps.docker_build.outputs.digest }} --format '{{json .}}'
- -
- name: Check image ID
- run: |
- if [[ "${{ matrix.driver }}" = "docker-container" ]] && [[ "${{ matrix.load }}" = "false" ]] && [[ "${{ matrix.push }}" = "false" ]]; then
- if [ -n "${{ steps.docker_build.outputs.imageid }}" ]; then
- echo "::error::Image ID should be empty"
- exit 1
- fi
- elif [ -z "${{ steps.docker_build.outputs.imageid }}" ]; then
- echo "::error::Image ID should not be empty"
- exit 1
- fi
- -
- name: Inspect image
- if: ${{ matrix.load }}
- run: |
- set -x
- docker image inspect ${{ steps.docker_build.outputs.imageid }}
-
- registry-cache:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push
- id: docker_build
- uses: ./
- with:
- context: ./test
- file: ./test/multi.Dockerfile
- builder: ${{ steps.buildx.outputs.name }}
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- cache-from: type=registry,ref=localhost:5000/name/app
- cache-to: type=inline
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:latest --format '{{json .}}'
- -
- name: Check digest
- run: |
- if [ -z "${{ steps.docker_build.outputs.digest }}" ]; then
- echo "::error::Digest should not be empty"
- exit 1
- fi
-
- github-cache:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- buildkitd-flags: --debug
- -
- name: Build and push
- uses: ./
- with:
- context: ./test
- file: ./test/multi.Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- cache-from: type=gha,scope=ci-${{ matrix.buildx_version }}
- cache-to: type=gha,scope=ci-${{ matrix.buildx_version }}
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
-
- local-cache:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- buildkitd-flags: --debug
- -
- name: Cache Build
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- with:
- path: /tmp/.buildx-cache
- key: ${{ runner.os }}-local-test-${{ github.sha }}
- restore-keys: |
- ${{ runner.os }}-local-test-
- -
- name: Build and push
- uses: ./
- with:
- context: ./test
- file: ./test/multi.Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- localhost:5000/name/app:latest
- localhost:5000/name/app:1.0.0
- cache-from: type=local,src=/tmp/.buildx-cache
- cache-to: type=local,dest=/tmp/.buildx-cache-new
- -
- name: Inspect
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:1.0.0 --format '{{json .}}'
- -
- # Temp fix
- # https://github.com/docker/build-push-action/issues/252
- # https://github.com/moby/buildkit/issues/1896
- name: Move cache
- run: |
- rm -rf /tmp/.buildx-cache
- mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
- standalone:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Uninstall docker cli
- run: |
- if dpkg -s "docker-ce" >/dev/null 2>&1; then
- sudo dpkg -r --force-depends docker-ce-cli docker-buildx-plugin
- else
- sudo apt-get purge -y moby-cli moby-buildx
- fi
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
-
- named-context-pin:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build base image
- uses: ./
- with:
- context: ./test
- file: ./test/named-context.Dockerfile
- build-contexts: |
- alpine=docker-image://alpine:edge
-
- named-context-docker:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver: docker
- -
- name: Build base image
- uses: ./
- with:
- context: ./test
- file: ./test/named-context-base.Dockerfile
- load: true
- tags: my-base-image:local
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/named-context.Dockerfile
- build-contexts: |
- base=docker-image://my-base-image:local
-
- named-context-container:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- network=host
- -
- name: Build base image
- uses: ./
- with:
- context: ./test
- file: ./test/named-context-base.Dockerfile
- tags: localhost:5000/my-base-image:latest
- push: true
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/named-context.Dockerfile
- build-contexts: |
- alpine=docker-image://localhost:5000/my-base-image:latest
-
- docker-config-malformed:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set malformed docker config
- run: |
- mkdir -p ~/.docker
- echo 'foo_bar' >> ~/.docker/config.json
- -
- name: Build
- uses: ./
- with:
- context: ./test
-
- proxy-docker-config:
- runs-on: ubuntu-latest
- services:
- squid-proxy:
- image: ubuntu/squid:latest@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029
- ports:
- - 3128:3128
- steps:
- -
- name: Check proxy
- run: |
- netstat -aptn
- curl --retry 5 --retry-all-errors --retry-delay 0 --connect-timeout 5 --proxy http://127.0.0.1:3128 -v --insecure --head https://www.google.com
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set proxy config
- run: |
- mkdir -p ~/.docker
- echo '{"proxies":{"default":{"httpProxy":"http://127.0.0.1:3128","httpsProxy":"http://127.0.0.1:3128"}}}' > ~/.docker/config.json
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- network=host
- buildkitd-flags: --debug
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/proxy.Dockerfile
-
- proxy-buildkitd:
- runs-on: ubuntu-latest
- services:
- squid-proxy:
- image: ubuntu/squid:latest@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029
- ports:
- - 3128:3128
- steps:
- -
- name: Check proxy
- run: |
- netstat -aptn
- curl --retry 5 --retry-all-errors --retry-delay 0 --connect-timeout 5 --proxy http://127.0.0.1:3128 -v --insecure --head https://www.google.com
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- network=host
- env.http_proxy=http://127.0.0.1:3128
- env.https_proxy=http://127.0.0.1:3128
- buildkitd-flags: --debug
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
-
- annotations:
- runs-on: ubuntu-latest
- env:
- DOCKER_IMAGE: localhost:5000/name/app
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Docker meta
- id: meta
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
- with:
- images: ${{ env.DOCKER_IMAGE }}
- tags: |
- type=schedule
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=semver,pattern={{major}}
- type=sha
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build and push to local registry
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- annotations: |
- index:com.example.key=value
- index:com.example.key2=value2
- manifest:com.example.key3=value3
- -
- name: Check manifest
- run: |
- docker buildx imagetools inspect ${{ env.DOCKER_IMAGE }}:${{ steps.meta.outputs.version }} --format '{{json .}}'
-
- multi-output:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- buildkitd-flags: --debug
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- outputs: |
- type=image,name=localhost:5000/name/app:latest,push=true
- type=docker,name=app:local
- type=oci,dest=/tmp/oci.tar
- -
- name: Check registry
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:latest --format '{{json .}}'
- -
- name: Check docker
- run: |
- docker image inspect app:local
- -
- name: Check oci
- run: |
- set -ex
- mkdir -p /tmp/oci-out
- tar xf /tmp/oci.tar -C /tmp/oci-out
- tree -nh /tmp/oci-out
-
- load-and-push:
- runs-on: ubuntu-latest
- services:
- registry:
- image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
- ports:
- - 5000:5000
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- network=host
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- buildkitd-flags: --debug
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/Dockerfile
- load: true
- push: true
- tags: localhost:5000/name/app:latest
- -
- name: Check registry
- run: |
- docker buildx imagetools inspect localhost:5000/name/app:latest --format '{{json .}}'
- -
- name: Check docker
- run: |
- docker image inspect localhost:5000/name/app:latest
-
- summary-disable:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
- env:
- DOCKER_BUILD_SUMMARY: false
-
- summary-not-supported:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: v0.12.1
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
-
- record-upload-disable:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
- env:
- DOCKER_BUILD_RECORD_UPLOAD: false
-
- record-retention-days:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- days:
- - 2
- - 0
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
- env:
- DOCKER_BUILD_RECORD_RETENTION_DAYS: ${{ matrix.days }}
-
- checks:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- buildx-version:
- - edge
- - latest
- - v0.14.1
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ matrix.buildx-version }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/lint.Dockerfile
-
- annotations-disabled:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./
- with:
- context: ./test
- file: ./test/lint.Dockerfile
- env:
- DOCKER_BUILD_CHECKS_ANNOTATIONS: false
-
- call-check:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- id: docker_build
- continue-on-error: true
- uses: ./
- with:
- context: ./test
- file: ./test/lint.Dockerfile
- call: check
- -
- name: Check
- run: |
- if [ "${{ steps.docker_build.outcome }}" != "failure" ] || [ "${{ steps.docker_build.conclusion }}" != "success" ]; then
- echo "::error::Should have failed"
- exit 1
- fi
-
- no-default-attestations:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: action
- -
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- with:
- version: ${{ inputs.buildx-version || env.BUILDX_VERSION }}
- driver-opts: |
- image=${{ inputs.buildkit-image || env.BUILDKIT_IMAGE }}
- -
- name: Build
- uses: ./action
- with:
- file: ./test/Dockerfile
- env:
- BUILDX_NO_DEFAULT_ATTESTATIONS: 1
-
-# Automatically generated and managed by: `actions-lockfile pin `
-dependencies:
- - github.com/actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7:sha1-668228422ae6a00e4ad889ee87cd7109ec5666a7
- - github.com/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd
- - github.com/docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf:sha1-030e881283bb7a6894de51c315a6bfe6a94e05cf
- - github.com/docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd:sha1-4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd
- - github.com/docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a:sha1-ce360397dd3f832beb865e1373c09c0e9f86d70a
diff --git a/testdata/real-world/vercel-next.js-build_and_test.yml b/testdata/real-world/vercel-next.js-build_and_test.yml
deleted file mode 100644
index da3d103..0000000
--- a/testdata/real-world/vercel-next.js-build_and_test.yml
+++ /dev/null
@@ -1,1276 +0,0 @@
-name: build-and-test
-
-on:
- push:
- branches: ['canary']
- pull_request:
- types: [opened, synchronize]
-
-concurrency:
- # Limit concurrent runs to 1 per PR,
- # but allow concurrent runs on push if they potentially use different source code
- group: ${{ github.event_name == 'pull_request' && format('{0}-pr-{1}', github.workflow, github.ref_name) || format('{0}-sha-{1}', github.workflow, github.sha) }}
- cancel-in-progress: true
-
-# NOTE: anything in `afterBuild` inherits environment variables defined in
-# `build_reusable.yml` (not these!) because that job executes within the context
-# of that workflow. Environment variables are not automatically passed to
-# reusable workflows.
-env:
- NODE_MAINTENANCE_VERSION: 20
- NODE_LTS_VERSION: 22
-
-jobs:
- optimize-ci:
- uses: ./.github/workflows/graphite_ci_optimizer.yml
- secrets: inherit
-
- changes:
- name: Determine changes
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 25
-
- - name: check for docs only change
- id: docs-change
- run: |
- echo "DOCS_ONLY<> $GITHUB_OUTPUT;
- echo "$(node scripts/run-for-change.mjs --not --type docs --exec echo 'false')" >> $GITHUB_OUTPUT;
- echo 'EOF' >> $GITHUB_OUTPUT
-
- - name: check for release
- id: is-release
- run: |
- if [[ $(node ./scripts/check-is-release.js 2> /dev/null || :) == v* ]];
- then
- echo "IS_RELEASE=true" >> $GITHUB_OUTPUT
- else
- echo "IS_RELEASE=false" >> $GITHUB_OUTPUT
- fi
-
- outputs:
- docs-only: ${{ steps.docs-change.outputs.DOCS_ONLY != 'false' }}
- is-release: ${{ steps.is-release.outputs.IS_RELEASE == 'true' }}
- rspack: >-
- ${{
- steps.is-release.outputs.IS_RELEASE == 'true' || (
- github.event_name == 'pull_request' &&
- contains(github.event.pull_request.labels.*.name, 'Rspack')
- )
- }}
-
- build-native:
- name: build-native
- uses: ./.github/workflows/build_reusable.yml
- needs: ['changes']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
- with:
- skipInstallBuild: 'yes'
- stepName: 'build-native'
- secrets: inherit
-
- build-native-windows:
- name: build-native-windows
- uses: ./.github/workflows/build_reusable.yml
- needs: ['changes']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
- with:
- skipInstallBuild: 'yes'
- stepName: 'build-native-windows'
- runs_on_labels: '["windows","self-hosted","x64"]'
- buildNativeTarget: 'x86_64-pc-windows-msvc'
-
- secrets: inherit
-
- build-next:
- name: build-next
- uses: ./.github/workflows/build_reusable.yml
- with:
- skipNativeBuild: 'yes'
- stepName: 'build-next'
- secrets: inherit
-
- fetch-test-timings:
- name: fetch test timings
- runs-on: ubuntu-latest
- needs: ['changes']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
- steps:
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_LTS_VERSION }}
- check-latest: true
-
- - name: Setup pnpm
- run: |
- npm i -g corepack@0.31
- corepack enable
-
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 25
-
- - name: Install dependencies
- run: pnpm install
-
- - name: Fetch test timings
- run: node run-tests.js --timings --write-timings -g 1/1
- continue-on-error: true
- env:
- KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }}
- KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }}
-
- - name: Ensure test timings file exists
- run: |
- if [ ! -f test-timings.json ]; then
- echo "No timings fetched, creating empty timings file"
- echo '{}' > test-timings.json
- fi
-
- - name: Upload test timings
- uses: actions/upload-artifact@v4
- with:
- name: test-timings
- path: test-timings.json
- # Allows "rerun failed jobs" for N days
- retention-days: 5
- if-no-files-found: error
-
- lint:
- name: lint
- needs: ['build-next']
- uses: ./.github/workflows/build_reusable.yml
- with:
- skipNativeBuild: 'yes'
- skipNativeInstall: 'yes'
- afterBuild: |
- pnpm lint-no-typescript
- pnpm check-examples
- pnpm validate-externals-doc
- stepName: 'lint'
- secrets: inherit
-
- validate-docs-links:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- - name: Setup corepack
- run: |
- npm i -g corepack@0.31
- corepack enable
- - name: 'Run link checker'
- run: node ./.github/actions/validate-docs-links/dist/index.js
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- check-types-precompiled:
- name: types and precompiled
- needs: ['changes', 'build-native', 'build-next']
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: pnpm types-and-precompiled
- stepName: 'types-and-precompiled'
- secrets: inherit
-
- test-cargo-unit:
- name: test cargo unit
- needs: ['changes', 'build-next']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- needsRust: 'yes'
- needsNextest: 'yes'
- skipNativeBuild: 'yes'
- afterBuild: pnpm dlx turbo@${TURBO_VERSION} run test-cargo-unit --remote-cache-timeout 60 --log-order stream
- stepName: 'test-cargo-unit'
- secrets: inherit
-
- test-bench:
- name: test cargo benches
- needs: ['optimize-ci', 'changes', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/test-turbopack-rust-bench-test.yml
- secrets: inherit
-
- rust-check:
- name: rust check
- needs: ['changes', 'build-next']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- needsRust: 'yes'
- skipInstallBuild: 'yes'
- skipNativeBuild: 'yes'
- afterBuild: pnpm dlx turbo@${TURBO_VERSION} run rust-check
- stepName: 'rust-check'
- secrets: inherit
-
- rustdoc-check:
- name: rustdoc check
- needs: ['changes', 'build-next']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- needsRust: 'yes'
- skipInstallBuild: 'yes'
- skipNativeBuild: 'yes'
- afterBuild: ./scripts/deploy-turbopack-docs.sh
- stepName: 'rustdoc-check'
- secrets: inherit
-
- ast-grep:
- needs: ['changes', 'build-next']
- runs-on: ubuntu-latest
- name: ast-grep lint
- steps:
- - uses: actions/checkout@v4
- - name: ast-grep lint step
- uses: ast-grep/action@cf62e780f0c88301228978d593a7784427a097a6 # v1.5.0
- with:
- # Keep in sync with the next.js repo's root package.json
- version: 0.31.0
-
- devlow-bench:
- name: Run devlow benchmarks
- needs: ['optimize-ci', 'changes', 'build-next', 'build-native']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && github.event_name != 'pull_request' }}
-
- strategy:
- fail-fast: false
- matrix:
- mode:
- - '--turbopack=false'
- - '--turbopack=true'
- selector:
- - '--scenario=heavy-npm-deps-dev --page=homepage'
- - '--scenario=heavy-npm-deps-build --page=homepage'
- - '--scenario=heavy-npm-deps-build-turbo-cache-enabled --page=homepage'
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- ./node_modules/.bin/devlow-bench ./scripts/devlow-bench.mjs \
- --datadog=ubuntu-latest-16-core \
- ${{ matrix.mode }} \
- ${{ matrix.selector }}
- stepName: 'devlow-bench-${{ matrix.mode }}-${{ matrix.selector }}'
- secrets: inherit
-
- test-devlow:
- name: test devlow package
- needs: ['optimize-ci', 'changes']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
- uses: ./.github/workflows/build_reusable.yml
- with:
- skipNativeBuild: 'yes'
- stepName: 'test-devlow'
- afterBuild: |
- pnpm run --filter=devlow-bench test
- secrets: inherit
-
- test-turbopack-dev:
- name: test turbopack dev
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export IS_TURBOPACK_TEST=1
- export TURBOPACK_DEV=1
- export NEXT_TEST_MODE=dev
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- export RUST_BACKTRACE=1
-
- node run-tests.js \
- --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' \
- --timings \
- --require-timings \
- -g ${{ matrix.group }}
- testTimingsArtifact: 'test-timings'
- stepName: 'test-turbopack-dev-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-turbopack-integration:
- name: test turbopack integration
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group:
- - 1/13
- - 2/13
- - 3/13
- - 4/13
- - 5/13
- - 6/13
- - 7/13
- - 8/13
- - 9/13
- - 10/13
- - 11/13
- - 12/13
- - 13/13
- # Empty value uses default
- # TODO: Run with React 18.
- # Integration tests use the installed React version in next/package.json.
- # We can't easily switch like we do for e2e tests.
- # Skipping this dimension until we can figure out a way to test multiple React versions.
- react: ['']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export IS_TURBOPACK_TEST=1
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- export RUST_BACKTRACE=1
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type integration
- testTimingsArtifact: 'test-timings'
- stepName: 'test-turbopack-integration-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-turbopack-production:
- name: test turbopack production
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export IS_TURBOPACK_TEST=1
- export TURBOPACK_BUILD=1
- export NEXT_TEST_MODE=start
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- export RUST_BACKTRACE=1
-
- node run-tests.js --timings --require-timings -g ${{ matrix.group }} --type production
- testTimingsArtifact: 'test-timings'
- stepName: 'test-turbopack-production-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-rspack-dev:
- name: test rspack dev
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/5, 2/5, 3/5, 4/5, 5/5]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-dev-tests-manifest.json"
- export NEXT_TEST_MODE=dev
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- # rspack flags
- export NEXT_RSPACK=1
- export NEXT_TEST_USE_RSPACK=1
-
- # HACK: Despite the name, this environment variable is only used to gate
- # tests, so it's applicable to rspack
- export TURBOPACK_DEV=1
-
- node run-tests.js \
- --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' \
- --timings \
- --require-timings \
- -g ${{ matrix.group }}
- testTimingsArtifact: 'test-timings'
- stepName: 'test-rspack-dev-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-rspack-integration:
- name: test rspack development integration
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
- strategy:
- fail-fast: false
- matrix:
- group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
- # Empty value uses default
- # TODO: Run with React 18.
- # Integration tests use the installed React version in next/package.json.
- # We can't easily switch like we do for e2e tests.
- # Skipping this dimension until we can figure out a way to test multiple React versions.
- react: ['']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-dev-tests-manifest.json"
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- # rspack flags
- export NEXT_RSPACK=1
- export NEXT_TEST_USE_RSPACK=1
-
- # HACK: Despite the name, this environment variable is only used to gate
- # tests, so it's applicable to rspack
- export TURBOPACK_DEV=1
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type integration
- testTimingsArtifact: 'test-timings'
- stepName: 'test-rspack-integration-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-rspack-production:
- name: test rspack production
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-build-tests-manifest.json"
- export NEXT_TEST_MODE=start
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- # rspack flags
- export NEXT_RSPACK=1
- export NEXT_TEST_USE_RSPACK=1
-
- # HACK: Despite the name, this environment variable is only used to gate
- # tests, so it's applicable to rspack
- export TURBOPACK_BUILD=1
-
- node run-tests.js --timings --require-timings -g ${{ matrix.group }} --type production
- testTimingsArtifact: 'test-timings'
- stepName: 'test-rspack-production-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-rspack-production-integration:
- name: test rspack production integration
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-next',
- 'build-native',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
- strategy:
- fail-fast: false
- matrix:
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- # Empty value uses default
- # TODO: Run with React 18.
- # Integration tests use the installed React version in next/package.json.
- # We can't easily switch like we do for e2e tests.
- # Skipping this dimension until we can figure out a way to test multiple React versions.
- react: ['']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-build-tests-manifest.json"
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- # rspack flags
- export NEXT_RSPACK=1
- export NEXT_TEST_USE_RSPACK=1
-
- # HACK: Despite the name, this environment variable is only used to gate
- # tests, so it's applicable to rspack
- export TURBOPACK_BUILD=1
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type integration
- testTimingsArtifact: 'test-timings'
- stepName: 'test-rspack-production-integration-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-next-swc-wasm:
- name: test next-swc wasm
- needs: ['optimize-ci', 'changes', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- skipNativeBuild: 'yes'
- skipNativeInstall: 'yes'
- afterBuild: |
- rustup target add wasm32-unknown-unknown
- node ./scripts/normalize-version-bump.js
- pnpm dlx turbo@${TURBO_VERSION} run build-wasm -- --target nodejs
- git checkout .
-
- export NEXT_TEST_MODE=start
- export NEXT_TEST_WASM=true
- export IS_WEBPACK_TEST=1
- node run-tests.js \
- test/production/pages-dir/production/test/index.test.ts \
- test/e2e/streaming-ssr/index.test.ts
- stepName: 'test-next-swc-wasm'
- secrets: inherit
-
- #[NOTE] currently this only checks building wasi target
- test-next-napi-bindings-wasi:
- name: test next-swc wasi
- needs: ['optimize-ci', 'changes', 'build-next']
- # TODO: Re-enable this when https://github.com/napi-rs/napi-rs/issues/2009 is addressed.
- # Specifically, the `platform` value is now `threads` in
- # https://github.com/napi-rs/napi-rs/blob/e4ad4767efaf093fdff3dc768856f6100a6e3f72/cli/src/api/build.ts#L530
- if: false
- # if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- skipNativeBuild: 'yes'
- skipNativeInstall: 'yes'
- afterBuild: |
- rustup target add wasm32-wasip1-threads
- pnpm dlx turbo@${TURBO_VERSION} run build-native-wasi
- stepName: 'test-next-napi-bindings-wasi'
- secrets: inherit
-
- test-unit:
- name: test unit
- needs: ['changes', 'build-next', 'build-native']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- node: [20, 22] # TODO: use env var like [env.NODE_MAINTENANCE_VERSION, env.NODE_LTS_VERSION]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: ${{ matrix.node }}
- afterBuild: node run-tests.js --type unit
- stepName: 'test-unit-${{ matrix.node }}'
-
- secrets: inherit
-
- # TODO: Remove this once we bump minimum Node.js version to v22
- test-next-config-ts-native-ts-dev:
- name: test next-config-ts-native-ts dev
- needs: ['changes', 'build-next', 'build-native']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- node: [22, 24]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: ${{ matrix.node }}
- afterBuild: |
- export __NEXT_NODE_NATIVE_TS_LOADER_ENABLED=true
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- NEXT_TEST_MODE=dev NODE_OPTIONS=--experimental-transform-types node run-tests.js test/e2e/app-dir/next-config-ts-native-ts/**/*.test.ts test/e2e/app-dir/next-config-ts-native-mts/**/*.test.ts
- stepName: 'test-next-config-ts-native-ts-dev-${{ matrix.node }}'
-
- secrets: inherit
-
- # TODO: Remove this once we bump minimum Node.js version to v22
- test-next-config-ts-native-ts-prod:
- name: test next-config-ts-native-ts prod
- needs: ['changes', 'build-next', 'build-native']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- node: [22, 24]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: ${{ matrix.node }}
- afterBuild: |
- export __NEXT_NODE_NATIVE_TS_LOADER_ENABLED=true
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- NEXT_TEST_MODE=start NODE_OPTIONS=--experimental-transform-types node run-tests.js test/e2e/app-dir/next-config-ts-native-ts/**/*.test.ts test/e2e/app-dir/next-config-ts-native-mts/**/*.test.ts
- stepName: 'test-next-config-ts-native-ts-prod-${{ matrix.node }}'
-
- secrets: inherit
-
- test-unit-windows:
- name: test unit windows
- needs: ['changes', 'build-native', 'build-native-windows']
- if: ${{ needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- node: [20, 22] # TODO: use env var like [env.NODE_MAINTENANCE_VERSION, env.NODE_LTS_VERSION]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: ${{ matrix.node }}
- afterBuild: node run-tests.js --type unit
- stepName: 'test-unit-windows-${{ matrix.node }}'
- runs_on_labels: '["windows","self-hosted","x64"]'
- buildNativeTarget: 'x86_64-pc-windows-msvc'
-
- secrets: inherit
-
- test-new-tests-dev:
- name: Test new and changed tests for flakes (dev)
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- # test-new-tests-if
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
- # test-new-tests-end-if
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/5, 2/5, 3/5, 4/5, 5/5]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- node scripts/test-new-tests.mjs \
- --flake-detection \
- --mode dev \
- --group ${{ matrix.group }}
- stepName: 'test-new-tests-dev-${{matrix.group}}'
- timeout_minutes: 60 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
-
- secrets: inherit
-
- test-new-tests-start:
- name: Test new and changed tests for flakes (prod)
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- # test-new-tests-if
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
- # test-new-tests-end-if
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/5, 2/5, 3/5, 4/5, 5/5]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- node scripts/test-new-tests.mjs \
- --flake-detection \
- --mode start \
- --group ${{ matrix.group }}
- stepName: 'test-new-tests-start-${{matrix.group}}'
- timeout_minutes: 60 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
- secrets: inherit
-
- test-new-tests-deploy:
- name: Test new and changed tests when deployed
- needs:
- ['optimize-ci', 'test-prod', 'test-new-tests-dev', 'test-new-tests-start']
- # test-new-tests-if
- if: ${{ needs.optimize-ci.outputs.skip == 'false' }}
- # test-new-tests-end-if
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/5, 2/5, 3/5, 4/5, 5/5]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- # Keep Next.js related env variables in sync with additionalEnv in next-deploy.ts
- afterBuild: |
- export NEXT_ENABLE_ADAPTER=1
- export NEXT_E2E_TEST_TIMEOUT=240000
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- node scripts/test-new-tests.mjs \
- --mode deploy \
- --group ${{ matrix.group }}
- stepName: 'test-new-tests-deploy-${{matrix.group}}'
-
- secrets: inherit
-
- test-new-tests-deploy-cache-components:
- name: Test new and changed tests when deployed (cache components)
- needs:
- [
- 'optimize-ci',
- 'test-cache-components-prod',
- 'test-new-tests-dev',
- 'test-new-tests-start',
- ]
- # test-new-tests-if
- if: ${{ needs.optimize-ci.outputs.skip == 'false' }}
- # test-new-tests-end-if
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/5, 2/5, 3/5, 4/5, 5/5]
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_ENABLE_ADAPTER=1
- export NEXT_EXTERNAL_TESTS_FILTERS="test/deploy-tests-manifest.json,test/cache-components-tests-manifest.json"
- export NEXT_E2E_TEST_TIMEOUT=240000
- node scripts/test-new-tests.mjs \
- --mode deploy \
- --group ${{ matrix.group }}
- stepName: 'test-new-tests-deploy-cache-components-${{matrix.group}}'
-
- secrets: inherit
-
- test-dev: # TODO: rename to include webpack
- name: test dev
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export IS_WEBPACK_TEST=1
- export NEXT_TEST_MODE=dev
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type development
- testTimingsArtifact: 'test-timings'
- stepName: 'test-dev-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-dev-windows:
- name: test dev windows
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native-windows',
- 'build-native',
- 'build-next',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- # Should this be using turbopack? a variation?
- afterBuild: |
- export NEXT_TEST_MODE=dev
- export IS_WEBPACK_TEST=1
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- node run-tests.js \
- test/e2e/app-dir/app/index.test.ts \
- test/e2e/app-dir/app-edge/app-edge.test.ts \
- test/e2e/app-dir/proxy-runtime-nodejs/proxy-runtime-nodejs.test.ts \
- test/development/app-dir/segment-explorer/segment-explorer.test.ts
- stepName: 'test-dev-windows'
- runs_on_labels: '["windows","self-hosted","x64"]'
- buildNativeTarget: 'x86_64-pc-windows-msvc'
- secrets: inherit
-
- test-integration-windows:
- name: test integration windows
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native-windows',
- 'build-native',
- 'build-next',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export IS_WEBPACK_TEST=1
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
- node run-tests.js \
- --concurrency 4 \
- test/production/pages-dir/production/test/index.test.ts \
- test/integration/css-client-nav/test/index.test.ts \
- test/integration/rewrites-has-condition/test/index.test.ts \
- test/integration/create-next-app/index.test.ts \
- test/integration/create-next-app/package-manager/pnpm.test.ts
- stepName: 'test-integration-windows'
- runs_on_labels: '["windows","self-hosted","x64"]'
- buildNativeTarget: 'x86_64-pc-windows-msvc'
- secrets: inherit
-
- test-prod-windows:
- name: test prod windows
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native-windows',
- 'build-native',
- 'build-next',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export NEXT_TEST_MODE=start
- export IS_WEBPACK_TEST=1
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- node run-tests.js --type production \
- test/e2e/app-dir/app/index.test.ts \
- test/e2e/app-dir/app-edge/app-edge.test.ts \
- test/e2e/app-dir/metadata-edge/index.test.ts \
- test/e2e/app-dir/non-root-project-monorepo/non-root-project-monorepo.test.ts \
- test/e2e/app-dir/proxy-runtime-nodejs/proxy-runtime-nodejs.test.ts
- stepName: 'test-prod-windows'
- runs_on_labels: '["windows","self-hosted","x64"]'
- buildNativeTarget: 'x86_64-pc-windows-msvc'
- secrets: inherit
-
- test-prod:
- name: test prod
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- exclude:
- # Excluding React 18 tests unless on `canary` branch until budget is approved.
- - react: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run-react-18-tests') && '18.3.1' }}
- group: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10]
- # Empty value uses default
- react: ['', '18.3.1']
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export IS_WEBPACK_TEST=1
- export NEXT_TEST_MODE=start
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- node run-tests.js --timings --require-timings -g ${{ matrix.group }} --type production
- testTimingsArtifact: 'test-timings'
- stepName: 'test-prod-react-${{ matrix.react }}-${{ matrix.group }}'
- secrets: inherit
-
- test-integration:
- name: test integration
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group:
- - 1/13
- - 2/13
- - 3/13
- - 4/13
- - 5/13
- - 6/13
- - 7/13
- - 8/13
- - 9/13
- - 10/13
- - 11/13
- - 12/13
- - 13/13
- # Empty value uses default
- # TODO: Run with React 18.
- # Integration tests use the installed React version in next/package.json.
- # We can't easily switch like we do for e2e tests.
- # Skipping this dimension until we can figure out a way to test multiple React versions.
- react: ['']
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export IS_WEBPACK_TEST=1
- export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
- export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type integration
- testTimingsArtifact: 'test-timings'
- stepName: 'test-integration-${{ matrix.group }}-react-${{ matrix.react }}'
- secrets: inherit
-
- test-firefox-safari:
- name: test firefox and safari
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- browser: 'firefox webkit'
- afterBuild: |
- pnpm playwright install
-
- # these all run without concurrency because they're heavier
- export TEST_CONCURRENCY=1
- export IS_TURBOPACK_TEST=1
- TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=firefox node run-tests.js \
- test/production/pages-dir/production/test/index.test.ts \
- test/production/chunk-load-failure/chunk-load-failure.test.ts
-
- TURBOPACK_DEV=1 NEXT_TEST_MODE=dev BROWSER_NAME=firefox node run-tests.js \
- test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts
-
- TURBOPACK_DEV=1 NEXT_TEST_MODE=dev BROWSER_NAME=safari node run-tests.js \
- test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts
-
- TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=safari node run-tests.js \
- test/production/pages-dir/production/test/index.test.ts \
- test/production/chunk-load-failure/chunk-load-failure.test.ts \
- test/e2e/basepath/basepath.test.ts \
- test/e2e/basepath/error-pages.test.ts
-
- TURBOPACK_BUILD=1 NEXT_TEST_MODE=start BROWSER_NAME=safari DEVICE_NAME='iPhone XR' node run-tests.js \
- test/production/prerender-prefetch/index.test.ts
- stepName: 'test-firefox-safari'
- secrets: inherit
-
- # Manifest generated via: https://gist.github.com/wyattjoh/2ceaebd82a5bcff4819600fd60126431
- test-cache-components-integration:
- name: test cache components integration
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- uses: ./.github/workflows/build_reusable.yml
- with:
- nodeVersion: 20.9.0
- afterBuild: |
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json"
- export IS_WEBPACK_TEST=1
-
- node run-tests.js \
- --timings \
- --type integration
- stepName: 'test-cache-components-integration'
- secrets: inherit
-
- test-cache-components-dev:
- name: test cache components dev
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json"
- export NEXT_TEST_MODE=dev
- export IS_WEBPACK_TEST=1
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type development
- testTimingsArtifact: 'test-timings'
- stepName: 'test-cache-components-dev-${{ matrix.group }}'
- secrets: inherit
-
- test-cache-components-prod:
- name: test cache components prod
- needs:
- [
- 'optimize-ci',
- 'changes',
- 'build-native',
- 'build-next',
- 'fetch-test-timings',
- ]
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json"
- export NEXT_TEST_MODE=start
- export IS_WEBPACK_TEST=1
-
- node run-tests.js \
- --timings \
- --require-timings \
- -g ${{ matrix.group }} \
- --type production
- testTimingsArtifact: 'test-timings'
- stepName: 'test-cache-components-prod-${{ matrix.group }}'
- secrets: inherit
-
- test-node-streams-dev:
- name: test node streams dev
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_USE_NODE_STREAMS=true
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json,test/use-node-streams-tests-manifest.json"
- export NEXT_TEST_MODE=dev
- export IS_TURBOPACK_TEST=1
- export TURBOPACK_DEV=1
-
- node run-tests.js \
- --timings \
- -g ${{ matrix.group }} \
- --type development
- stepName: 'test-node-streams-dev-${{ matrix.group }}'
- secrets: inherit
-
- test-node-streams-prod:
- name: test node streams prod
- needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
- if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
-
- strategy:
- fail-fast: false
- matrix:
- group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
- uses: ./.github/workflows/build_reusable.yml
- with:
- afterBuild: |
- export __NEXT_USE_NODE_STREAMS=true
- export __NEXT_CACHE_COMPONENTS=true
- export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
- export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
- export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json,test/use-node-streams-tests-manifest.json"
- export NEXT_TEST_MODE=start
- export IS_TURBOPACK_TEST=1
- export TURBOPACK_BUILD=1
-
- node run-tests.js \
- --timings \
- -g ${{ matrix.group }} \
- --type production
- stepName: 'test-node-streams-prod-${{ matrix.group }}'
- secrets: inherit
-
- tests-pass:
- needs:
- [
- 'build-native',
- 'build-next',
- 'lint',
- 'validate-docs-links',
- 'check-types-precompiled',
- 'test-unit',
- 'test-next-config-ts-native-ts-dev',
- 'test-next-config-ts-native-ts-prod',
- 'test-dev',
- 'test-prod',
- 'test-integration',
- 'test-firefox-safari',
- 'test-cache-components-dev',
- 'test-cache-components-prod',
- 'test-cache-components-integration',
- 'test-node-streams-dev',
- 'test-node-streams-prod',
- 'test-cargo-unit',
- 'rust-check',
- 'rustdoc-check',
- 'test-next-swc-wasm',
- 'test-turbopack-dev',
- 'test-turbopack-integration',
- 'test-new-tests-dev',
- 'test-new-tests-start',
- 'test-new-tests-deploy',
- 'test-new-tests-deploy-cache-components',
- 'test-turbopack-production',
- 'test-unit-windows',
- 'test-dev-windows',
- 'test-integration-windows',
- 'test-prod-windows',
- ]
-
- if: always()
- runs-on: ubuntu-latest
- # Coupled with retry logic in retry_test.yml
- name: thank you, next
- steps:
- - run: exit 1
- if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
-
-# Automatically generated and managed by: `actions-lockfile pin `
-dependencies:
- - github.com/actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5
- - github.com/actions/setup-node@v4:sha1-49933ea5288caeca8642d1e84afbd3f7d6820020
- - github.com/actions/upload-artifact@v4:sha1-ea165f8d65b6e75b540449e92b4886f43607fa02
- - github.com/ast-grep/action@cf62e780f0c88301228978d593a7784427a097a6:sha1-cf62e780f0c88301228978d593a7784427a097a6
diff --git a/testdata/workflows/basic.yml b/testdata/workflows/basic.yml
deleted file mode 100644
index 1e7ad23..0000000
--- a/testdata/workflows/basic.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-# Test: basic workflow with two well-known public actions
-name: basic-ci
-on:
- push:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- - run: go test ./...
diff --git a/testdata/workflows/composite.yml b/testdata/workflows/composite.yml
deleted file mode 100644
index 68a93b6..0000000
--- a/testdata/workflows/composite.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Test: workflow using a composite action (triggers recursive resolution)
-name: composite-test
-on:
- push:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: nodeselector/actions-test-fixtures/simple-composite@main
- - run: echo "done"
diff --git a/testdata/workflows/mixed.yml b/testdata/workflows/mixed.yml
deleted file mode 100644
index 4ae6081..0000000
--- a/testdata/workflows/mixed.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-# Test: workflow with mixed action types (some should be ignored by lockfile)
-name: mixed-uses
-on:
- push:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: ./local-action
- - uses: docker://alpine:3.19
- - uses: actions/setup-node@v4
diff --git a/testdata/workflows/nested-composite.yml b/testdata/workflows/nested-composite.yml
deleted file mode 100644
index d61835a..0000000
--- a/testdata/workflows/nested-composite.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Test: workflow using a nested composite (depth 2 recursion)
-name: nested-composite-test
-on:
- push:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: nodeselector/actions-test-fixtures/nested-composite@main
- - run: echo "done"
diff --git a/testdata/workflows/path-action.yml b/testdata/workflows/path-action.yml
deleted file mode 100644
index 123e2f9..0000000
--- a/testdata/workflows/path-action.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-# Test: workflow with path-based action reference
-name: cache-test
-on:
- push:
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/cache/restore@v4
- - run: make build
- - uses: actions/cache/save@v4
diff --git a/testdata/workflows/sha-pinned.yml b/testdata/workflows/sha-pinned.yml
deleted file mode 100644
index f4a540e..0000000
--- a/testdata/workflows/sha-pinned.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-# Test: workflow with SHA-pinned action (already pinned, should resolve to same SHA)
-name: already-pinned
-on:
- workflow_dispatch:
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
diff --git a/testdata/workflows/tampered.yml b/testdata/workflows/tampered.yml
deleted file mode 100644
index cafc8ab..0000000
--- a/testdata/workflows/tampered.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-# Test: workflow with tampered dependency -- SHA is wrong
-name: tampered
-on:
- push:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
-
-# Automatically generated and managed by: `actions-lockfile pin `
-dependencies:
- - github.com/actions/checkout@v4:sha1-0000000000000000000000000000000000000000
- - github.com/actions/setup-go@v5:sha1-1111111111111111111111111111111111111111