diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae7acdb..b81edbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index 6c916e1..f46bf23 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -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'); diff --git a/.github/workflows/watch-php-releases.yml b/.github/workflows/watch-php-releases.yml index b59d9de..f135ec1 100644 --- a/.github/workflows/watch-php-releases.yml +++ b/.github/workflows/watch-php-releases.yml @@ -6,6 +6,7 @@ on: permissions: contents: write + actions: write jobs: check: @@ -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'); diff --git a/.github/workflows/watch-runner-images.yml b/.github/workflows/watch-runner-images.yml index ed43207..049725c 100644 --- a/.github/workflows/watch-runner-images.yml +++ b/.github/workflows/watch-runner-images.yml @@ -6,6 +6,7 @@ on: permissions: contents: write + actions: write jobs: check: @@ -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 @@ -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'); diff --git a/Makefile b/Makefile index 7e7df77..ca1c0a1 100644 --- a/Makefile +++ b/Makefile @@ -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/, 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 @@ -133,6 +137,7 @@ 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 \ @@ -140,7 +145,7 @@ ci-cell: --arch $(ARCH) \ --php $(PHP) \ --registry $(or $(REGISTRY),oci-layout:./out/oci-layout) \ - --repo . && \ + --repo . $$force_flag && \ $(PHPUP_BIN) test \ --os $(OS) \ --arch $(ARCH) \ diff --git a/internal/build/build.go b/internal/build/build.go index 34534b7..4f2495b 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 /build/php// so @@ -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 @@ -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) @@ -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) @@ -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 @@ -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 /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 } @@ -565,6 +585,7 @@ func parseCellFlags(args []string) (*cellOpts, error) { Registry: *registryFlag, Repo: absRepo, OutDir: *outDir, + Force: *force, }, nil } @@ -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 @@ -603,6 +625,7 @@ func parseExtFlags(args []string) (*extOpts, error) { "Docker output directory (defaults to /build/ext/----/)") 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 } @@ -638,6 +661,7 @@ func parseExtFlags(args []string) (*extOpts, error) { Repo: absRepo, OutDir: *outDir, CoreDigest: *coreDigest, + Force: *force, }, nil } @@ -713,6 +737,10 @@ type phpOpts struct { // under /build/php/---/; 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 @@ -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 /build/php/---/)") + force := fs.Bool("force", false, "Skip the spec-hash cache-probe and rebuild unconditionally") if err := fs.Parse(args); err != nil { return nil, err } @@ -758,6 +787,7 @@ func parsePHPFlags(args []string) (*phpOpts, error) { Registry: *registryFlag, Repo: absRepo, OutDir: *outDir, + Force: *force, }, nil } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 96f64d7..1bc18c8 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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)