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
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,12 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:
inputs:
force:
description: "Bypass the spec-hash cache-probe and rebuild every bundle from source. Used by security-rebuild and manual full-rebuild dispatches; leave unchecked for a routine run."
type: boolean
default: false

permissions:
contents: read
Expand DownExpand Up@@ -100,7 +106,13 @@ jobs:
- name: Run ci-cell (build + test)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make ci-cell OS=${{ matrix.os }} ARCH=${{ matrix.arch }} PHP=${{ matrix.php }}
# workflow_dispatch threads `inputs.force` through so a manual
# full-rebuild run (or a security-rebuild trigger) bypasses the
# spec-hash cache-probe in `phpup build cell`. Empty string for
# every other event type — make ci-cell treats empty FORCE as
# "do the normal cache-probe".
FORCE: ${{ inputs.force && '1' || '' }}
run: make ci-cell OS=${{ matrix.os }} ARCH=${{ matrix.arch }} PHP=${{ matrix.php }} FORCE="$FORCE"
- name: Upload oci-layout artifact (main only)
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
Expand Down
36 changes: 25 additions & 11 deletions .github/workflows/security-rebuild.yml
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
name: security-rebuild

# Triggers a force-rebuild of the full bundle pipeline via ci.yml's
# workflow_dispatch path. Fires on repository_dispatch so operators or
# security automation can request a fresh build even when no source file
# changed — useful when a system library (e.g. libssl, libxml2) ships a
# CVE fix and the downstream bundles need re-linking against the patched
# library.
on:
repository_dispatch:
types: [security-rebuild]

permissions:
actions: write

jobs:
pipeline:
permissions:
contents: write
packages: write
pull-requests: write
id-token: write
uses: ./.github/workflows/plan-and-build.yml
with:
force: true
push: true
secrets: inherit
dispatch-rebuild:
runs-on: ubuntu-24.04
steps:
- name: Dispatch ci.yml with force=true
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci.yml',
ref: 'main',
inputs: { force: 'true' },
});
core.info('Dispatched ci.yml with force=true on main');
19 changes: 16 additions & 3 deletions .github/workflows/watch-php-releases.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ on:

permissions:
contents: write
actions: write

jobs:
check:
Expand All@@ -28,8 +29,20 @@ jobs:
git add .state/php-versions.json
git commit -m "chore: observed new PHP release"
git push
- name: Dispatch rebuild
- name: Dispatch ci.yml for rebuild
if: steps.poll.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v4
uses: actions/github-script@v7
with:
event-type: php-release-detected
# force=false — a new upstream PHP release alone doesn't
# invalidate existing bundles. The .state/ commit above is the
# canonical signal; this dispatch just lets the pipeline rerun
# over the latest main (catalog update lands separately).
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci.yml',
ref: 'main',
inputs: { force: 'false' },
});
core.info('Dispatched ci.yml on main after observing new PHP release');
23 changes: 19 additions & 4 deletions .github/workflows/watch-runner-images.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ on:

permissions:
contents: write
actions: write

