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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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$
Expand Down
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
```
13 changes: 6 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
62 changes: 62 additions & 0 deletions cmd/phpup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
_ "embed"
"errors"
"flag"
"fmt"
"log"
Expand All @@ -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"
Expand Down Expand Up @@ -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:<path> --to ghcr.io/<owner>` promotes
// every manifest in a local oci-layout to a remote registry. Lives
// at the top level (rather than under `phpup internal`) because the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package compatdiff

import (
"errors"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package compatdiff

import (
"path/filepath"
Expand Down
2 changes: 1 addition & 1 deletion cmd/compat-diff/diff.go → internal/compatdiff/diff.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package compatdiff

import (
"encoding/json"
Expand Down
28 changes: 23 additions & 5 deletions cmd/compat-diff/main.go → internal/compatdiff/diff_main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// cmd/compat-diff/main.go
package main
package compatdiff

import (
"flag"
Expand Down Expand Up @@ -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))
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// cmd/compat-diff/main_test.go
package main
package compatdiff

import (
"os"
Expand All @@ -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 `<phpup> 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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package compatdiff

import (
"path/filepath"
Expand Down
2 changes: 1 addition & 1 deletion cmd/gc-bundles/filter.go → internal/gcbundles/filter.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package gcbundles

import "time"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package gcbundles

import (
"testing"
Expand Down
Loading
Loading