diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ddbd9..96bddc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,11 +145,12 @@ jobs: - name: Update lockfile env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} run: | - go run ./cmd/lockfile-update \ + bin/phpup lockfile-update \ -catalog ./catalog \ -lockfile ./bundles.lock \ - -registry "ghcr.io/${{ github.repository_owner }}" + -registry "ghcr.io/$OWNER" - name: Commit lockfile (if changed) run: | git config user.name "buildrush-bot" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 078502e..0ac4516 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -39,7 +39,6 @@ jobs: cp bundles.lock cmd/phpup/bundles.lock GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o dist/phpup-linux-amd64 ./cmd/phpup - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o dist/planner-linux-amd64 ./cmd/planner rm -f cmd/phpup/bundles.lock (cd dist && sha256sum -- * > sha256sums.txt) diff --git a/.golangci.yml b/.golangci.yml index 7b36c91..6800f7e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -54,16 +54,16 @@ linters: # no untrusted input touches the command vector - linters: [gosec] text: "G204" - path: cmd/lockfile-update/ - # Subprocess calls in gc-bundles execute git with internally-constructed args only; + path: internal/lockfileupdate/ + # Subprocess calls in gc-bundles execute gh/git with internally-constructed args only; # no untrusted input touches the command vector - linters: [gosec] text: "G204" - path: cmd/gc-bundles/ + path: internal/gcbundles/ # CommitOpts (96 bytes) parameter passing cost is negligible vs. git subprocess invocation cost - linters: [gocritic] text: "hugeParam" - path: cmd/lockfile-update/commit.go + path: internal/lockfileupdate/commit.go paths: - third_party$ - builtin$ diff --git a/CLAUDE.md b/CLAUDE.md index 348bb05..4c19add 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,7 @@ All code changes MUST pass `make check` before committing. This runs: ## Architecture -- Go runtime binary (`cmd/phpup/`) resolves inputs against an embedded lockfile, fetches OCI bundles from GHCR, extracts, composes, and exports a PHP environment. -- Go planner tool (`cmd/planner/`) expands catalog YAML into GitHub Actions build matrices. +- Go runtime binary (`cmd/phpup/`) resolves inputs against an embedded lockfile, fetches OCI bundles from GHCR, extracts, composes, and exports a PHP environment. Also hosts maintainer subcommands: `phpup plan` (build-matrix planner), `phpup lockfile update`, `phpup gc-bundles`, `phpup hermetic-audit`, `phpup compat-diff` — all backed by packages in `internal/`. - Catalog (`catalog/`) is declarative YAML describing what to build. - Builders (`builders/`) are shell scripts that compile PHP and extensions. - Workflows (`.github/workflows/`) orchestrate the build pipeline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 403e25e..a1ed5d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Practical implications: - **Don't force-push while CI is in flight.** The bot pushes with `--force-with-lease`; it will refuse to overwrite a newer tip, and the pipeline will fail the run. Wait for a green CI run before rebasing, then `git pull --rebase` to pick up the bot's commit. - **Fork PRs are blocked from auto-publishing.** GitHub does not grant fork PRs write access to the head ref or packages. A maintainer must label the PR `safe-to-build` and re-run the pipeline. -- **Declined PRs leave no trace on main.** Orphan bundles accumulate on GHCR under the PR branch's lifetime; run `go run ./cmd/gc-bundles --org buildrush --min-age-days 30` periodically (or when GHCR quota pressure warrants) to reap them. +- **Declined PRs leave no trace on main.** Orphan bundles accumulate on GHCR under the PR branch's lifetime; run `phpup gc-bundles --org buildrush --min-age-days 30` periodically (or when GHCR quota pressure warrants) to reap them. See `docs/superpowers/specs/2026-04-20-bundle-schema-and-rollout-design.md` for the full rollout design. @@ -67,11 +67,16 @@ make build-linux-amd64 # Cross-compile for Linux ## Project Structure ``` -cmd/phpup/ — Runtime binary (what users execute) -cmd/planner/ — Build matrix planner (CI tool) -internal/ — Go packages (plan, resolve, oci, extract, compose, env, cache, catalog, lockfile, planner) +cmd/phpup/ — Runtime binary (what users + CI execute). Hosts the + runtime "install" flow plus maintainer subcommands: + build, test, push, plan, lockfile-update, gc-bundles, + hermetic-audit, compat-diff. +internal/ — Go packages backing the phpup binary: plan, resolve, + oci, extract, compose, env, cache, catalog, lockfile, + planner, registry, build, testsuite, gcbundles, + hermeticaudit, lockfileupdate, compatdiff. catalog/ — Declarative build specs (YAML) builders/ — Shell scripts that compile PHP and extensions .github/workflows/ — CI/CD pipeline -test/ — Smoke and integration tests +test/ — Smoke and compat fixtures ``` diff --git a/Makefile b/Makefile index 4cebab5..7e7df77 100644 --- a/Makefile +++ b/Makefile @@ -69,19 +69,18 @@ test: cmd/phpup/bundles.lock test-node: npm test -# Build all binaries (native platform) +# Build phpup (native platform). The planner, lockfile-update, gc-bundles, +# hermetic-audit, and compat-diff tools now live as subcommands under phpup +# (see `phpup --help`); no separate binaries to cross-compile. build: cmd/phpup/bundles.lock go build -o bin/phpup ./cmd/phpup - go build -o bin/planner ./cmd/planner -# Cross-compile for Linux +# Cross-compile phpup for Linux build-linux-amd64: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/phpup-linux-amd64 ./cmd/phpup - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/planner-linux-amd64 ./cmd/planner build-linux-arm64: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/phpup-linux-arm64 ./cmd/phpup - GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/planner-linux-arm64 ./cmd/planner # Build a PHP core bundle locally via phpup (docker-wrapped under the hood). # Invocation: @@ -180,5 +179,5 @@ clean: rm -rf bin/ dist/ # Dry-run the GC tool locally (requires gh auth login + network access). -gc-bundles-dry-run: - go run ./cmd/gc-bundles --org buildrush --min-age-days 30 +gc-bundles-dry-run: $(PHPUP_BIN) + $(PHPUP_BIN) gc-bundles --org buildrush --min-age-days 30 diff --git a/README.md b/README.md index 53d5348..8cf823d 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ OS × ARCH × PHP cell and executes the fixture matrix in The fixtures use v2-shaped inputs (`php-version`, `extensions`, `ini-values`, `coverage`), so any drift from v2 semantics surfaces as a fixture failure. A side-by-side diff against `shivammathur/setup-php@v2` -can be reproduced on demand with `go run ./cmd/compat-diff`; the pinned +can be reproduced on demand with `phpup compat-diff`; the pinned v2 SHA and the accepted-deviation allowlist live in [`docs/compat-matrix.md`](docs/compat-matrix.md). diff --git a/cmd/phpup/main.go b/cmd/phpup/main.go index 7aa7ce9..622b110 100644 --- a/cmd/phpup/main.go +++ b/cmd/phpup/main.go @@ -3,6 +3,7 @@ package main import ( "context" _ "embed" + "errors" "flag" "fmt" "log" @@ -18,12 +19,17 @@ import ( "github.com/buildrush/setup-php/internal/cache" "github.com/buildrush/setup-php/internal/catalog" "github.com/buildrush/setup-php/internal/compat" + "github.com/buildrush/setup-php/internal/compatdiff" "github.com/buildrush/setup-php/internal/compose" "github.com/buildrush/setup-php/internal/env" "github.com/buildrush/setup-php/internal/extract" + "github.com/buildrush/setup-php/internal/gcbundles" + "github.com/buildrush/setup-php/internal/hermeticaudit" "github.com/buildrush/setup-php/internal/lockfile" + "github.com/buildrush/setup-php/internal/lockfileupdate" "github.com/buildrush/setup-php/internal/oci" "github.com/buildrush/setup-php/internal/plan" + "github.com/buildrush/setup-php/internal/planner" "github.com/buildrush/setup-php/internal/resolve" "github.com/buildrush/setup-php/internal/testsuite" "github.com/buildrush/setup-php/internal/version" @@ -77,6 +83,62 @@ func main() { return } + // `phpup compat-diff …` compares our probe output to shivammathur's + // v2 probe output, filtering through the compat-matrix allowlist. + // Preserves the retired cmd/compat-diff exit convention: 0 = match, + // 1 = unexplained deviation(s), 2 = malformed input / usage error. + if len(os.Args) > 1 && os.Args[1] == "compat-diff" { + os.Exit(int(compatdiff.Main(os.Args[2:]))) + } + + // `phpup lockfile-update …` regenerates bundles.lock from the current + // catalog and the state of GHCR. Invoked by ci.yml::publish after a + // main-branch bundle rebuild; same flag surface + behaviour as the + // retired cmd/lockfile-update binary. + if len(os.Args) > 1 && os.Args[1] == "lockfile-update" { + if err := lockfileupdate.Main(os.Args[2:]); err != nil { + log.Fatalf("%v", err) + } + return + } + + // `phpup gc-bundles …` prunes unreferenced GHCR bundles via the + // `gh` CLI (dry-run by default; --confirm to actually delete). + // Same flag surface as the retired cmd/gc-bundles binary. + if len(os.Args) > 1 && os.Args[1] == "gc-bundles" { + if err := gcbundles.Main(os.Args[2:]); err != nil { + log.Fatalf("%v", err) + } + return + } + + // `phpup hermetic-audit …` verifies a built bundle's ELF shared-lib + // closure against an OS runner image. Preserves the pre-refactor + // cmd/hermetic-audit exit convention: 0 = clean, 1 = findings, 2 = error. + if len(os.Args) > 1 && os.Args[1] == "hermetic-audit" { + err := hermeticaudit.Main(os.Args[2:]) + switch { + case err == nil: + return + case errors.Is(err, hermeticaudit.ErrFindings): + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "::error::phpup hermetic-audit: %v\n", err) + os.Exit(2) + } + } + + // `phpup plan …` drives the matrix planner that generates the per-cell + // build matrices (consumed by older workflow orchestrators and by + // maintainers debugging catalog/lockfile interaction locally). Same + // flags as the retired cmd/planner binary; byte-identical output. + if len(os.Args) > 1 && os.Args[1] == "plan" { + if err := planner.Main(os.Args[2:]); err != nil { + log.Fatalf("%v", err) + } + return + } + // `phpup push --from oci-layout: --to ghcr.io/` promotes // every manifest in a local oci-layout to a remote registry. Lives // at the top level (rather than under `phpup internal`) because the diff --git a/cmd/compat-diff/allowlist.go b/internal/compatdiff/allowlist.go similarity index 98% rename from cmd/compat-diff/allowlist.go rename to internal/compatdiff/allowlist.go index 84df06c..d9e9d1e 100644 --- a/cmd/compat-diff/allowlist.go +++ b/internal/compatdiff/allowlist.go @@ -1,4 +1,4 @@ -package main +package compatdiff import ( "errors" diff --git a/cmd/compat-diff/allowlist_test.go b/internal/compatdiff/allowlist_test.go similarity index 98% rename from cmd/compat-diff/allowlist_test.go rename to internal/compatdiff/allowlist_test.go index 83cd689..9f4828c 100644 --- a/cmd/compat-diff/allowlist_test.go +++ b/internal/compatdiff/allowlist_test.go @@ -1,4 +1,4 @@ -package main +package compatdiff import ( "path/filepath" diff --git a/cmd/compat-diff/diff.go b/internal/compatdiff/diff.go similarity index 99% rename from cmd/compat-diff/diff.go rename to internal/compatdiff/diff.go index cc0e516..1528027 100644 --- a/cmd/compat-diff/diff.go +++ b/internal/compatdiff/diff.go @@ -1,4 +1,4 @@ -package main +package compatdiff import ( "encoding/json" diff --git a/cmd/compat-diff/main.go b/internal/compatdiff/diff_main.go similarity index 75% rename from cmd/compat-diff/main.go rename to internal/compatdiff/diff_main.go index 3b959ce..f607da4 100644 --- a/cmd/compat-diff/main.go +++ b/internal/compatdiff/diff_main.go @@ -1,5 +1,5 @@ // cmd/compat-diff/main.go -package main +package compatdiff import ( "flag" @@ -78,10 +78,28 @@ func run(args cliArgs, stdout, stderr *os.File) int { return exitDiff } -func main() { - args, code := parseFlags(os.Args[1:], os.Stderr) +// ExitCode is returned by Main so the phpup dispatcher can preserve the +// pre-refactor cmd/compat-diff exit convention: +// +// 0 = match (no deviations) +// 1 = deviation(s) found +// 2 = usage / I/O / malformed-input error +type ExitCode int + +const ( + ExitMatch ExitCode = exitMatch + ExitDiff ExitCode = exitDiff + ExitMalformed ExitCode = exitMalformed +) + +// Main is the entry point for `phpup compat-diff`. args is everything after +// the subcommand token. Returns an ExitCode the phpup dispatcher converts to +// os.Exit; byte-identical stdout/stderr to the retired cmd/compat-diff +// binary for the same inputs. +func Main(args []string) ExitCode { + parsed, code := parseFlags(args, os.Stderr) if code != 0 { - os.Exit(code) + return ExitCode(code) } - os.Exit(run(args, os.Stdout, os.Stderr)) + return ExitCode(run(parsed, os.Stdout, os.Stderr)) } diff --git a/cmd/compat-diff/main_test.go b/internal/compatdiff/diff_main_test.go similarity index 86% rename from cmd/compat-diff/main_test.go rename to internal/compatdiff/diff_main_test.go index c54572e..42c08e9 100644 --- a/cmd/compat-diff/main_test.go +++ b/internal/compatdiff/diff_main_test.go @@ -1,5 +1,4 @@ -// cmd/compat-diff/main_test.go -package main +package compatdiff import ( "os" @@ -9,20 +8,47 @@ import ( "testing" ) +// buildBinary builds the phpup binary (which now hosts compat-diff as a +// subcommand) into a t.TempDir(). Tests then invoke ` compat-diff …` +// to exercise the full dispatch path, matching the pre-consolidation +// integration-test shape. func buildBinary(t *testing.T) string { t.Helper() - bin := t.TempDir() + "/compat-diff" - cmd := exec.Command("go", "build", "-o", bin, ".") + // Resolve repo root from this file's package: internal/compatdiff/ → two + // levels up. Avoids hard-coding CWD expectations that flake under `go + // test ./…` vs. running from the package dir. + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + repoRoot := filepath.Clean(filepath.Join(wd, "..", "..")) + // Ensure the embedded lockfile is present so `go build ./cmd/phpup` + // succeeds (same prepare step the repo Makefile does). + src := filepath.Join(repoRoot, "bundles.lock") + dst := filepath.Join(repoRoot, "cmd", "phpup", "bundles.lock") + if _, err := os.Stat(dst); os.IsNotExist(err) { + data, err := os.ReadFile(filepath.Clean(src)) + if err != nil { + t.Fatalf("read bundles.lock: %v", err) + } + if err := os.WriteFile(dst, data, 0o600); err != nil { + t.Fatalf("write cmd/phpup/bundles.lock: %v", err) + } + t.Cleanup(func() { _ = os.Remove(dst) }) + } + bin := t.TempDir() + "/phpup" + cmd := exec.Command("go", "build", "-o", bin, "./cmd/phpup") + cmd.Dir = repoRoot out, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("build: %v: %s", err, out) + t.Fatalf("build phpup: %v: %s", err, out) } return bin } func TestMissingFlagsExits2(t *testing.T) { bin := buildBinary(t) - cmd := exec.Command(bin) + cmd := exec.Command(bin, "compat-diff") out, err := cmd.CombinedOutput() ee, ok := err.(*exec.ExitError) if !ok { @@ -38,7 +64,7 @@ func TestMissingFlagsExits2(t *testing.T) { func runCLI(t *testing.T, bin string, args ...string) (output string, exitCode int) { t.Helper() - cmd := exec.Command(bin, args...) + cmd := exec.Command(bin, append([]string{"compat-diff"}, args...)...) out, err := cmd.CombinedOutput() if err == nil { return string(out), 0 diff --git a/cmd/compat-diff/diff_test.go b/internal/compatdiff/diff_test.go similarity index 99% rename from cmd/compat-diff/diff_test.go rename to internal/compatdiff/diff_test.go index 7a3e206..30a9612 100644 --- a/cmd/compat-diff/diff_test.go +++ b/internal/compatdiff/diff_test.go @@ -1,4 +1,4 @@ -package main +package compatdiff import ( "path/filepath" diff --git a/cmd/compat-diff/testdata/compat-matrix-empty.md b/internal/compatdiff/testdata/compat-matrix-empty.md similarity index 100% rename from cmd/compat-diff/testdata/compat-matrix-empty.md rename to internal/compatdiff/testdata/compat-matrix-empty.md diff --git a/cmd/compat-diff/testdata/compat-matrix-entries.md b/internal/compatdiff/testdata/compat-matrix-entries.md similarity index 100% rename from cmd/compat-diff/testdata/compat-matrix-entries.md rename to internal/compatdiff/testdata/compat-matrix-entries.md diff --git a/cmd/compat-diff/testdata/compat-matrix-malformed.md b/internal/compatdiff/testdata/compat-matrix-malformed.md similarity index 100% rename from cmd/compat-diff/testdata/compat-matrix-malformed.md rename to internal/compatdiff/testdata/compat-matrix-malformed.md diff --git a/cmd/compat-diff/testdata/compat-matrix-no-markers.md b/internal/compatdiff/testdata/compat-matrix-no-markers.md similarity index 100% rename from cmd/compat-diff/testdata/compat-matrix-no-markers.md rename to internal/compatdiff/testdata/compat-matrix-no-markers.md diff --git a/cmd/compat-diff/testdata/probe-bare-ext-empty.json b/internal/compatdiff/testdata/probe-bare-ext-empty.json similarity index 100% rename from cmd/compat-diff/testdata/probe-bare-ext-empty.json rename to internal/compatdiff/testdata/probe-bare-ext-empty.json diff --git a/cmd/compat-diff/testdata/probe-bare-ini-shift.json b/internal/compatdiff/testdata/probe-bare-ini-shift.json similarity index 100% rename from cmd/compat-diff/testdata/probe-bare-ini-shift.json rename to internal/compatdiff/testdata/probe-bare-ini-shift.json diff --git a/cmd/compat-diff/testdata/probe-bare.json b/internal/compatdiff/testdata/probe-bare.json similarity index 100% rename from cmd/compat-diff/testdata/probe-bare.json rename to internal/compatdiff/testdata/probe-bare.json diff --git a/cmd/gc-bundles/filter.go b/internal/gcbundles/filter.go similarity index 96% rename from cmd/gc-bundles/filter.go rename to internal/gcbundles/filter.go index fb2a752..8739365 100644 --- a/cmd/gc-bundles/filter.go +++ b/internal/gcbundles/filter.go @@ -1,4 +1,4 @@ -package main +package gcbundles import "time" diff --git a/cmd/gc-bundles/filter_test.go b/internal/gcbundles/filter_test.go similarity index 98% rename from cmd/gc-bundles/filter_test.go rename to internal/gcbundles/filter_test.go index ab373a1..889c9f8 100644 --- a/cmd/gc-bundles/filter_test.go +++ b/internal/gcbundles/filter_test.go @@ -1,4 +1,4 @@ -package main +package gcbundles import ( "testing" diff --git a/cmd/gc-bundles/main.go b/internal/gcbundles/gc.go similarity index 81% rename from cmd/gc-bundles/main.go rename to internal/gcbundles/gc.go index e9bbe93..16592fa 100644 --- a/cmd/gc-bundles/main.go +++ b/internal/gcbundles/gc.go @@ -1,10 +1,12 @@ -// Command gc-bundles prunes unreferenced GHCR bundles. +// Package gcbundles implements `phpup gc-bundles`, which prunes +// unreferenced GHCR bundles. // // Runs in two modes: // // --dry-run (default): list candidate versions without deleting. -// --confirm: actually delete the candidates. Guarded behind a workflow -// dispatch input; local dev should stick to --dry-run. +// --confirm: actually delete the candidates. Local dev should stick +// to --dry-run; operators invoke --confirm after reviewing +// the dry-run output. // // A version is a candidate for pruning when it is: // - a container manifest under ghcr.io//php-core or @@ -15,27 +17,29 @@ // // Released-tag lockfile references are load-bearing for product-vision §16 // reproducibility and must never be pruned. -package main +package gcbundles import ( "encoding/json" "flag" "fmt" - "log" "os" "strings" "time" ) -func main() { - org := flag.String("org", "buildrush", "GHCR organization to scan") - minAgeDays := flag.Int("min-age-days", 30, "minimum age before a version is prunable") - confirm := flag.Bool("confirm", false, "actually delete (default is dry-run)") - flag.Parse() - - if err := run(*org, *minAgeDays, *confirm, os.Stdout); err != nil { - log.Fatalf("gc-bundles: %v", err) +// Main is the entry point for `phpup gc-bundles`. args is everything after +// the subcommand token. Byte-identical stdout to the previous cmd/gc-bundles +// binary for the same inputs. +func Main(args []string) error { + fs := flag.NewFlagSet("phpup gc-bundles", flag.ContinueOnError) + org := fs.String("org", "buildrush", "GHCR organization to scan") + minAgeDays := fs.Int("min-age-days", 30, "minimum age before a version is prunable") + confirm := fs.Bool("confirm", false, "actually delete (default is dry-run)") + if err := fs.Parse(args); err != nil { + return err } + return run(*org, *minAgeDays, *confirm, os.Stdout) } // run is the testable core. It writes a human-readable report to w — diff --git a/cmd/gc-bundles/ghcr.go b/internal/gcbundles/ghcr.go similarity index 98% rename from cmd/gc-bundles/ghcr.go rename to internal/gcbundles/ghcr.go index 9fb6683..abbb291 100644 --- a/cmd/gc-bundles/ghcr.go +++ b/internal/gcbundles/ghcr.go @@ -1,4 +1,4 @@ -package main +package gcbundles import ( "encoding/json" diff --git a/cmd/gc-bundles/ghcr_test.go b/internal/gcbundles/ghcr_test.go similarity index 98% rename from cmd/gc-bundles/ghcr_test.go rename to internal/gcbundles/ghcr_test.go index f938a58..47b507e 100644 --- a/cmd/gc-bundles/ghcr_test.go +++ b/internal/gcbundles/ghcr_test.go @@ -1,4 +1,4 @@ -package main +package gcbundles import ( "encoding/json" diff --git a/cmd/gc-bundles/refs.go b/internal/gcbundles/refs.go similarity index 99% rename from cmd/gc-bundles/refs.go rename to internal/gcbundles/refs.go index a7f7281..7e200d7 100644 --- a/cmd/gc-bundles/refs.go +++ b/internal/gcbundles/refs.go @@ -1,4 +1,4 @@ -package main +package gcbundles import ( "bytes" diff --git a/cmd/gc-bundles/refs_test.go b/internal/gcbundles/refs_test.go similarity index 99% rename from cmd/gc-bundles/refs_test.go rename to internal/gcbundles/refs_test.go index 739b597..cad65e6 100644 --- a/cmd/gc-bundles/refs_test.go +++ b/internal/gcbundles/refs_test.go @@ -1,4 +1,4 @@ -package main +package gcbundles import ( "os" diff --git a/cmd/hermetic-audit/main.go b/internal/hermeticaudit/audit.go similarity index 80% rename from cmd/hermetic-audit/main.go rename to internal/hermeticaudit/audit.go index 6c7d20f..a4648b9 100644 --- a/cmd/hermetic-audit/main.go +++ b/internal/hermeticaudit/audit.go @@ -1,13 +1,15 @@ -// Package main implements hermetic-audit, which verifies a built bundle's ELF -// files resolve all their shared-library dependencies when loaded on a given -// runner OS. Uses ldd inside a Docker image matching --expected-runner-os so -// the audit reflects the apt reality of the target runner, not the CI host. -package main +// Package hermeticaudit implements `phpup hermetic-audit`, which verifies +// a built bundle's ELF files resolve all their shared-library dependencies +// when loaded on a given runner OS. Uses ldd inside a Docker image matching +// --expected-runner-os so the audit reflects the apt reality of the target +// runner, not the CI host. +package hermeticaudit import ( "bufio" "bytes" "encoding/json" + "errors" "flag" "fmt" "os" @@ -23,27 +25,36 @@ type metaSidecar struct { Kind string `json:"kind"` } -func main() { - bundlePath := flag.String("bundle", "", "path to extracted bundle directory") - runnerOS := flag.String("expected-runner-os", "", "e.g. ubuntu-22.04 or ubuntu-24.04") - format := flag.String("format", "human", "human|json") - flag.Parse() +// ErrFindings is returned by Main when the audit found unresolvable or +// mis-captured libraries. Callers handle this as a non-zero exit; other +// error values indicate a usage or I/O failure. Matches the pre-refactor +// cmd/hermetic-audit exit convention: 0 = clean, 1 = findings, 2 = error. +var ErrFindings = errors.New("hermetic-audit: findings present") + +// Main is the entry point for `phpup hermetic-audit`. args is everything +// after the subcommand token. Output is byte-identical to the previous +// cmd/hermetic-audit binary for the same inputs. +func Main(args []string) error { + fs := flag.NewFlagSet("phpup hermetic-audit", flag.ContinueOnError) + bundlePath := fs.String("bundle", "", "path to extracted bundle directory") + runnerOS := fs.String("expected-runner-os", "", "e.g. ubuntu-22.04 or ubuntu-24.04") + format := fs.String("format", "human", "human|json") + if err := fs.Parse(args); err != nil { + return err + } if *bundlePath == "" || *runnerOS == "" { - fmt.Fprintln(os.Stderr, "usage: hermetic-audit --bundle --expected-runner-os ") - os.Exit(2) + return fmt.Errorf("usage: phpup hermetic-audit --bundle --expected-runner-os ") } meta, err := readMeta(filepath.Join(*bundlePath, "meta.json")) if err != nil { - fmt.Fprintf(os.Stderr, "::error::hermetic-audit: %v\n", err) - os.Exit(2) + return err } elves, err := findELFs(*bundlePath) if err != nil { - fmt.Fprintf(os.Stderr, "::error::hermetic-audit: find ELFs: %v\n", err) - os.Exit(2) + return fmt.Errorf("find ELFs: %w", err) } var report auditReport @@ -52,8 +63,7 @@ func main() { for _, elf := range elves { missing, err := lddInDocker(elf, *runnerOS) if err != nil { - fmt.Fprintf(os.Stderr, "::error::hermetic-audit: ldd %s: %v\n", elf, err) - os.Exit(2) + return fmt.Errorf("ldd %s: %w", elf, err) } unexplained, captureBugs := classifyMissing(missing, meta.HermeticLibs) for _, u := range unexplained { @@ -74,8 +84,9 @@ func main() { } if len(report.Unexplained) > 0 || len(report.CaptureBugs) > 0 { - os.Exit(1) + return ErrFindings } + return nil } type findingEntry struct { diff --git a/cmd/hermetic-audit/main_test.go b/internal/hermeticaudit/audit_test.go similarity index 98% rename from cmd/hermetic-audit/main_test.go rename to internal/hermeticaudit/audit_test.go index f3d7192..5a171b4 100644 --- a/cmd/hermetic-audit/main_test.go +++ b/internal/hermeticaudit/audit_test.go @@ -1,4 +1,4 @@ -package main +package hermeticaudit import ( "reflect" diff --git a/cmd/lockfile-update/commit.go b/internal/lockfileupdate/commit.go similarity index 99% rename from cmd/lockfile-update/commit.go rename to internal/lockfileupdate/commit.go index fc1551f..56e2d84 100644 --- a/cmd/lockfile-update/commit.go +++ b/internal/lockfileupdate/commit.go @@ -1,4 +1,4 @@ -package main +package lockfileupdate import ( "bytes" diff --git a/cmd/lockfile-update/commit_test.go b/internal/lockfileupdate/commit_test.go similarity index 99% rename from cmd/lockfile-update/commit_test.go rename to internal/lockfileupdate/commit_test.go index 7368a3f..6f9f437 100644 --- a/cmd/lockfile-update/commit_test.go +++ b/internal/lockfileupdate/commit_test.go @@ -1,4 +1,4 @@ -package main +package lockfileupdate import ( "os" diff --git a/cmd/lockfile-update/main.go b/internal/lockfileupdate/update.go similarity index 81% rename from cmd/lockfile-update/main.go rename to internal/lockfileupdate/update.go index bf6c3f6..d2d9601 100644 --- a/cmd/lockfile-update/main.go +++ b/internal/lockfileupdate/update.go @@ -1,10 +1,6 @@ -// Command lockfile-update regenerates bundles.lock from the current catalog -// and the state of GHCR. Replaces the previous bash + Python implementation. -// -// Usage: -// -// lockfile-update [-catalog ./catalog] [-lockfile ./bundles.lock] [-registry ghcr.io/buildrush] -package main +// Package lockfileupdate implements `phpup lockfile-update`, which +// regenerates bundles.lock from the current catalog and the state of GHCR. +package lockfileupdate import ( "context" @@ -30,46 +26,52 @@ type resolvedEntry struct { SpecHash string } -func main() { - catalogDir := flag.String("catalog", "./catalog", "path to catalog directory") - lockfilePath := flag.String("lockfile", "./bundles.lock", "path to bundles.lock") - registry := flag.String("registry", "ghcr.io/buildrush", "OCI registry prefix") - commit := flag.Bool("commit", false, "commit + push the updated lockfile to HEAD (CI use)") - flag.Parse() +// Main is the entry point for `phpup lockfile-update`. args is everything +// after the subcommand token. Byte-identical stdout (the single "wrote …" +// line) and lockfile output to the retired cmd/lockfile-update binary for +// the same inputs. +func Main(args []string) error { + fs := flag.NewFlagSet("phpup lockfile-update", flag.ContinueOnError) + catalogDir := fs.String("catalog", "./catalog", "path to catalog directory") + lockfilePath := fs.String("lockfile", "./bundles.lock", "path to bundles.lock") + registry := fs.String("registry", "ghcr.io/buildrush", "OCI registry prefix") + commit := fs.Bool("commit", false, "commit + push the updated lockfile to HEAD (CI use)") + if err := fs.Parse(args); err != nil { + return err + } ctx := context.Background() cat, err := catalog.LoadCatalog(*catalogDir) if err != nil { - log.Fatalf("load catalog: %v", err) + return fmt.Errorf("load catalog: %w", err) } if err := cat.PHP.Validate(); err != nil { - log.Fatalf("validate catalog: %v", err) + return fmt.Errorf("validate catalog: %w", err) } token := os.Getenv("GHCR_TOKEN") client, err := oci.NewClient(*registry, token) if err != nil { - log.Fatalf("create OCI client: %v", err) + return fmt.Errorf("create OCI client: %w", err) } builderOS, err := readBuilderOS(filepath.Join("builders", "common", "builder-os.env")) if err != nil { - log.Fatalf("read builder-os.env: %v", err) + return fmt.Errorf("read builder-os.env: %w", err) } - // Hash builder scripts and schema version file builderHashPHP, err := planner.HashFile(filepath.Join("builders", "linux", "build-php.sh")) if err != nil { - log.Fatalf("hash php builder: %v", err) + return fmt.Errorf("hash php builder: %w", err) } builderHashExt, err := planner.HashFile(filepath.Join("builders", "linux", "build-ext.sh")) if err != nil { - log.Fatalf("hash ext builder: %v", err) + return fmt.Errorf("hash ext builder: %w", err) } schemaEnvHash, err := planner.HashFile(filepath.Join("builders", "common", "bundle-schema-version.env")) if err != nil { - log.Fatalf("hash schema env: %v", err) + return fmt.Errorf("hash schema env: %w", err) } builderHashPHP = builderHashPHP + ":" + schemaEnvHash builderHashExt = builderHashExt + ":" + schemaEnvHash @@ -88,7 +90,7 @@ func main() { c := &phpCells[i] yamlBytes, err := planner.PerVersionYAML(cat.PHP, c.Version) if err != nil { - log.Fatalf("per-version yaml %s: %v", c.Version, err) + return fmt.Errorf("per-version yaml %s: %w", c.Version, err) } c.SpecHash = planner.ComputeSpecHash(c, yamlBytes, builderHashPHP, builderOS) @@ -111,7 +113,7 @@ func main() { } extYAML, err := planner.ExtensionYAML(ext) if err != nil { - log.Fatalf("ext yaml %s: %v", ext.Name, err) + return fmt.Errorf("ext yaml %s: %w", ext.Name, err) } cells := planner.ExpandExtMatrix(ext, coreDigestByKey) for i := range cells { @@ -139,14 +141,14 @@ func main() { preserveGeneratedAtIfUnchanged(lf, *lockfilePath) if err := lf.Write(*lockfilePath); err != nil { - log.Fatalf("write lockfile: %v", err) + return fmt.Errorf("write lockfile: %w", err) } fmt.Printf("wrote %s with %d entries\n", *lockfilePath, len(lf.Bundles)) if *commit { branch := firstNonEmpty(os.Getenv("GITHUB_HEAD_REF"), os.Getenv("GITHUB_REF_NAME")) if branch == "" { - log.Fatalf("--commit requires GITHUB_HEAD_REF or GITHUB_REF_NAME to be set") + return fmt.Errorf("--commit requires GITHUB_HEAD_REF or GITHUB_REF_NAME to be set") } runID := os.Getenv("GITHUB_RUN_ID") if runID == "" { @@ -159,9 +161,10 @@ func main() { ActorName: "github-actions[bot]", ActorEmail: "41898282+github-actions[bot]@users.noreply.github.com", }); err != nil { - log.Fatalf("commit lockfile: %v", err) + return fmt.Errorf("commit lockfile: %w", err) } } + return nil } func firstNonEmpty(values ...string) string { diff --git a/cmd/lockfile-update/main_test.go b/internal/lockfileupdate/update_test.go similarity index 99% rename from cmd/lockfile-update/main_test.go rename to internal/lockfileupdate/update_test.go index 0201c7b..9ad45f8 100644 --- a/cmd/lockfile-update/main_test.go +++ b/internal/lockfileupdate/update_test.go @@ -1,4 +1,4 @@ -package main +package lockfileupdate import ( "path/filepath" diff --git a/internal/oci/client.go b/internal/oci/client.go index 1868803..116e38c 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -12,8 +12,9 @@ import ( // Client is a thin facade over an internal/registry.Store. Its public surface // is preserved for backwards compatibility with the pre-refactor call sites -// in cmd/phpup, cmd/planner, and cmd/lockfile-update. The facade itself is -// slated for deletion by end of PR 3 of the local+CI unification rollout. +// (now consolidated as phpup subcommands in internal/planner, +// internal/lockfileupdate, and cmd/phpup itself). Slated for deletion once +// callers migrate to registry.Store directly. type Client struct { registryURI string token string diff --git a/cmd/planner/main.go b/internal/planner/cli.go similarity index 62% rename from cmd/planner/main.go rename to internal/planner/cli.go index 87f73fa..f429aa4 100644 --- a/cmd/planner/main.go +++ b/internal/planner/cli.go @@ -1,4 +1,4 @@ -package main +package planner import ( "context" @@ -12,84 +12,90 @@ import ( "github.com/buildrush/setup-php/internal/catalog" "github.com/buildrush/setup-php/internal/lockfile" "github.com/buildrush/setup-php/internal/oci" - "github.com/buildrush/setup-php/internal/planner" ) -func main() { - catalogDir := flag.String("catalog", "./catalog", "path to catalog directory") - lockfilePath := flag.String("lockfile", "./bundles.lock", "path to bundles.lock") - registry := flag.String("registry", "ghcr.io/buildrush", "OCI registry prefix") - outputDir := flag.String("output-matrix", "/tmp/matrix", "output directory for matrix JSON files") - force := flag.Bool("force", false, "force rebuild even if digests match") - flag.Parse() +// Main is the entry point for `phpup plan`. args is everything after the +// "plan" subcommand token. Output is byte-identical to the previous +// cmd/planner binary for the same inputs (same matrix JSON, same stdout +// progress line) — callers that parsed its output can switch transparently. +func Main(args []string) error { + fs := flag.NewFlagSet("phpup plan", flag.ContinueOnError) + catalogDir := fs.String("catalog", "./catalog", "path to catalog directory") + lockfilePath := fs.String("lockfile", "./bundles.lock", "path to bundles.lock") + registry := fs.String("registry", "ghcr.io/buildrush", "OCI registry prefix") + outputDir := fs.String("output-matrix", "/tmp/matrix", "output directory for matrix JSON files") + force := fs.Bool("force", false, "force rebuild even if digests match") + if err := fs.Parse(args); err != nil { + return err + } ctx := context.Background() cat, err := catalog.LoadCatalog(*catalogDir) if err != nil { - log.Fatalf("load catalog: %v", err) + return fmt.Errorf("load catalog: %w", err) } if err := cat.PHP.Validate(); err != nil { - log.Fatalf("validate PHP spec: %v", err) + return fmt.Errorf("validate PHP spec: %w", err) } lf, err := lockfile.ParseFile(*lockfilePath) if err != nil { - log.Fatalf("parse lockfile: %v", err) + return fmt.Errorf("parse lockfile: %w", err) } token := os.Getenv("GHCR_TOKEN") client, err := oci.NewClient(*registry, token) if err != nil { - log.Fatalf("create OCI client: %v", err) + return fmt.Errorf("create OCI client: %w", err) } - result := &planner.Result{} + result := &Result{} builderOS, err := readBuilderOS(filepath.Join("builders", "common", "builder-os.env")) if err != nil { - log.Fatalf("read builder-os.env: %v", err) + return fmt.Errorf("read builder-os.env: %w", err) } - // Hash builder scripts and shared support files the builders source. Changes - // to any of these change the bundle contents; fold them into builderHash so - // spec_hash invalidates and bundles rebuild. Ordering mirrors the historical - // inline concatenation (build-.sh + schema env + capture + pack) so - // spec_hash values stay byte-identical across this refactor. + // Hash builder scripts and shared support files the builders source. + // Changes to any of these change the bundle contents; fold them into + // builderHash so spec_hash invalidates and bundles rebuild. Ordering + // mirrors the historical inline concatenation (build-.sh + schema + // env + capture + pack) so spec_hash values stay byte-identical across + // this refactor. common := []string{ filepath.Join("builders", "common", "bundle-schema-version.env"), filepath.Join("builders", "common", "capture-hermetic-libs.sh"), filepath.Join("builders", "common", "pack-bundle.sh"), } - builderHashPHP, err := planner.HashFiles(append( + builderHashPHP, err := HashFiles(append( []string{filepath.Join("builders", "linux", "build-php.sh")}, common..., )) if err != nil { - log.Fatalf("hash php builder: %v", err) + return fmt.Errorf("hash php builder: %w", err) } - builderHashExt, err := planner.HashFiles(append( + builderHashExt, err := HashFiles(append( []string{filepath.Join("builders", "linux", "build-ext.sh")}, common..., )) if err != nil { - log.Fatalf("hash ext builder: %v", err) + return fmt.Errorf("hash ext builder: %w", err) } - // Expand PHP matrix - phpCells := planner.ExpandPHPMatrix(cat.PHP) + phpCells := ExpandPHPMatrix(cat.PHP) for i := range phpCells { - yamlBytes, err := planner.PerVersionYAML(cat.PHP, phpCells[i].Version) + yamlBytes, err := PerVersionYAML(cat.PHP, phpCells[i].Version) if err != nil { - log.Fatalf("per-version yaml for %s: %v", phpCells[i].Version, err) + return fmt.Errorf("per-version yaml for %s: %w", phpCells[i].Version, err) } - phpCells[i].SpecHash = planner.ComputeSpecHash(&phpCells[i], yamlBytes, builderHashPHP, builderOS) + phpCells[i].SpecHash = ComputeSpecHash(&phpCells[i], yamlBytes, builderHashPHP, builderOS) } if !*force { phpCells = filterExisting(ctx, phpCells, lf, client) } - result.PHP = planner.Matrix{Include: phpCells} + result.PHP = Matrix{Include: phpCells} // Build a map of already-published php-core digests keyed by // lockfile.PHPBundleKey (matches the key format ExpandExtMatrix builds @@ -105,56 +111,57 @@ func main() { } } - // Expand extension matrices - var extCells []planner.MatrixCell + var extCells []MatrixCell for _, ext := range cat.Extensions { if ext.Kind == catalog.ExtensionKindBundled { continue } - cells := planner.ExpandExtMatrix(ext, coreDigestByKey) - extYAML, err := planner.ExtensionYAML(ext) + cells := ExpandExtMatrix(ext, coreDigestByKey) + extYAML, err := ExtensionYAML(ext) if err != nil { - log.Fatalf("ext yaml for %s: %v", ext.Name, err) + return fmt.Errorf("ext yaml for %s: %w", ext.Name, err) } for i := range cells { - cells[i].SpecHash = planner.ComputeSpecHash(&cells[i], extYAML, builderHashExt, builderOS) + cells[i].SpecHash = ComputeSpecHash(&cells[i], extYAML, builderHashExt, builderOS) } if !*force { cells = filterExisting(ctx, cells, lf, client) } extCells = append(extCells, cells...) } - result.Ext = planner.Matrix{Include: extCells} + result.Ext = Matrix{Include: extCells} // Tools: empty for Phase 1 - result.Tool = planner.Matrix{Include: []planner.MatrixCell{}} + result.Tool = Matrix{Include: []MatrixCell{}} - if err := planner.WriteMatrices(result, *outputDir); err != nil { - log.Fatalf("write matrices: %v", err) + if err := WriteMatrices(result, *outputDir); err != nil { + return fmt.Errorf("write matrices: %w", err) } fmt.Printf("Plan complete: %d PHP cores, %d extensions, %d tools\n", len(result.PHP.Include), len(result.Ext.Include), len(result.Tool.Include)) + return nil } -// readBuilderOS parses builders/common/builder-os.env and returns the value of -// BUILDER_OS. Missing file or missing key is a fatal — the planner's spec_hash -// depends on this being load-bearing, and silent fallback would produce a -// lockfile where every entry looks up-to-date but reflects the wrong runner. +// readBuilderOS parses builders/common/builder-os.env and returns the value +// of BUILDER_OS. Missing file or missing key is a fatal — the planner's +// spec_hash depends on this being load-bearing, and silent fallback would +// produce a lockfile where every entry looks up-to-date but reflects the +// wrong runner. func readBuilderOS(path string) (string, error) { data, err := os.ReadFile(filepath.Clean(path)) if err != nil { return "", fmt.Errorf("read %s: %w", path, err) } - v := planner.ParseEnvValue(data, "BUILDER_OS") + v := ParseEnvValue(data, "BUILDER_OS") if v == "" { return "", fmt.Errorf("%s: BUILDER_OS not found", path) } return v, nil } -func filterExisting(ctx context.Context, cells []planner.MatrixCell, lf *lockfile.Lockfile, client *oci.Client) []planner.MatrixCell { - var filtered []planner.MatrixCell +func filterExisting(ctx context.Context, cells []MatrixCell, lf *lockfile.Lockfile, client *oci.Client) []MatrixCell { + var filtered []MatrixCell for i := range cells { cell := &cells[i] @@ -177,11 +184,11 @@ func filterExisting(ctx context.Context, cells []planner.MatrixCell, lf *lockfil entry, ok := lf.LookupEntry(key) if !ok { - filtered = append(filtered, *cell) // not in lockfile, must build + filtered = append(filtered, *cell) continue } if entry.SpecHash != "" && entry.SpecHash != cell.SpecHash { - filtered = append(filtered, *cell) // inputs drifted, must rebuild + filtered = append(filtered, *cell) continue } @@ -212,7 +219,6 @@ func filterExisting(ctx context.Context, cells []planner.MatrixCell, lf *lockfil filtered = append(filtered, *cell) continue } - // Tag exists and resolves to the lockfile digest — skip. } return filtered } diff --git a/internal/planner/helpers.go b/internal/planner/helpers.go index f3126f8..57afa6a 100644 --- a/internal/planner/helpers.go +++ b/internal/planner/helpers.go @@ -57,9 +57,9 @@ func ExtensionYAMLFromFile(path string) ([]byte, error) { // the given key, or "" if the key is absent. Lines are split on "\n"; the // first line matching "=" (after trimming) wins. Blank lines and // lines starting with "#" are ignored. Values are not unquoted — callers that -// need quote handling must do it themselves. This mirrors the parser that -// lived inline in cmd/planner/main.go for BUILDER_OS and is deliberately -// minimal; extend only with a test-driven reason. +// need quote handling must do it themselves. This parser is deliberately +// minimal (it only needs to extract BUILDER_OS for the planner's spec-hash); +// extend only with a test-driven reason. func ParseEnvValue(data []byte, key string) string { prefix := key + "=" for _, line := range strings.Split(string(data), "\n") {