jobs:
check:
Expand All@@ -14,9 +15,11 @@ jobs:
- uses: actions/checkout@v6
- name: Check runner images
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LATEST=$(curl -sSf "https://api.github.com/repos/actions/runner-images/releases/latest" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" | jq -r .tag_name)
-H "Authorization: Bearer $GH_TOKEN" | jq -r .tag_name)
CURRENT=$(cat .state/runner-images-tag.txt 2>/dev/null || echo "")
if [ "$LATEST" != "$CURRENT" ]; then
echo "$LATEST" > .state/runner-images-tag.txt
Expand All@@ -30,8 +33,20 @@ jobs:
git add .state/
git commit -m "chore: observed runner image update"
git push
- name: Dispatch rebuild
- name: Dispatch ci.yml for rebuild
if: steps.check.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v4
uses: actions/github-script@v7
with:
event-type: runner-image-updated
# force=false — runner-image updates don't invalidate existing
# bundles on their own. ci.yml on main re-runs against the new
# runner release so any drift surfaces; operators decide whether
# to escalate to a force-rebuild via security-rebuild dispatch.
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci.yml',
ref: 'main',
inputs: { force: 'false' },
});
core.info('Dispatched ci.yml on main after observing runner-image update');
7 changes: 6 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,10 @@ bundle-ext: $(PHPUP_BIN)
# OS, ARCH, PHP; REGISTRY defaults to the local OCI layout used by
# bundle-php/bundle-ext. Exercised end-to-end via `make ci`.
#
# Set FORCE=1 to skip the spec-hash cache-probe and rebuild php-core + every
# ext from source even when a matching bundle is already in REGISTRY. Used
# by security-rebuild and manual workflow_dispatch invocations.
#
# The cell container is always linux/<ARCH>, so phpup must be cross-compiled
# to match — a darwin/arm64 host binary or a linux/amd64 host binary running
# an aarch64 cell would trip `exec format error`. We resolve the right
Expand All@@ -133,14 +137,15 @@ ci-cell:
aarch64|arm64) phpup_linux_bin="bin/phpup-linux-arm64" ;; \
*) echo "ci-cell: unknown ARCH=$(ARCH), want x86_64 or aarch64" >&2; exit 2 ;; \
esac; \
force_flag=""; [ -n "$(FORCE)" ] && force_flag="--force"; \
$(MAKE) "$$phpup_linux_bin" && \
$(MAKE) $(PHPUP_BIN) && \
$(PHPUP_BIN) build cell \
--os $(OS) \
--arch $(ARCH) \
--php $(PHP) \
--registry $(or $(REGISTRY),oci-layout:./out/oci-layout) \
--repo . && \
--repo . $$force_flag && \
$(PHPUP_BIN) test \
--os $(OS) \
--arch $(ARCH) \
Expand Down
70 changes: 50 additions & 20 deletions internal/build/build.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,16 +129,23 @@ func BuildPHP(ctx context.Context, args []string) error {
// Remote backends return ErrUnsupported for LookupBySpec; treat that
// as a soft miss so callers without an oci-layout cache fall through
// to building. Hard errors from a layout backend still propagate.
ref, hit, err := store.LookupBySpec(ctx, "php-core", specHash)
if errors.Is(err, registry.ErrUnsupported) {
hit, err = false, nil
}
if err != nil {
return fmt.Errorf("phpup build php: lookup by spec: %w", err)
}
if hit {
fmt.Printf("phpup build php: cache hit %s (spec-hash %s)\n", ref.Digest, specHash)
return nil
// --force bypasses the probe entirely so security rebuilds / manual
// dispatches always produce fresh artifacts even when the spec-hash
// matches a published bundle.
if opts.Force {
fmt.Printf("phpup build php: --force, skipping cache-probe (spec-hash %s)\n", specHash)
} else {
ref, hit, err := store.LookupBySpec(ctx, "php-core", specHash)
if errors.Is(err, registry.ErrUnsupported) {
hit, err = false, nil
}
if err != nil {
return fmt.Errorf("phpup build php: lookup by spec: %w", err)
}
if hit {
fmt.Printf("phpup build php: cache hit %s (spec-hash %s)\n", ref.Digest, specHash)
return nil
}
}

// 3. Prepare output mount dir. Default is <repo>/build/php/<slug>/ so
Expand DownExpand Up@@ -260,16 +267,21 @@ func BuildExt(ctx context.Context, args []string) error {
// Remote backends return ErrUnsupported for LookupBySpec; treat that
// as a soft miss so callers without an oci-layout cache fall through
// to building. Hard errors from a layout backend still propagate.
ref, hit, err := store.LookupBySpec(ctx, bundleName, specHash)
if errors.Is(err, registry.ErrUnsupported) {
hit, err = false, nil
}
if err != nil {
return fmt.Errorf("phpup build ext: lookup by spec: %w", err)
}
if hit {
fmt.Printf("phpup build ext: cache hit %s (spec-hash %s)\n", ref.Digest, specHash)
return nil
// --force bypasses the probe (see BuildPHP for rationale).
if opts.Force {
fmt.Printf("phpup build ext: --force, skipping cache-probe (spec-hash %s)\n", specHash)
} else {
ref, hit, err := store.LookupBySpec(ctx, bundleName, specHash)
if errors.Is(err, registry.ErrUnsupported) {
hit, err = false, nil
}
if err != nil {
return fmt.Errorf("phpup build ext: lookup by spec: %w", err)
}
if hit {
fmt.Printf("phpup build ext: cache hit %s (spec-hash %s)\n", ref.Digest, specHash)
return nil
}
}

// 3. Start sidecar + seed prerequisite core. The sidecar is tied to
Expand DownExpand Up@@ -450,6 +462,9 @@ func BuildCell(ctx context.Context, args []string) error {
if opts.OutDir != "" {
phpArgs = append(phpArgs, "--out-dir", opts.OutDir)
}
if opts.Force {
phpArgs = append(phpArgs, "--force")
}
fmt.Printf("phpup build cell: [1/%d] build php %s\n", total, opts.Version)
if err := BuildPHP(ctx, phpArgs); err != nil {
return fmt.Errorf("phpup build cell: build php: %w", err)
Expand DownExpand Up@@ -500,6 +515,9 @@ func BuildCell(ctx context.Context, args []string) error {
// /tmp mount.
extArgs = append(extArgs, "--out-dir", filepath.Join(opts.OutDir, "ext", ext.Name+"-"+ext.Version))
}
if opts.Force {
extArgs = append(extArgs, "--force")
}
fmt.Printf("phpup build cell: [%d/%d] build ext %s %s\n", i+2, total, ext.Name, ext.Version)
if err := BuildExt(ctx, extArgs); err != nil {
return fmt.Errorf("phpup build cell: build ext %s: %w", ext.Name, err)
Expand All@@ -522,6 +540,7 @@ type cellOpts struct {
Registry string // "oci-layout:./out/oci-layout" or "ghcr.io/..."
Repo string // absolute path to setup-php repo root
OutDir string // --out-dir override; empty = let BuildPHP/BuildExt derive
Force bool // --force: skip the spec-hash cache-probe and rebuild unconditionally
}

// parseCellFlags parses the flag tail for `phpup build cell`. The shape
Expand All@@ -540,6 +559,7 @@ func parseCellFlags(args []string) (*cellOpts, error) {
repo := fs.String("repo", ".", "Path to setup-php repo root")
outDir := fs.String("out-dir", "",
"Shared docker output directory root (defaults to <repo>/build/ derivation for each build subcommand)")
force := fs.Bool("force", false, "Skip the spec-hash cache-probe and rebuild every sub-build unconditionally")
if err := fs.Parse(args); err != nil {
return nil, err
}
Expand All@@ -565,6 +585,7 @@ func parseCellFlags(args []string) (*cellOpts, error) {
Registry: *registryFlag,
Repo: absRepo,
OutDir: *outDir,
Force: *force,
}, nil
}

Expand All@@ -584,6 +605,7 @@ type extOpts struct {
Repo string
OutDir string
CoreDigest string // "sha256:..." — required; resolved by caller
Force bool // --force: skip the spec-hash cache-probe and rebuild unconditionally
}

// parseExtFlags parses the flag tail for `phpup build ext`. The FlagSet
Expand All@@ -603,6 +625,7 @@ func parseExtFlags(args []string) (*extOpts, error) {
"Docker output directory (defaults to <repo>/build/ext/<name>-<version>-<php_abi>-<os>-<arch>/)")
coreDigest := fs.String("php-core-digest", "",
"Digest of the prerequisite php-core bundle (sha256:...). Required.")
force := fs.Bool("force", false, "Skip the spec-hash cache-probe and rebuild unconditionally")
if err := fs.Parse(args); err != nil {
return nil, err
}
Expand DownExpand Up@@ -638,6 +661,7 @@ func parseExtFlags(args []string) (*extOpts, error) {
Repo: absRepo,
OutDir: *outDir,
CoreDigest: *coreDigest,
Force: *force,
}, nil
}

Expand DownExpand Up@@ -713,6 +737,10 @@ type phpOpts struct {
// under <repo>/build/php/<version>-<os>-<arch>-<ts>/; non-empty =
// use verbatim (may be absolute or relative — absolutized in BuildPHP).
OutDir string
// Force skips the spec-hash cache-probe and rebuilds unconditionally.
// Used by security-rebuild and catalog-update dispatches to force a
// full rebuild even when the spec-hash matches an existing bundle.
Force bool
}

// parsePHPFlags parses the flag tail for `phpup build php`. The FlagSet
Expand All@@ -729,6 +757,7 @@ func parsePHPFlags(args []string) (*phpOpts, error) {
repo := fs.String("repo", ".", "Path to setup-php repo root")
outDir := fs.String("out-dir", "",
"Docker output directory (defaults to <repo>/build/php/<version>-<os>-<arch>-<ts>/)")
force := fs.Bool("force", false, "Skip the spec-hash cache-probe and rebuild unconditionally")
if err := fs.Parse(args); err != nil {
return nil, err
}
Expand DownExpand Up@@ -758,6 +787,7 @@ func parsePHPFlags(args []string) (*phpOpts, error) {
Registry: *registryFlag,
Repo: absRepo,
OutDir: *outDir,
Force: *force,
}, nil
}

Expand Down
49 changes: 49 additions & 0 deletions internal/build/build_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,6 +183,55 @@ func TestBuildPHP_CacheHit_ShortCircuitsWithoutRunning(t *testing.T) {
}
}

// TestBuildPHP_Force_BypassesCacheHit verifies that --force makes BuildPHP
// skip the LookupBySpec cache probe and actually invoke the runner even
// when an identically-spec-hashed bundle is already in the target layout.
// Regression test for the security-rebuild path: without --force, a
// published bundle with matching spec-hash short-circuits the build and
// the rebuild is effectively a no-op.
func TestBuildPHP_Force_BypassesCacheHit(t *testing.T) {
repo := t.TempDir()
writeRepoFixture(t, repo)
layoutDir := filepath.Join(t.TempDir(), "layout")

hash, err := ComputeSpecHash(&SpecHashInputs{
Kind: "php", Version: "8.4", OS: "linux", Arch: "x86_64", TS: "nts", Repo: repo,
})
if err != nil {
t.Fatalf("ComputeSpecHash: %v", err)
}
layoutURI := seedLayout(t, layoutDir, "php-core", hash)

var called bool
restore := SetRunner(func(ctx context.Context, opts *DockerRunOpts) error {
called = true
return fakeRunner([]byte("synthetic-bundle"))(ctx, opts)
})
defer restore()

out := captureStdout(t, func() {
err = BuildPHP(context.Background(), []string{
"--php", "8.4",
"--registry", layoutURI,
"--repo", repo,
"--out-dir", t.TempDir(),
"--force",
})
})
if err != nil {
t.Fatalf("BuildPHP --force: %v", err)
}
if !called {
t.Error("runner was NOT called under --force even though cache was populated")
}
if !strings.Contains(out, "--force, skipping cache-probe") {
t.Errorf("stdout = %q, want contains \"--force, skipping cache-probe\"", out)
}
if strings.Contains(out, "cache hit") {
t.Errorf("stdout = %q, must NOT contain \"cache hit\" under --force", out)
}
}

func TestBuildPHP_CacheMiss_InvokesRunnerThenPushes(t *testing.T) {
repo := t.TempDir()
writeRepoFixture(t, repo)
Expand Down
Loading