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
52 changes: 41 additions & 11 deletions .github/workflows/build-extension.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@ on:
required: false
type: string
default: ''
php_core_digest:
description: 'OCI manifest digest of the prerequisite php-core bundle (sha256:...). Required for phpup build ext.'
required: true
type: string
push:
required: false
type: boolean
Expand DownExpand Up@@ -52,23 +56,49 @@ jobs:
curl -sSfLO "https://github.com/oras-project/oras/releases/download/v1.3.1/oras_1.3.1_linux_${ORAS_ARCH}.tar.gz"
tar -xzf "oras_1.3.1_linux_${ORAS_ARCH}.tar.gz" -C /usr/local/bin oras

- name: Resolve build_deps from catalog
id: build_deps
run: |
deps=$(yq eval '.build_deps.linux // [] | join(" ")' catalog/extensions/${{ inputs.extension }}.yaml)
echo "deps=${deps}" >> "$GITHUB_OUTPUT"
- uses: actions/setup-go@v6
with:
go-version: '1.26'

- name: Build phpup
run: make bin/phpup

- name: Build extension
# Run the ext build via phpup. phpup docker-wraps
# builders/linux/build-ext.sh unchanged and writes the resulting
# bundle.tar.zst + meta.json + bundle.tar.zst.sha256 into a
# project-relative output dir. The subsequent "Stage bundle for
# publish" step copies them to /tmp/ so "Push to GHCR",
# "Sign bundle", and "Upload bundle artifact" keep reading from
# the same paths they did before this rewiring — preserving the
# digest job-output contract byte-for-byte.
- name: Build extension via phpup
env:
EXT_NAME: ${{ inputs.extension }}
EXT_VERSION: ${{ inputs.ext_version }}
PHP_ABI: ${{ inputs.php_abi }}
OS: ${{ inputs.os }}
ARCH: ${{ inputs.arch }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY: ghcr.io/${{ github.repository_owner }}
WORKSPACE: ${{ github.workspace }}
BUILD_DEPS: ${{ steps.build_deps.outputs.deps }}
run: ./builders/linux/build-ext.sh
PHP_CORE_DIGEST: ${{ inputs.php_core_digest }}
PHPUP_OUT_DIR: ${{ github.workspace }}/build/ext/${{ inputs.extension }}-${{ inputs.ext_version }}-${{ inputs.php_abi }}-${{ inputs.os }}-${{ inputs.arch }}
run: |
./bin/phpup build ext \
--ext "$EXT_NAME" \
--ext-version "$EXT_VERSION" \
--php-abi "$PHP_ABI" \
--arch "$ARCH" \
--os "$OS" \
--php-core-digest "$PHP_CORE_DIGEST" \
--registry oci-layout:./out/oci-layout \
--repo . \
--out-dir "$PHPUP_OUT_DIR"

- name: Stage bundle for publish
env:
PHPUP_OUT_DIR: ${{ github.workspace }}/build/ext/${{ inputs.extension }}-${{ inputs.ext_version }}-${{ inputs.php_abi }}-${{ inputs.os }}-${{ inputs.arch }}
run: |
cp "$PHPUP_OUT_DIR/bundle.tar.zst" /tmp/bundle.tar.zst
cp "$PHPUP_OUT_DIR/bundle.tar.zst.sha256" /tmp/bundle.tar.zst.sha256
cp "$PHPUP_OUT_DIR/meta.json" /tmp/meta.json

- name: Push to GHCR
id: push
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/plan-and-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ jobs:
os: ${{ matrix.os }}
arch: ${{ matrix.arch }}
spec_hash: ${{ matrix.spec_hash }}
php_core_digest: ${{ matrix.core_digest }}
push: ${{ inputs.push }}

update-lock:
Expand Down
9 changes: 8 additions & 1 deletion cmd/lockfile-update/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ func main() {

var resolved []resolvedEntry

// coreDigestByKey accumulates freshly-resolved PHP-core digests keyed by
// lockfile.PHPBundleKey so ExpandExtMatrix can stamp each ext cell's
// CoreDigest. lockfile-update is a digest-resolution pass — unresolved
// cores will warn during ext expansion.
coreDigestByKey := make(map[string]string)

// PHP core cells.
phpCells := planner.ExpandPHPMatrix(cat.PHP)
for i := range phpCells {
Expand All@@ -95,6 +101,7 @@ func main() {
}
key := lockfile.PHPBundleKey(c.Version, c.OS, c.Arch, c.TS)
resolved = append(resolved, resolvedEntry{Key: key, Digest: digest, SpecHash: c.SpecHash})
coreDigestByKey[key] = digest
}

// Extensions.
Expand All@@ -106,7 +113,7 @@ func main() {
if err != nil {
log.Fatalf("ext yaml %s: %v", ext.Name, err)
}
cells := planner.ExpandExtMatrix(ext)
cells := planner.ExpandExtMatrix(ext, coreDigestByKey)
for i := range cells {
c := &cells[i]
c.SpecHash = planner.ComputeSpecHash(c, extYAML, builderHashExt, builderOS)
Expand Down
17 changes: 16 additions & 1 deletion cmd/planner/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,13 +91,28 @@ func main() {
}
result.PHP = planner.Matrix{Include: phpCells}

// Build a map of already-published php-core digests keyed by
// lockfile.PHPBundleKey (matches the key format ExpandExtMatrix builds
// internally). Used to populate ext cells' CoreDigest field so Task 5's
// build-extension job can pin the core by digest. Cells whose core is
// being rebuilt in the same run won't have an entry here yet — the
// workflow orchestrator (plan-and-build.yml) serializes build-ext
// after build-php so the lockfile can be refreshed before ext builds
// consume the value; this field is plumbing-only in Task 4.
coreDigestByKey := make(map[string]string, len(lf.Bundles))
for key, entry := range lf.Bundles {
if strings.HasPrefix(key, "php:") {
coreDigestByKey[key] = entry.Digest
}
}

// Expand extension matrices
var extCells []planner.MatrixCell
for _, ext := range cat.Extensions {
if ext.Kind == catalog.ExtensionKindBundled {
continue
}
cells := planner.ExpandExtMatrix(ext)
cells := planner.ExpandExtMatrix(ext, coreDigestByKey)
extYAML, err := planner.ExtensionYAML(ext)
if err != nil {
log.Fatalf("ext yaml for %s: %v", ext.Name, err)
Expand Down
31 changes: 6 additions & 25 deletions internal/build/build.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,8 +11,7 @@ import (
"strings"
"time"

"gopkg.in/yaml.v3"

"github.com/buildrush/setup-php/internal/catalog"
"github.com/buildrush/setup-php/internal/registry"
)

Expand DownExpand Up@@ -451,32 +450,14 @@ func tsFromPHPABI(phpABI string) string {
// yq eval '.build_deps.linux // [] | join(" ")' catalog/extensions/<name>.yaml
//
// Absent or empty returns "" — the builder treats that as a no-op.
// Reading YAML into map[string]any keeps us schema-agnostic so catalog
// additions don't require Go changes.
// Uses the typed catalog API so the extension-schema shape lives in one
// place (internal/catalog) instead of drifting across ad-hoc parsers.
func loadExtBuildDeps(path string) (string, error) {
data, err := os.ReadFile(filepath.Clean(path))
spec, err := catalog.LoadExtensionSpec(path)
if err != nil {
return "", fmt.Errorf("read extension catalog: %w", err)
}
var doc map[string]any
if err := yaml.Unmarshal(data, &doc); err != nil {
return "", fmt.Errorf("parse extension catalog: %w", err)
}
bd, ok := doc["build_deps"].(map[string]any)
if !ok {
return "", nil
}
linux, ok := bd["linux"].([]any)
if !ok {
return "", nil
}
pkgs := make([]string, 0, len(linux))
for _, p := range linux {
if s, ok := p.(string); ok {
pkgs = append(pkgs, s)
}
return "", fmt.Errorf("load extension catalog: %w", err)
}
return strings.Join(pkgs, " "), nil
return strings.Join(spec.BuildDeps["linux"], " "), nil
}

// phpOpts is the parsed flag set for `phpup build php`. Repo is resolved
Expand Down
38 changes: 37 additions & 1 deletion internal/build/sidecar.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,11 +97,46 @@ func currentSidecarLifecycle() SidecarLifecycle {
// network; SeedCore pushes the prerequisite bundle via remote.Write.
type defaultSidecarLifecycle struct{}

// sidecarLabel marks every container and network this lifecycle
// creates so sweepStaleSidecars can reliably clean up zombies from
// prior runs without touching unrelated docker state.
const sidecarLabel = "buildrush.phpup.sidecar=1"

// sweepStaleSidecars removes any containers or networks from prior runs
// that didn't clean up after themselves (e.g. outer timeout killed the
// process before the defer). Scoped by label so unrelated docker state
// is untouched. Errors are ignored — if docker can't list or remove the
// resources, the subsequent Start will either succeed (the zombies didn't
// collide) or fail with its own clear error.
func sweepStaleSidecars(ctx context.Context) {
// Best-effort; failures are not actionable from the caller's
// perspective and would just add noise on first-ever-run (no
// prior label to match).

// Containers first (they hold the network in use, so must go before the network).
if out, err := execDocker(ctx, "ps", "-aq", "--filter", "label="+sidecarLabel); err == nil {
for _, id := range strings.Fields(string(out)) {
_, _ = execDocker(ctx, "rm", "-f", id)
}
}
// Then networks.
if out, err := execDocker(ctx, "network", "ls", "-q", "--filter", "label="+sidecarLabel); err == nil {
for _, id := range strings.Fields(string(out)) {
_, _ = execDocker(ctx, "network", "rm", id)
}
}
}

// Start spins up a distribution:3 container on a fresh network and
// waits for its /v2/ endpoint to become reachable. Returns the
// *Sidecar and a stop function the caller MUST defer to tear down
// both the container and the network.
func (defaultSidecarLifecycle) Start(ctx context.Context) (*Sidecar, func(context.Context) error, error) {
// Opportunistic: clean up any zombie sidecars from prior runs that
// aborted before their deferred stop (panic, outer timeout,
// SIGKILL). Scoped by label so unrelated docker state is untouched.
sweepStaleSidecars(ctx)

// Ephemeral unique names to avoid collision across concurrent
// runs. Timestamp in UTC so the name is deterministic at the
// nanosecond level; strip the "." from the fractional-second
Expand All@@ -110,7 +145,7 @@ func (defaultSidecarLifecycle) Start(ctx context.Context) (*Sidecar, func(contex
network := "phpup-build-" + tag
containerName := "phpup-sidecar-" + tag

if err := dockerCmdCombined(ctx, "network", "create", network); err != nil {
if err := dockerCmdCombined(ctx, "network", "create", "--label", sidecarLabel, network); err != nil {
return nil, nil, fmt.Errorf("sidecar: create network: %w", err)
}

Expand All@@ -122,6 +157,7 @@ func (defaultSidecarLifecycle) Start(ctx context.Context) (*Sidecar, func(contex
"run", "-d", "--rm",
"--name", containerName,
"--network", network,
"--label", sidecarLabel,
"--publish", "127.0.0.1::5000",
"distribution/distribution:3",
)
Expand Down
67 changes: 67 additions & 0 deletions internal/build/sidecar_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -237,3 +237,70 @@ func TestSidecar_LifecycleAndSeed_Real(t *testing.T) {
t.Errorf("pulled bundle = %q, want %q", got, bundlePayload)
}
}

// TestSidecar_SweepsZombiesOnStart_Real seeds a fake zombie container
// + network labeled as prior-run sidecars, then calls Start and asserts
// the zombie is swept as a side effect. Guards against leaked state
// from runs killed by an outer signal/timeout before defer stop().
// Skipped under -short and when docker is absent.
func TestSidecar_SweepsZombiesOnStart_Real(t *testing.T) {
if testing.Short() {
t.Skip("skipping real docker test under -short")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skipf("docker not found: %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

// Step 1: create a FAKE zombie: a container + network labeled as a
// sidecar but never properly torn down.
zombieName := "phpup-sidecar-zombie-" + strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "")
zombieNet := "phpup-build-zombie-" + strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "")

if _, err := execDocker(ctx, "network", "create", "--label", "buildrush.phpup.sidecar=1", zombieNet); err != nil {
t.Fatalf("seed zombie network: %v", err)
}
// Use a tiny image; we don't care about the registry functionality here.
if _, err := execDocker(ctx, "run", "-d", "--name", zombieName,
"--network", zombieNet,
"--label", "buildrush.phpup.sidecar=1",
"alpine:3", "sleep", "300"); err != nil {
// Cleanup before failing.
_, _ = execDocker(ctx, "network", "rm", zombieNet)
t.Fatalf("seed zombie container: %v", err)
}

// Step 2: verify zombie exists.
outBefore, _ := execDocker(ctx, "ps", "-aq", "--filter", "name="+zombieName)
if strings.TrimSpace(string(outBefore)) == "" {
_, _ = execDocker(ctx, "rm", "-f", zombieName)
_, _ = execDocker(ctx, "network", "rm", zombieNet)
t.Fatal("zombie container not created")
}

// Step 3: Start a fresh sidecar — should sweep the zombie as a side effect.
sc, stop, err := defaultSidecarLifecycle{}.Start(ctx)
if err != nil {
// Cleanup any remaining zombies.
_, _ = execDocker(ctx, "rm", "-f", zombieName)
_, _ = execDocker(ctx, "network", "rm", zombieNet)
t.Fatalf("Start: %v", err)
}
defer func() { _ = stop(context.Background()) }()

// Step 4: verify the zombie container is gone.
outAfter, _ := execDocker(ctx, "ps", "-aq", "--filter", "name="+zombieName)
if strings.TrimSpace(string(outAfter)) != "" {
// Force cleanup.
_, _ = execDocker(ctx, "rm", "-f", zombieName)
_, _ = execDocker(ctx, "network", "rm", zombieNet)
t.Errorf("zombie container %s was NOT swept by Start", zombieName)
}

// Sanity: the new sidecar's name is different from the zombie.
if sc.Name == zombieName {
t.Errorf("new sidecar collided with zombie name: %s", sc.Name)
}
}
39 changes: 32 additions & 7 deletions internal/planner/planner.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"

Expand All@@ -23,6 +24,14 @@ type MatrixCell struct {
Extension string `json:"extension,omitempty"`
ExtVer string `json:"ext_version,omitempty"`
PHPAbi string `json:"php_abi,omitempty"`

// CoreDigest is the OCI manifest digest of the prerequisite php-core
// bundle for this ext cell (e.g., "sha256:abc..."). Populated ONLY for
// ext cells; zero for php/tool cells. Surfaced in the emitted matrix
// JSON as `core_digest` so build-extension.yml can pass it to
// `phpup build ext --php-core-digest`. omitempty keeps the JSON
// backward-compatible — php/tool cells don't gain a noisy empty field.
CoreDigest string `json:"core_digest,omitempty"`
}

// Matrix is the GitHub Actions matrix JSON format.
Expand DownExpand Up@@ -60,7 +69,16 @@ func ExpandPHPMatrix(spec *catalog.PHPSpec) []MatrixCell {
}

// ExpandExtMatrix expands an extension's abi_matrix, applying excludes.
func ExpandExtMatrix(spec *catalog.ExtensionSpec) []MatrixCell {
//
// coreDigestByKey maps a canonical PHP bundle key (matching
// lockfile.PHPBundleKey — "php:<ver>:<os>:<arch>:<ts>") to the resolved OCI
// digest of the prerequisite php-core bundle. The resolved digest (if any) is
// stored on each ext cell's CoreDigest field. Pass nil if no digest context
// is available; cells will have empty CoreDigest and a warning is logged per
// unresolved cell. The zero-value behavior intentionally matches the existing
// "missing ABI row" case (no silent skip) — so Task 5's consumer must treat
// empty CoreDigest as a hard error, not as "fall back to tag-form".
func ExpandExtMatrix(spec *catalog.ExtensionSpec, coreDigestByKey map[string]string) []MatrixCell {
if spec.Kind == catalog.ExtensionKindBundled {
return nil
}
Expand All@@ -74,13 +92,20 @@ func ExpandExtMatrix(spec *catalog.ExtensionSpec) []MatrixCell {
if isExcluded(spec.Exclude, osName, arch, php) {
continue
}
coreKey := fmt.Sprintf("php:%s:%s:%s:%s", php, osName, arch, ts)
digest := coreDigestByKey[coreKey]
if digest == "" && coreDigestByKey != nil {
log.Printf("WARN: ExpandExtMatrix: no core digest for ext=%s ext_ver=%s php=%s os=%s arch=%s ts=%s (key=%s); cell will have empty CoreDigest",
spec.Name, ver, php, osName, arch, ts, coreKey)
}
cells = append(cells, MatrixCell{
Extension: spec.Name,
ExtVer: ver,
PHPAbi: fmt.Sprintf("%s-%s", php, ts),
OS: osName,
Arch: arch,
TS: ts,
Extension: spec.Name,
ExtVer: ver,
PHPAbi: fmt.Sprintf("%s-%s", php, ts),
OS: osName,
Arch: arch,
TS: ts,
CoreDigest: digest,
})
}
}
Expand Down
Loading
Loading