Skip to content

feat(build): phpup build php|ext subcommand (PR 2/6)#56

Merged
phramz merged 11 commits into
mainfrom
feat/local-ci-pr2-build-subcommand
Apr 23, 2026
Merged

feat(build): phpup build php|ext subcommand (PR 2/6)#56
phramz merged 11 commits into
mainfrom
feat/local-ci-pr2-build-subcommand

Conversation

@phramz

@phramz phramz commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of 6 in the local+CI unification rollout (spec).

  • internal/registry.Store extended with Annotations{BundleName, SpecHash} on Push and a new LookupBySpec(ctx, name, specHash) probe for the cache short-circuit. Remote backend's lookup returns ErrUnsupported (anonymous OCI registries don't expose index walks); build callers soft-miss it.
  • internal/planner helpers exported (HashFiles, PerVersionYAMLFromFile, ExtensionYAMLFromFile, ParseEnvValue) so internal/build.ComputeSpecHash wraps planner.ComputeSpecHash from the same inputs. Zero spec-hash drift verified across 182 production cells (10 PHP + 172 ext).
  • internal/build.DockerRun — swappable RunnerFunc via SetRunner(fn) so unit tests don't need real docker. Gated real-docker smoke test exercises the default runner.
  • phpup build php subcommand — docker-wraps builders/linux/build-php.sh unchanged, cache-probes via spec-hash, pushes to registry.Store with bundle-name + spec-hash annotations. Real-docker integration test passes end-to-end.
  • phpup build ext subcommand — ephemeral distribution/distribution:3 sidecar registry on an isolated docker network; prerequisite php-core copied from source Store to sidecar via remote.Write with name.Insecure; build container runs on the same network with REGISTRY overridden so fetch-core.sh pulls from the sidecar transparently. Builder scripts stay unchanged. Real-docker sidecar test passes.
  • Output dirs default to project-relative ./build/php/…/ and ./build/ext/…/ (gitignored) so artifacts persist for inspection; --out-dir overrides.
  • make bundle-php / make bundle-ext now call phpup build with oci-layout defaults.
  • .github/workflows/build-php-core.yml rewired to invoke phpup build php; digest job-output contract preserved byte-for-byte via a thin staging cp step before the existing GHCR push.

Backward compatibility: existing consumers of buildrush/setup-php@v1 see zero change — phpup install is untouched.

Test plan

  • Unit tests green: go test ./internal/build/... ./internal/registry/... ./internal/planner/... — 30 internal/build tests (83.5% coverage w/ integration), 28 internal/planner tests (90.5%), 28 internal/registry tests (76.9%).
  • Real-docker integration tests pass:
    • TestBuildPHP_RealDockerSmoke exercises the mount contract end-to-end with a synthetic builder.
    • TestSidecar_LifecycleAndSeed_Real spins up distribution:3 and verifies bundle round-trip via remote.Get.
  • make check green end-to-end including docker-backed local-ci smoke (ubuntu:22.04 + 24.04 × amd64 + arm64 × hard4 extensions).
  • Spec-hash stability: cmd/planner output on a853877 (main) vs. c603093 (post-refactor) shows zero drift across all 182 cells.
  • CI: full matrix green on this PR.

Explicitly deferred to follow-ups

  • .github/workflows/build-extension.yml is unchanged. Rewiring cleanly depends on (a) implementing remoteStore.Push (~100 LOC; was intentionally ErrUnsupported in PR 1) and (b) threading php_core_digest through planner.MatrixCell + plan-and-build.yml inputs. Both exceed the "wiring only" scope of Task 6 and will land in a PR 2.5 or PR 3.
  • Zombie-sidecar cleanupdefaultSidecarLifecycle.Start currently does not sweep stale phpup-sidecar-* containers or phpup-build-* networks. Flagged in Task 5 code review; can be added via a docker ps --filter label=buildrush.phpup.sidecar=1 sweep at Start.
  • loadExtBuildDeps refactor — current implementation parses catalog/extensions/<name>.yaml via map[string]any; should switch to the existing typed catalog.LoadExtensionSpec for DRY.
  • cmd/lockfile-update hash-inputs divergence (pre-existing, flagged in PR 2 Task 2 review) — hashes only build-<kind>.sh + bundle-schema-version.env; doesn't include capture-hermetic-libs.sh + pack-bundle.sh. Not a regression from this PR; port to planner.HashFiles in a follow-up to centralize.

