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