Non-goals (land in subsequent PRs per the spec's 6-PR rollout)

  • phpup test subcommand — PR 3.
  • New unified ci.yml with thin OS×ARCH×PHP matrix — PR 4.
  • Old-workflow cutover — PR 5.
  • CLI binary consolidation (planner, lockfile-update, etc. → phpup subcommands) — PR 6.

Notes for reviewers

  • The 10 commits are atomic and independently revertible. feat commits introduce capabilities behind tests; fix commits address prior-review findings.
  • Each task went through a two-stage review (spec compliance, then code quality) via fresh subagents.
  • One //nolint:gosec is introduced (on execDocker in internal/build/sidecar.go) — G204 genuine false positive (execve without shell; argv from typed struct fields). Matches Task 3's docker.go pattern.

phramz added 11 commits April 23, 2026 14:30
Push now accepts an Annotations value carrying manifest-level metadata
(BundleName, SpecHash). LookupBySpec walks the layout index for a
manifest whose annotations match a given (name, spec-hash) pair — the
foundation for phpup build's cache-probe short-circuit (arrives in
PR 2 Tasks 4-5). Remote backend's LookupBySpec is a no-op returning
(zero, false, nil); anonymous OCI registries don't expose index
walks, so build callers should use an oci-layout registry as their
cache store and publish to remote only after a fresh build produces
an artifact.

Breaking change: Store.Push signature gains an Annotations value.
Only internal callers (layout_test.go, remote_test.go) were
affected and have been updated in the same commit.
- layout.LookupBySpec now rejects empty name or specHash explicitly
  so a Ref{} zero-value probe can't false-positive on un-annotated
  manifests. Add regression test.
- remote.LookupBySpec now returns ErrUnsupported so callers who
  wire only a remote store see the structural capability gap
  instead of a silent miss every call. Build callers can convert
  to soft-miss via errors.Is(err, ErrUnsupported).
- New test exercises the empty-Annotations back-compat fallback
  where Push derives BundleName from ref.Name.
- Godoc clarifications on asMap's never-nil contract and on how
  to call LookupBySpec from an Annotations value.
…r helpers

Extract HashFiles / PerVersionYAMLFromFile / ExtensionYAMLFromFile /
ParseEnvValue from cmd/planner/main.go inlining into exported
internal/planner helpers so phpup build (PR 2 Task 4/5) can compute
spec-hashes from the same inputs CI planning uses. Values are
byte-identical across the refactor — verified against the full
catalog (10 PHP cells, 172 ext cells) before/after: zero spec_hash
diffs.

internal/build.ComputeSpecHash accepts CLI-friendly SpecHashInputs
(kind/name/version/os/arch/ts/repo-root) and wraps
planner.ComputeSpecHash with repo-relative path resolution. Tests
use tempdir fixtures; no real catalog or builders touched. The
wrapper also includes a cross-check test that builds the inputs by
hand the planner's way and asserts the wrapper's output matches,
so any future drift between the two code paths surfaces at CI time.
Thin wrapper over `docker run --rm` that PR 2 Tasks 4-5 drive. A
package-level RunnerFunc var is swappable via SetRunner(fn) (ret a
restore callback) so unit tests don't need real docker to assert the
argv phpup would build. The realDockerRun default goes through
exec.CommandContext; env keys are sorted for deterministic argv.

A gated smoke test exercises real docker with `alpine:3 echo hello`,
skipped under -short or when docker is absent.
… false positive

gocritic:hugeParam on DockerRun wasn't a false positive — the ~136B
DockerRunOpts is a real large-struct copy. Switch the public API to
*DockerRunOpts (and RunnerFunc accordingly), eliminating the
//nolint:gocritic suppression entirely.

gosec:G204 on exec.CommandContext IS a genuine false positive:
CommandContext passes argv directly to execve(2) without shell
interpretation, and all argv strings come from typed DockerRunOpts
fields. Keep the //nolint:gosec with a tightened justification
invoking the false-positive criterion rather than prior-art config
exclusions.
phpup build php [--php 8.4] [--os jammy] [--arch x86_64] [--ts nts]
  [--registry oci-layout:./out/oci-layout] [--repo .]

Computes the spec-hash from the same builder + catalog inputs the
planner uses, probes the target registry with LookupBySpec, and
short-circuits on hit. On miss: spins up a bare ubuntu:22.04 (jammy)
or ubuntu:24.04 (noble) container under the requested platform
(linux/amd64 or linux/arm64), mounts the repo read-only + a tempdir
output mount, runs builders/linux/build-php.sh unchanged, reads the
resulting bundle.tar.zst + meta.json, and Pushes to the registry
with bundle-name + spec-hash annotations.

Callers with a remote-only registry (no oci-layout cache) are
tolerated: LookupBySpec's ErrUnsupported is treated as a soft miss.

BuildExt is declared as a stub returning an explanatory error until
PR 2 Task 5 implements it.
- Container mount for bundle output was /tmp/out; builders write to
  /tmp via pack-bundle.sh's hardcoded OUTPUT_PATH. Real docker runs
  would have lost the bundle on container exit. Mount outDir at /tmp
  directly; keep OUTPUT_DIR=/tmp/out for make install INSTALL_ROOT
  staging inside the mount. Unit tests bypassed this via fake runners.
- Add TestBuildPHP_RealDockerSmoke that swaps the repo fixture's
  build-php.sh for a 3-line synthetic and exercises real docker to
  catch mount-contract regressions in seconds.
- Reject --ts zts at parse time: the flag was cache-key-only with no
  builder support, so accepting it silently cached an NTS artifact
  under a ZTS key. Fail loudly until builder support lands.
- Normalize --arch aliases (amd64 -> x86_64, arm64 -> aarch64) at
  parse time so spec-hashes are stable regardless of caller
  convention; dockerPlatform simplified.
- Stream apt output through to the user's stderr instead of silencing
  it; extract linuxAptPreamble constant so Task 5 can share.
- Drop dead SchemaVersion default and linter-theater filepath.Clean
  calls. Revise BuildExt stub wording for end-user readability.
…nored)

phpup build php's mounted output directory was a tempdir under /tmp.
Per user redirect, switch the default to <repo>/build/php/<version>-
<os>-<arch>-<ts>/ so artifacts persist next to the source tree for
easy inspection, keyed deterministically on the spec tuple (repeated
runs overwrite the same dir instead of piling up tempdirs). Add
--out-dir flag for explicit override; tests pass t.TempDir() via the
flag to keep the worktree clean.

.gitignore gains /build/ (docker output) and /out/ (default oci-layout
registry target). Leading slashes anchor the patterns to the repo root
so they don't shadow internal/build/ (the Go package).
phpup build ext --ext redis --ext-version 6.2.0 --php-abi 8.4-nts \
  --php-core-digest sha256:... [--arch x86_64] [--os jammy] \
  [--registry oci-layout:./out/oci-layout] [--out-dir ./build/ext/...]

Spins up an ephemeral distribution:3 registry on a fresh docker
network, copies the prerequisite php-core from the source
registry.Store to the sidecar via remote.Write (name.Insecure —
distribution speaks HTTP by default), then runs
builders/linux/build-ext.sh inside a container on the same network
with REGISTRY overridden to the sidecar. fetch-core.sh pulls from the
sidecar without any script change. Strict spec compliance:
builders/** stays unchanged.

After the build exits, phpup reads the ext bundle + meta.json from
the output mount and pushes back to the destination Store with
bundle-name + spec-hash annotations. Cache probe via LookupBySpec
short-circuits redundant rebuilds.

Default output dir mirrors Task 4's pattern:
<repo>/build/ext/<name>-<version>-<php_abi>-<os>-<arch>/ (gitignored).

SidecarLifecycle is a package-level var swappable via
SetSidecarLifecycle for unit tests; a fakeSidecar exercises BuildExt's
orchestration without real docker. A gated
TestSidecar_LifecycleAndSeed_Real covers the real distribution:3 path
(skipped under -short or when docker is absent).
Makefile's bundle-php / bundle-ext targets now invoke bin/phpup build
instead of raw `docker run ubuntu:22.04 bash -c "... build-*.sh"`.
Defaults match the hermetic dev loop (--registry oci-layout:./out/oci-layout);
users override with REGISTRY=... for remote targets. A new bin/phpup
file target depends on the embedded bundles.lock so make auto-rebuilds
the binary when the lockfile changes.

build-php-core.yml now runs the core build via `bin/phpup build php`
(docker-wrapped builders/linux/build-php.sh, unchanged), writes into a
project-relative output dir, then a thin "Stage bundle for publish"
step copies bundle.tar.zst + .sha256 + meta.json to /tmp/ so the
existing Push to GHCR, Sign bundle, Smoke test, and Upload bundle
artifact steps read from the same paths they did before — preserving
the `digest` job-output contract (sha256 of the bundle layer) byte-
for-byte.

build-extension.yml is intentionally not rewired in this commit:
phpup build ext requires a workflow-supplied --php-core-digest and
works against a store whose Push supports remote backends. Both
gaps land in follow-up PRs (planner surfacing core-digest to the ext
matrix, and internal/registry.remoteStore.Push). Left as a separate
task so this wiring stays scoped.
TestBuildPHP_RealDockerSmoke failed on GitHub Actions linux runners
with apt-key unable to create temporary config files. The outDir
mounted into the build container at /tmp was mode 0o750, which the
container-internal _apt user (dropped-privilege, different uid from
host) couldn't write to. Docker Desktop on macOS transparently maps
uids so the test passed locally; GHA enforces real mount semantics.

Fix: prepareOutDir now chmods the directory to 1777 (world-writable
+ sticky) matching traditional /tmp semantics. This is exactly the
intended use (the dir IS a /tmp replacement for a docker container);
justification comment on the //nolint:gosec accompanies the chmod.
@phramz
phramz merged commit 57f33c6 into main Apr 23, 2026
209 checks passed
@phramz
phramz deleted the feat/local-ci-pr2-build-subcommand branch April 23, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant