From a45800171b009a3d805dd270e14eb358e07d964f Mon Sep 17 00:00:00 2001 From: Maximilian Date: Fri, 24 Apr 2026 09:55:24 +0200 Subject: [PATCH] feat(ci): cut over to unified ci.yml, delete legacy workflows (PR 5/6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the cutover planned in docs/superpowers/specs/2026-04-23- local-ci-unification-design.md. The new ci.yml (PR 4) is now the sole CI + publish path; the parallel-run grace period is over. Deleted workflows: build-php-core.yml, build-extension.yml — superseded by ci.yml::pipeline (phpup build cell + phpup test) plan.yml, plan-and-build.yml, on-push.yml — orchestrator layer replaced by ci.yml's inline matrix integration-test.yml, compat-harness.yml — fixtures now run inside each pipeline cell via phpup test (see test/compat/fixtures.yaml) nightly.yml, manual.yml, gc-bundles.yml — scheduled/dispatch entry points removed; gc-bundles still usable on-demand as `go run ./cmd/gc-bundles --org buildrush --min-age-days N` bootstrap.yml — depended on plan.yml + build-php-core.yml + build-extension.yml; dead after those deletions ci-lint.yml — folded into ci.yml::lint (adds `go vet` step; golangci-lint / gofmt / mod-tidy / test / build were already covered by ci.yml's lint+unit jobs) Deleted scripts: test/smoke/local-ci.sh — docker smoke replaced by `make ci-cell`, which runs one full pipeline cell (phpup build cell + phpup test) locally. The `local-ci` Makefile target is removed with it. Publish cutover: ci.yml::publish drops the `vars.PHPUP_NEW_CI_PUBLISH == '1'` gate and the "(new ci.yml)" marker in the lockfile commit message — it's now the sole writer to ghcr.io and the lockfile. Housekeeping: - Makefile: drop `check-fast` (was only distinct from `check` via the now-gone local-ci smoke), retarget `check` at the fast path. - CLAUDE.md: Quality Gates + Testing sections updated to point at `make ci-cell` for full-cell validation instead of the deleted local-ci smoke. - CONTRIBUTING.md: refer to ci.yml instead of ci-lint.yml; note that gc-bundles is now manual. - README.md: v2-compat section now describes fixture coverage inside ci.yml::pipeline instead of the deleted compat-harness.yml. - Refresh stale code comments in cmd/{planner,gc-bundles,phpup}, internal/build/, internal/planner/, internal/registry/ that pointed at deleted workflow files. Verification: make check passes; verified ci.yml is the only CI workflow triggering on push/PR to main (watch-*.yml and security-rebuild.yml re-trigger ci.yml; release-please.yml + check-release-pr.yml are orthogonal release tooling, retained). --- .github/workflows/bootstrap.yml | 40 ---- .github/workflows/build-extension.yml | 151 ------------- .github/workflows/build-php-core.yml | 146 ------------ .github/workflows/ci-lint.yml | 73 ------ .github/workflows/ci.yml | 8 +- .github/workflows/compat-harness.yml | 221 ------------------- .github/workflows/gc-bundles.yml | 44 ---- .github/workflows/integration-test.yml | 116 ---------- .github/workflows/manual.yml | 25 --- .github/workflows/nightly.yml | 37 ---- .github/workflows/on-push.yml | 27 --- .github/workflows/plan-and-build.yml | 113 ---------- .github/workflows/plan.yml | 44 ---- CLAUDE.md | 5 +- CONTRIBUTING.md | 4 +- Makefile | 41 +--- README.md | 19 +- cmd/gc-bundles/main.go | 5 +- cmd/phpup/main.go | 7 +- cmd/planner/main.go | 11 +- internal/build/build.go | 14 +- internal/build/build_test.go | 7 +- internal/planner/planner.go | 2 +- internal/registry/media_types.go | 26 +-- internal/registry/remote.go | 12 +- internal/registry/remote_test.go | 4 +- test/compat/fixtures.yaml | 5 +- test/smoke/local-ci.sh | 294 ------------------------- 28 files changed, 75 insertions(+), 1426 deletions(-) delete mode 100644 .github/workflows/bootstrap.yml delete mode 100644 .github/workflows/build-extension.yml delete mode 100644 .github/workflows/build-php-core.yml delete mode 100644 .github/workflows/ci-lint.yml delete mode 100644 .github/workflows/compat-harness.yml delete mode 100644 .github/workflows/gc-bundles.yml delete mode 100644 .github/workflows/integration-test.yml delete mode 100644 .github/workflows/manual.yml delete mode 100644 .github/workflows/nightly.yml delete mode 100644 .github/workflows/on-push.yml delete mode 100644 .github/workflows/plan-and-build.yml delete mode 100644 .github/workflows/plan.yml delete mode 100755 test/smoke/local-ci.sh diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml deleted file mode 100644 index 6c8e8c8..0000000 --- a/.github/workflows/bootstrap.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: bootstrap -on: - workflow_dispatch: - -permissions: - packages: write - id-token: write - -jobs: - plan: - uses: ./.github/workflows/plan.yml - with: - force: true - - build-php: - needs: plan - if: needs.plan.outputs.php_matrix != '{"include":[]}' - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.php_matrix) }} - uses: ./.github/workflows/build-php-core.yml - with: - version: ${{ matrix.version }} - os: ${{ matrix.os }} - arch: ${{ matrix.arch }} - ts: ${{ matrix.ts }} - - build-ext: - needs: [plan, build-php] - if: needs.plan.outputs.ext_matrix != '{"include":[]}' - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.ext_matrix) }} - uses: ./.github/workflows/build-extension.yml - with: - extension: ${{ matrix.extension }} - ext_version: ${{ matrix.ext_version }} - php_abi: ${{ matrix.php_abi }} - os: ${{ matrix.os }} - arch: ${{ matrix.arch }} diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml deleted file mode 100644 index f82affd..0000000 --- a/.github/workflows/build-extension.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: build-extension -on: - workflow_call: - inputs: - extension: - required: true - type: string - ext_version: - required: true - type: string - php_abi: - required: true - type: string - os: - required: true - type: string - arch: - required: true - type: string - spec_hash: - 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 - default: true - outputs: - digest: - description: 'OCI digest of the pushed bundle (empty when push=false)' - value: ${{ jobs.build.outputs.digest }} - spec_hash: - description: 'Echoed spec_hash for lockfile update' - value: ${{ inputs.spec_hash }} - -jobs: - build: - name: 'ext ${{ inputs.extension }} ${{ inputs.ext_version }} @ PHP ${{ inputs.php_abi }}' - runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} - permissions: - packages: write - id-token: write - outputs: - digest: ${{ steps.push.outputs.digest }} - steps: - - uses: actions/checkout@v6 - - - name: Install oras - env: - ORAS_ARCH: ${{ inputs.arch == 'aarch64' && 'arm64' || 'amd64' }} - run: | - 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 - - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - - name: Build phpup - run: make bin/phpup - - # 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 }} - 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 - if: ${{ inputs.push }} - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "$GHCR_TOKEN" | oras login ghcr.io -u ${{ github.actor }} --password-stdin - DIGEST=$(cat /tmp/bundle.tar.zst.sha256) - TAG="${{ inputs.ext_version }}-${{ inputs.php_abi }}-${{ inputs.os }}-${{ inputs.arch }}" - oras push \ - --disable-path-validation \ - "ghcr.io/${{ github.repository_owner }}/php-ext-${{ inputs.extension }}:${TAG}" \ - --artifact-type application/vnd.buildrush.php-ext.v1 \ - /tmp/bundle.tar.zst:application/vnd.oci.image.layer.v1.tar+zstd \ - /tmp/meta.json:application/vnd.buildrush.meta.v1+json - echo "digest=sha256:$(echo -n "$DIGEST" | sed 's/^sha256://')" >> "$GITHUB_OUTPUT" - - - uses: sigstore/cosign-installer@v3 - if: ${{ inputs.push }} - - name: Sign bundle - if: ${{ inputs.push }} - env: - EXT_NAME: ${{ inputs.extension }} - OWNER: ${{ github.repository_owner }} - run: | - DIGEST=$(cat /tmp/bundle.tar.zst.sha256 | sed 's/^sha256://') - REF="ghcr.io/${OWNER}/php-ext-${EXT_NAME}@sha256:${DIGEST}" - # Retry on Fulcio OIDC transients ("invalid character 'u' looking - # for beginning of value") — affects ~5-10% of cosign invocations - # under load. Backoff: 5s, 15s, 30s, 60s, 120s. - for attempt in 1 2 3 4 5; do - if cosign sign --yes "$REF"; then - echo "cosign sign succeeded on attempt $attempt" - exit 0 - fi - case "$attempt" in 1) sleep 5;; 2) sleep 15;; 3) sleep 30;; 4) sleep 60;; *) sleep 120;; esac - done - echo "::error::cosign sign failed after 5 attempts for $REF" - exit 1 - - - name: Upload bundle artifact (PR) - if: ${{ !inputs.push }} - uses: actions/upload-artifact@v7 - with: - name: php-ext-${{ inputs.extension }}-${{ inputs.ext_version }}-${{ inputs.php_abi }}-${{ inputs.os }}-${{ inputs.arch }} - path: | - /tmp/bundle.tar.zst - /tmp/meta.json - retention-days: 3 diff --git a/.github/workflows/build-php-core.yml b/.github/workflows/build-php-core.yml deleted file mode 100644 index 993627d..0000000 --- a/.github/workflows/build-php-core.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: build-php-core -on: - workflow_call: - inputs: - version: - required: true - type: string - os: - required: true - type: string - arch: - required: true - type: string - ts: - required: true - type: string - spec_hash: - required: false - type: string - default: '' - push: - required: false - type: boolean - default: true - outputs: - digest: - description: 'OCI digest of the pushed bundle (empty when push=false)' - value: ${{ jobs.build.outputs.digest }} - spec_hash: - description: 'Echoed spec_hash for lockfile update' - value: ${{ inputs.spec_hash }} - -jobs: - build: - name: 'core PHP ${{ inputs.version }} (${{ inputs.os }}/${{ inputs.arch }}/${{ inputs.ts }})' - runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} - permissions: - packages: write - id-token: write - outputs: - digest: ${{ steps.push.outputs.digest }} - steps: - - uses: actions/checkout@v6 - - - name: Install oras - env: - ORAS_ARCH: ${{ inputs.arch == 'aarch64' && 'arm64' || 'amd64' }} - run: | - 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 - - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - - name: Build phpup - run: make bin/phpup - - # Run the PHP-core build via phpup. phpup docker-wraps - # builders/linux/build-php.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", "Smoke test", 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 PHP core via phpup - env: - PHP_VERSION: ${{ inputs.version }} - OS: ${{ inputs.os }} - ARCH: ${{ inputs.arch }} - TS: ${{ inputs.ts }} - PHPUP_OUT_DIR: ${{ github.workspace }}/build/php/${{ inputs.version }}-${{ inputs.os }}-${{ inputs.arch }}-${{ inputs.ts }} - run: | - ./bin/phpup build php \ - --php "$PHP_VERSION" \ - --os "$OS" \ - --arch "$ARCH" \ - --ts "$TS" \ - --registry oci-layout:./out/oci-layout \ - --repo . \ - --out-dir "$PHPUP_OUT_DIR" - - - name: Stage bundle for publish - env: - PHPUP_OUT_DIR: ${{ github.workspace }}/build/php/${{ inputs.version }}-${{ inputs.os }}-${{ inputs.arch }}-${{ inputs.ts }} - 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 - if: ${{ inputs.push }} - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "$GHCR_TOKEN" | oras login ghcr.io -u ${{ github.actor }} --password-stdin - DIGEST=$(cat /tmp/bundle.tar.zst.sha256) - TAG="${{ inputs.version }}-${{ inputs.os }}-${{ inputs.arch }}-${{ inputs.ts }}" - oras push \ - --disable-path-validation \ - "ghcr.io/${{ github.repository_owner }}/php-core:${TAG}" \ - --artifact-type application/vnd.buildrush.php-core.v1 \ - /tmp/bundle.tar.zst:application/vnd.oci.image.layer.v1.tar+zstd \ - /tmp/meta.json:application/vnd.buildrush.meta.v1+json - echo "digest=sha256:$(echo -n "$DIGEST" | sed 's/^sha256://')" >> "$GITHUB_OUTPUT" - - - uses: sigstore/cosign-installer@v3 - if: ${{ inputs.push }} - - name: Sign bundle - if: ${{ inputs.push }} - env: - OWNER: ${{ github.repository_owner }} - run: | - DIGEST=$(cat /tmp/bundle.tar.zst.sha256 | sed 's/^sha256://') - REF="ghcr.io/${OWNER}/php-core@sha256:${DIGEST}" - # Retry on Fulcio OIDC transients ("invalid character 'u' looking - # for beginning of value") — affects ~5-10% of cosign invocations - # under load. Backoff: 5s, 15s, 30s, 60s, 120s. - for attempt in 1 2 3 4 5; do - if cosign sign --yes "$REF"; then - echo "cosign sign succeeded on attempt $attempt" - exit 0 - fi - case "$attempt" in 1) sleep 5;; 2) sleep 15;; 3) sleep 30;; 4) sleep 60;; *) sleep 120;; esac - done - echo "::error::cosign sign failed after 5 attempts for $REF" - exit 1 - - - name: Smoke test - run: | - mkdir -p /tmp/smoke - tar --zstd -xf /tmp/bundle.tar.zst -C /tmp/smoke - /tmp/smoke/usr/local/bin/php -v - /tmp/smoke/usr/local/bin/php -m - - - name: Upload bundle artifact (PR) - if: ${{ !inputs.push }} - uses: actions/upload-artifact@v7 - with: - name: php-core-${{ inputs.version }}-${{ inputs.os }}-${{ inputs.arch }}-${{ inputs.ts }} - path: | - /tmp/bundle.tar.zst - /tmp/meta.json - retention-days: 3 diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml deleted file mode 100644 index 26e52bf..0000000 --- a/.github/workflows/ci-lint.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: ci-lint -on: - pull_request: - branches: [main] - -permissions: - contents: read - -concurrency: - group: ci-lint-${{ github.ref }} - cancel-in-progress: true - -jobs: - go-lint: - name: Go Lint & Test - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Prepare embedded lockfile - run: cp bundles.lock cmd/phpup/bundles.lock - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: latest - - name: go vet - run: go vet ./... - - name: gofmt check - run: | - UNFMT=$(gofmt -s -l .) - if [ -n "$UNFMT" ]; then - echo "Files need gofmt:" - echo "$UNFMT" - exit 1 - fi - - name: go mod tidy check - run: | - cp go.mod go.mod.bak - cp go.sum go.sum.bak 2>/dev/null || true - go mod tidy - diff go.mod go.mod.bak - diff go.sum go.sum.bak 2>/dev/null || true - - name: go test - run: go test -race -cover ./... - - name: go build - run: | - go build ./cmd/phpup - go build ./cmd/planner - - node-lint: - name: Node.js Lint - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: '24' - - run: npm ci - - run: npx eslint src/ test/ - - run: npx prettier --check src/ test/ - - run: npm test - - ci-lint-gate: - name: ci-lint gate - if: always() - needs: [go-lint, node-lint] - runs-on: ubuntu-24.04 - steps: - - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e0c248..f0ddbd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: latest + - name: go vet + run: go vet ./... - name: Install node deps run: npm ci - name: ESLint @@ -113,9 +115,9 @@ jobs: retention-days: 7 publish: - name: Publish to GHCR (gated) + name: Publish to GHCR needs: pipeline - if: github.ref == 'refs/heads/main' && vars.PHPUP_NEW_CI_PUBLISH == '1' + if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -156,6 +158,6 @@ jobs: if git diff --cached --quiet; then echo "No lockfile changes to commit." else - git commit -m "chore: update bundles.lock (new ci.yml)" + git commit -m "chore: update bundles.lock" git push fi diff --git a/.github/workflows/compat-harness.yml b/.github/workflows/compat-harness.yml deleted file mode 100644 index a9dc386..0000000 --- a/.github/workflows/compat-harness.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: compat-harness -on: - pull_request: - branches: [main] - paths-ignore: - - 'catalog/**' - - 'builders/**' - push: - branches: [main] - workflow_call: - workflow_dispatch: - -permissions: - contents: read - packages: read - -# Scope concurrency by event so a new PR push cancels its older harness runs, -# but does NOT cancel a workflow_call invocation from plan-and-build's -# post-rebuild path (which exercises the just-built bundles and must run to -# completion). -concurrency: - group: compat-harness-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_call' }} - -jobs: - build: - name: 'Build phpup (${{ matrix.goarch }})' - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - goarch: [amd64, arm64] - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Build binary - run: | - cp bundles.lock cmd/phpup/bundles.lock - GOOS=linux GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 go build -o phpup ./cmd/phpup - rm -f cmd/phpup/bundles.lock - - uses: actions/upload-artifact@v7 - with: - name: phpup-${{ matrix.goarch }} - path: phpup - retention-days: 1 - - fixtures: - name: Load fixtures - runs-on: ubuntu-24.04 - outputs: - matrix: ${{ steps.load.outputs.matrix }} - steps: - - uses: actions/checkout@v6 - - id: load - # Filter fixtures by (PHP version, arch) pairs present in bundles.lock - # so a fixture referencing a not-yet-built combination (new PHP version - # OR new arch) doesn't fail fast against an unresolvable lockfile entry. - # Plan-and-build's harness call (after update-lock) reads the - # freshly-committed lockfile and picks up the full set; the standalone - # harness here only runs fixtures whose (version, arch) is already - # buildable on the current ref. - # - # Fixtures default arch to 'x86_64' when the field is absent so existing - # 50 x86_64 fixtures stay covered without schema churn. - # - # runner_os-specified fixtures (e.g. bare-jammy-) validate the - # jammy-built bundles landing via BUILDER_OS=ubuntu-22.04. Those - # bundles only exist after plan-and-build's post-merge rebuild. - # Skip them in standalone (pull_request / push / workflow_dispatch) - # invocations; include them via workflow_call from plan-and-build. - env: - EVENT_NAME: ${{ github.event_name }} - run: | - pairs_json=$(jq -c '[.bundles | keys[] | select(startswith("php:")) | capture("^php:(?[^:]+):linux:(?[^:]+):") | .v + "/" + .a] | unique' bundles.lock) - matrix=$(yq -o=json '.fixtures' test/compat/fixtures.yaml \ - | jq --argjson p "$pairs_json" -c '{include: [.[] | select((.["php-version"] + "/" + (.arch // "x86_64")) as $pa | $p | index($pa))]}') - if [ "$EVENT_NAME" != "workflow_call" ]; then - matrix=$(echo "$matrix" | jq -c '{include: [.include[] | select((.runner_os // "") == "")]}') - fi - { - echo "matrix<> "$GITHUB_OUTPUT" - - ours: - name: 'ours: ${{ matrix.name }}' - needs: [build, fixtures] - runs-on: >- - ${{ matrix.arch == 'aarch64' - && (matrix.runner_os == 'ubuntu-22.04' && 'ubuntu-22.04-arm' || 'ubuntu-24.04-arm') - || (matrix.runner_os == 'ubuntu-22.04' && 'ubuntu-22.04' || 'ubuntu-24.04') }} - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.fixtures.outputs.matrix) }} - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v8 - with: - name: phpup-${{ matrix.arch == 'aarch64' && 'arm64' || 'amd64' }} - - - name: Place binary in tool cache - run: | - mkdir -p "$RUNNER_TOOL_CACHE/buildrush-bin" - cp phpup "$RUNNER_TOOL_CACHE/buildrush-bin/phpup" - chmod +x "$RUNNER_TOOL_CACHE/buildrush-bin/phpup" - - - name: Snapshot env + PATH before setup-php - run: | - env > /tmp/env-before - echo -n "$PATH" > /tmp/path-before - - - name: Setup PHP (ours) - uses: ./ - with: - php-version: ${{ matrix.php-version }} - extensions: ${{ matrix.extensions }} - ini-values: ${{ matrix.ini-values }} - coverage: ${{ matrix.coverage }} - ini-file: ${{ matrix['ini-file'] }} - - - name: Probe - run: | - chmod +x test/compat/probe.sh - bash test/compat/probe.sh \ - /tmp/ours.json \ - /tmp/env-before \ - /tmp/path-before \ - test/compat/ini-keys.txt - - - uses: actions/upload-artifact@v7 - with: - name: probe-ours-${{ matrix.name }} - path: /tmp/ours.json - retention-days: 1 - - theirs: - name: 'theirs: ${{ matrix.name }}' - needs: [fixtures] - runs-on: >- - ${{ matrix.arch == 'aarch64' - && (matrix.runner_os == 'ubuntu-22.04' && 'ubuntu-22.04-arm' || 'ubuntu-24.04-arm') - || (matrix.runner_os == 'ubuntu-22.04' && 'ubuntu-22.04' || 'ubuntu-24.04') }} - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.fixtures.outputs.matrix) }} - steps: - - uses: actions/checkout@v6 - - - name: Snapshot env + PATH before setup-php - run: | - env > /tmp/env-before - echo -n "$PATH" > /tmp/path-before - - - name: Setup PHP (shivammathur/setup-php@) - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f - with: - php-version: ${{ matrix.php-version }} - extensions: ${{ matrix.extensions }} - ini-values: ${{ matrix.ini-values }} - coverage: ${{ matrix.coverage }} - ini-file: ${{ matrix['ini-file'] }} - - - name: Probe - run: | - chmod +x test/compat/probe.sh - bash test/compat/probe.sh \ - /tmp/theirs.json \ - /tmp/env-before \ - /tmp/path-before \ - test/compat/ini-keys.txt - - - uses: actions/upload-artifact@v7 - with: - name: probe-theirs-${{ matrix.name }} - path: /tmp/theirs.json - retention-days: 1 - - diff: - name: 'diff: ${{ matrix.name }}' - needs: [fixtures, ours, theirs] - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.fixtures.outputs.matrix) }} - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - - uses: actions/download-artifact@v8 - with: - name: probe-ours-${{ matrix.name }} - path: probes/ours - - - uses: actions/download-artifact@v8 - with: - name: probe-theirs-${{ matrix.name }} - path: probes/theirs - - - name: Run compat-diff - run: | - go run ./cmd/compat-diff \ - --ours probes/ours/ours.json \ - --theirs probes/theirs/theirs.json \ - --allowlist docs/compat-matrix.md \ - --fixture '${{ matrix.name }}' - - compat-gate: - name: compat-harness gate - if: always() - needs: [build, fixtures, ours, theirs, diff] - runs-on: ubuntu-24.04 - steps: - - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/gc-bundles.yml b/.github/workflows/gc-bundles.yml deleted file mode 100644 index e7e9d8a..0000000 --- a/.github/workflows/gc-bundles.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: gc-bundles -on: - schedule: - - cron: "0 0 1 */3 *" - workflow_dispatch: - inputs: - confirm: - description: 'Actually delete candidates (otherwise dry-run)' - type: boolean - default: false - min_age_days: - description: 'Minimum age in days before a version is prunable' - type: number - default: 30 - -permissions: - contents: read - packages: write - -jobs: - gc: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Run GC - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - go run ./cmd/gc-bundles \ - --org ${{ github.repository_owner }} \ - --min-age-days ${{ inputs.min_age_days || 30 }} \ - ${{ inputs.confirm && '--confirm' || '' }} \ - | tee /tmp/gc-report.txt - - name: Upload GC report artifact - uses: actions/upload-artifact@v7 - with: - name: gc-bundles-report - path: /tmp/gc-report.txt - retention-days: 90 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml deleted file mode 100644 index f48fb13..0000000 --- a/.github/workflows/integration-test.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: integration-test -on: - pull_request: - branches: [main] - paths-ignore: - - 'catalog/**' - - 'builders/**' - push: - branches: [main] - workflow_call: - workflow_dispatch: - -permissions: - contents: read - packages: read - -jobs: - build: - name: Build phpup - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Build binary - run: | - cp bundles.lock cmd/phpup/bundles.lock - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o phpup ./cmd/phpup - rm -f cmd/phpup/bundles.lock - - uses: actions/upload-artifact@v7 - with: - name: phpup - path: phpup - retention-days: 1 - - test: - name: "Test: ${{ matrix.name }}" - needs: build - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - include: - - name: bare - extensions: '' - ini-values: '' - - name: redis - extensions: redis - ini-values: '' - - name: bundled - extensions: 'mbstring, intl, curl' - ini-values: '' - - name: ini - extensions: '' - ini-values: 'memory_limit=512M' - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v8 - with: - name: phpup - - - name: Place binary in tool cache - run: | - mkdir -p "$RUNNER_TOOL_CACHE/buildrush-bin" - cp phpup "$RUNNER_TOOL_CACHE/buildrush-bin/phpup" - chmod +x "$RUNNER_TOOL_CACHE/buildrush-bin/phpup" - - - name: Setup PHP - id: setup - uses: ./ - with: - php-version: '8.4' - extensions: ${{ matrix.extensions }} - ini-values: ${{ matrix.ini-values }} - - - name: Verify PHP version - run: | - php -v - php -v | grep -q "PHP 8.4" - - - name: Verify bundled extensions - run: | - for ext in mbstring curl intl zip openssl json pdo sodium gd opcache; do - php -i | grep -qi "${ext}" || { echo "FAIL: ${ext} not loaded"; exit 1; } - done - - - name: Verify redis extension - if: contains(matrix.extensions, 'redis') - run: | - php -r 'assert(extension_loaded("redis"));' - php -i | grep -qi "redis" - - - name: Verify ini values - if: matrix.ini-values != '' - run: | - php -i | grep -q "memory_limit => 512M" || { echo "FAIL: ini value not set"; exit 1; } - - - name: Verify action output - env: - PHP_VERSION: ${{ steps.setup.outputs.php-version }} - run: | - echo "Resolved version: $PHP_VERSION" - test -n "$PHP_VERSION" - [[ "$PHP_VERSION" == 8.4* ]] - - integration-gate: - name: integration-test gate - if: always() - needs: [build, test] - runs-on: ubuntu-24.04 - steps: - - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml deleted file mode 100644 index 0413381..0000000 --- a/.github/workflows/manual.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: manual -on: - workflow_dispatch: - inputs: - force: - description: 'Force rebuild even if digests match' - type: boolean - default: false - push: - description: 'Push bundles to GHCR and update bundles.lock' - type: boolean - default: true - -jobs: - pipeline: - permissions: - contents: write - packages: write - pull-requests: write - id-token: write - uses: ./.github/workflows/plan-and-build.yml - with: - force: ${{ inputs.force }} - push: ${{ inputs.push }} - secrets: inherit diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index 9f63050..0000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: nightly -on: - schedule: - - cron: '0 3 * * *' - workflow_dispatch: - -jobs: - pipeline: - permissions: - contents: write - packages: write - pull-requests: write - id-token: write - uses: ./.github/workflows/plan-and-build.yml - with: - force: false - push: true - secrets: inherit - - invariant-check: - needs: pipeline - if: always() - runs-on: ubuntu-24.04 - permissions: - contents: read - packages: read - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Run released-lockfile invariant - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: go test -tags invariants ./test/invariants/... -v diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml deleted file mode 100644 index ccd8b50..0000000 --- a/.github/workflows/on-push.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: on-push -on: - push: - branches: [main] - paths: ['catalog/**', 'builders/**'] - pull_request: - paths: ['catalog/**', 'builders/**'] - -# Cancel older in-progress runs of this workflow on the same ref so a new push -# supersedes the prior pipeline rather than running alongside it. Prevents -# stuck matrix slots from an older run blocking new work. -concurrency: - group: on-push-${{ github.ref }} - cancel-in-progress: true - -jobs: - pipeline: - permissions: - contents: write - packages: write - pull-requests: write - id-token: write - uses: ./.github/workflows/plan-and-build.yml - with: - force: false - push: true - secrets: inherit diff --git a/.github/workflows/plan-and-build.yml b/.github/workflows/plan-and-build.yml deleted file mode 100644 index 4c81c34..0000000 --- a/.github/workflows/plan-and-build.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: plan-and-build -on: - workflow_call: - inputs: - force: - description: 'Force rebuild even if digests match' - type: boolean - default: false - push: - description: 'Push bundles to GHCR and update bundles.lock' - type: boolean - default: true - -permissions: - contents: write - packages: write - pull-requests: write - id-token: write - -jobs: - plan: - uses: ./.github/workflows/plan.yml - with: - force: ${{ inputs.force }} - - build-php: - needs: plan - if: needs.plan.outputs.php_matrix != '{"include":[]}' - strategy: - fail-fast: false - max-parallel: 20 - matrix: ${{ fromJSON(needs.plan.outputs.php_matrix) }} - uses: ./.github/workflows/build-php-core.yml - with: - version: ${{ matrix.version }} - os: ${{ matrix.os }} - arch: ${{ matrix.arch }} - ts: ${{ matrix.ts }} - spec_hash: ${{ matrix.spec_hash }} - push: ${{ inputs.push }} - - build-ext: - needs: [plan, build-php] - if: | - always() && - needs.plan.result == 'success' && - needs.build-php.result != 'failure' && - needs.plan.outputs.ext_matrix != '{"include":[]}' - strategy: - fail-fast: false - max-parallel: 30 - matrix: ${{ fromJSON(needs.plan.outputs.ext_matrix) }} - uses: ./.github/workflows/build-extension.yml - with: - extension: ${{ matrix.extension }} - ext_version: ${{ matrix.ext_version }} - php_abi: ${{ matrix.php_abi }} - os: ${{ matrix.os }} - arch: ${{ matrix.arch }} - spec_hash: ${{ matrix.spec_hash }} - php_core_digest: ${{ matrix.core_digest }} - push: ${{ inputs.push }} - - update-lock: - needs: [plan, build-php, build-ext] - if: | - always() && - inputs.push && - needs.plan.result == 'success' && - needs.build-php.result != 'failure' && - needs.build-ext.result != 'failure' - runs-on: ubuntu-24.04 - permissions: - contents: write - packages: read - steps: - - name: Refuse fork PRs - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository - run: | - echo "::warning::Fork PRs cannot auto-publish bundles. Ask a maintainer to label the PR 'safe-to-build' and re-run after review." - exit 1 - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref || github.ref_name }} - fetch-depth: 0 - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Update and commit lockfile - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - go run ./cmd/lockfile-update \ - -catalog ./catalog \ - -lockfile ./bundles.lock \ - -registry ghcr.io/${{ github.repository_owner }} \ - -commit - - compat-harness: - needs: update-lock - permissions: - contents: read - packages: read - uses: ./.github/workflows/compat-harness.yml - secrets: inherit - - integration-test: - needs: update-lock - permissions: - contents: read - packages: read - uses: ./.github/workflows/integration-test.yml - secrets: inherit diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml deleted file mode 100644 index f625fb1..0000000 --- a/.github/workflows/plan.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: plan -on: - workflow_call: - inputs: - force: - type: boolean - default: false - outputs: - php_matrix: - value: ${{ jobs.plan.outputs.php_matrix }} - ext_matrix: - value: ${{ jobs.plan.outputs.ext_matrix }} - tool_matrix: - value: ${{ jobs.plan.outputs.tool_matrix }} - -jobs: - plan: - runs-on: ubuntu-24.04 - outputs: - php_matrix: ${{ steps.plan.outputs.php_matrix }} - ext_matrix: ${{ steps.plan.outputs.ext_matrix }} - tool_matrix: ${{ steps.plan.outputs.tool_matrix }} - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref || github.ref_name }} - - uses: actions/setup-go@v6 - with: - go-version: '1.26' - - name: Build planner - run: go build -o /tmp/planner ./cmd/planner - - id: plan - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - /tmp/planner \ - --catalog ./catalog \ - --lockfile ./bundles.lock \ - --registry ghcr.io/${{ github.repository_owner }} \ - --output-matrix /tmp/matrix \ - ${{ inputs.force && '--force' || '' }} - echo "php_matrix=$(cat /tmp/matrix/php.json)" >> "$GITHUB_OUTPUT" - echo "ext_matrix=$(cat /tmp/matrix/ext.json)" >> "$GITHUB_OUTPUT" - echo "tool_matrix=$(cat /tmp/matrix/tool.json)" >> "$GITHUB_OUTPUT" diff --git a/CLAUDE.md b/CLAUDE.md index 946e7a3..348bb05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,7 @@ All code changes MUST pass `make check` before committing. This runs: - Use conventional commit messages (feat:, fix:, chore:, docs:, test:, refactor:). - No "Co-Authored-By" or AI attribution in commit messages or PR descriptions. -- Verify `make check` passes before every commit AND before every push. `make check` now includes the docker-backed `local-ci` smoke that exercises the currently-published bundles on both jammy and noble runners (~3-5 min cold); use `make check-fast` during rapid iteration, then `make check` before the push. -- **Exception — builder/catalog bootstrap**: commits that change `builders/linux/*.sh`, `builders/common/*.sh`, `builders/common/*.env`, or `catalog/**` invalidate the published bundles by design. `local-ci` on such a commit exercises the OLD bundles (not what the commit produces) and will fail until the pipeline rebuilds and publishes. In that case: run `make check-fast` before pushing, note the pending rebuild in the commit message, and re-run full `make check` after the pipeline's bot-committed lockfile lands to confirm the fix. +- Verify `make check` passes before every commit AND before every push. For deeper validation that mirrors one cell of CI's `pipeline` matrix (builds php-core + extensions + runs fixtures inside bare-ubuntu docker), run `make ci-cell OS= ARCH= PHP=<8.1-8.4>` — takes ~15–30 min per cell, use when CI fails and you need to reproduce it locally per the "CI failures" rule below. ## CI failures: reproduce locally first @@ -64,7 +63,7 @@ Why: CI cycle times are 15–60 minutes; local iterations are seconds to minutes - Target overall test coverage of 80% or higher per package. Do not let coverage regress. - Go tests: `go test -race ./...` - Smoke tests: `test/smoke/run.sh` for bundle verification. -- Integration tests: `.github/workflows/integration-test.yml` (CI only). +- CI pipeline cell (build + fixture): `make ci-cell OS= ARCH= PHP=<8.1-8.4>`. - Local bundle builds: `make bundle-php` and `make bundle-ext` (requires Docker). ## Release Engineering diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a4087c..403e25e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ make check # verify everything works 2. Make changes 3. Run `make check` — must pass before committing 4. Commit with conventional messages (`feat:`, `fix:`, `chore:`, etc.) -5. Open a PR — CI runs `ci-lint.yml` automatically +5. Open a PR — CI runs `ci.yml` automatically (lint + unit tests + 16-cell pipeline) ## CI writes to your PR branch @@ -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 and are reaped by `gc-bundles.yml` on a quarterly schedule. +- **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. See `docs/superpowers/specs/2026-04-20-bundle-schema-and-rollout-design.md` for the full rollout design. diff --git a/Makefile b/Makefile index c96fb37..4cebab5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: check check-fast fmt fmt-check vet lint tidy tidy-check test test-node build clean \ +.PHONY: check fmt fmt-check vet lint tidy tidy-check test test-node build clean \ build-linux-amd64 build-linux-arm64 bundle-php bundle-ext gc-bundles-dry-run \ - local-ci ci-cell ci + ci-cell ci # Path to the native phpup binary used by bundle-php / bundle-ext. Overridable # so CI / power users can point at a pre-built binary. @@ -19,30 +19,13 @@ $(PHPUP_BIN): cmd/phpup/bundles.lock @mkdir -p $(dir $(PHPUP_BIN)) go build -o $(PHPUP_BIN) ./cmd/phpup -# Full pre-push check: static analysis + tests + builds + a docker smoke that -# exercises the published bundles on both jammy and noble runners. Takes ~5 -# minutes when the bundle caches are cold. Use check-fast for rapid iteration -# during development; use check before every push. -check: fmt-check vet lint tidy-check test test-node build local-ci +# Pre-push check: static analysis + tests + builds. Run before every commit +# and push. For a full CI cell smoke (build php-core + exts + run fixtures +# inside bare-ubuntu docker) use `make ci-cell OS=… ARCH=… PHP=…`, which +# reproduces one cell of the `ci.yml::pipeline` matrix locally. +check: fmt-check vet lint tidy-check test test-node build @rm -f cmd/phpup/bundles.lock -# Fast check: skip the docker-based local-ci. Keep PR-authors productive during -# rapid iteration; real gate is the full `check` target before push. -check-fast: fmt-check vet lint tidy-check test test-node build - @rm -f cmd/phpup/bundles.lock - -# Docker-based smoke that mirrors the compat-harness: pulls published bundles, -# composes them into a mock /opt/buildrush tree, and loads PHP + a multi-ext -# fixture in both ubuntu:22.04 and ubuntu:24.04 containers. Catches cross-OS -# runtime-dep / rpath regressions locally in ~3-5 min instead of a 30-min CI -# cycle. Skipped gracefully if docker is unavailable. -local-ci: - @if ! command -v docker >/dev/null 2>&1; then \ - echo "local-ci: docker not found, skipping (fix: install Docker to run this check)"; \ - exit 0; \ - fi - @./test/smoke/local-ci.sh - # Format all code fmt: gofmt -s -w . @@ -134,11 +117,11 @@ bundle-ext: $(PHPUP_BIN) --registry $(or $(REGISTRY),oci-layout:./out/oci-layout) \ --repo . -# Run one compat-harness cell via `phpup test`: loads the published (or local -# OCI-layout) bundles for the requested OS/ARCH/PHP inside docker and exercises -# every matching row in test/compat/fixtures.yaml. Caller supplies OS, ARCH, -# PHP; REGISTRY defaults to the local OCI layout used by bundle-php/bundle-ext. -# Exercised end-to-end via `make ci` and from test/smoke/local-ci.sh. +# Run one `ci.yml::pipeline` cell via `phpup test`: loads the published (or +# local OCI-layout) bundles for the requested OS/ARCH/PHP inside docker and +# exercises every matching row in test/compat/fixtures.yaml. Caller supplies +# OS, ARCH, PHP; REGISTRY defaults to the local OCI layout used by +# bundle-php/bundle-ext. Exercised end-to-end via `make ci`. # # 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 diff --git a/README.md b/README.md index 52fb08c..53d5348 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,16 @@ Details, deliberate deviations, and deferred behavioral quirks are catalogued in ## How we verify v2 compatibility -Every pull request and push to `main` triggers the -[`compat-harness` workflow](.github/workflows/compat-harness.yml), which runs -a fixture matrix through both `buildrush/setup-php` and -`shivammathur/setup-php@v2` (pinned by SHA in -[`docs/compat-matrix.md`](docs/compat-matrix.md)) and diffs the resulting PHP -environments. Any deviation that is not listed in the allowlist block of -`docs/compat-matrix.md` fails the check. See the spec -[`docs/superpowers/specs/2026-04-20-compat-harness-design.md`](docs/superpowers/specs/2026-04-20-compat-harness-design.md) -for the full design. +Every pull request and push to `main` runs the +[`ci.yml`](.github/workflows/ci.yml) pipeline, which builds each +OS × ARCH × PHP cell and executes the fixture matrix in +[`test/compat/fixtures.yaml`](test/compat/fixtures.yaml) via `phpup test`. +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 +v2 SHA and the accepted-deviation allowlist live in +[`docs/compat-matrix.md`](docs/compat-matrix.md). ## Contributing diff --git a/cmd/gc-bundles/main.go b/cmd/gc-bundles/main.go index 038902d..e9bbe93 100644 --- a/cmd/gc-bundles/main.go +++ b/cmd/gc-bundles/main.go @@ -38,8 +38,9 @@ func main() { } } -// run is the testable core. It writes a human-readable report to w; the -// dry-run report doubles as the artifact uploaded by gc-bundles.yml. +// run is the testable core. It writes a human-readable report to w — +// invoke on-demand (e.g. `go run ./cmd/gc-bundles --org buildrush +// --min-age-days 30`) when GHCR quota pressure warrants a sweep. func run(org string, minAgeDays int, confirm bool, w *os.File) error { runner := NewGHRunner() diff --git a/cmd/phpup/main.go b/cmd/phpup/main.go index 49fa193..7aa7ce9 100644 --- a/cmd/phpup/main.go +++ b/cmd/phpup/main.go @@ -66,9 +66,10 @@ func main() { } // `phpup test …` is dispatched the same way as `phpup build …`: its - // own FlagSet, its own argv universe. Used by maintainers to run the - // compat-harness fixtures locally against a registry (oci-layout or - // remote). See internal/testsuite.Main for the flag surface. + // own FlagSet, its own argv universe. Used by ci.yml::pipeline (via + // `make ci-cell`) to exercise the fixture matrix against a registry + // (oci-layout or remote). See internal/testsuite.Main for the flag + // surface. if len(os.Args) > 1 && os.Args[1] == "test" { if err := testsuite.Main(os.Args[2:]); err != nil { log.Fatalf("%v", err) diff --git a/cmd/planner/main.go b/cmd/planner/main.go index 6c1d631..87f73fa 100644 --- a/cmd/planner/main.go +++ b/cmd/planner/main.go @@ -93,12 +93,11 @@ func main() { // 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. + // internally). Used to populate ext cells' CoreDigest field so the ext + // builder can pin the core by digest. Cells whose core is being rebuilt + // in the same run get CoreDigest plumbed from the within-run + // build-php → build-ext ordering (see ci.yml::pipeline); legacy + // planner-driven workflows kept this as a lockfile lookup only. coreDigestByKey := make(map[string]string, len(lf.Bundles)) for key, entry := range lf.Bundles { if strings.HasPrefix(key, "php:") { diff --git a/internal/build/build.go b/internal/build/build.go index de85ef5..2652cc4 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -34,7 +34,7 @@ const orasVersion = "1.3.1" const LinuxAptPreamble = "apt-get update && " + "apt-get install -y --no-install-recommends curl xz-utils ca-certificates file jq zstd && " + // fetch-core.sh (called by build-ext.sh) shells out to `oras pull`. - // Install the same oras version the existing build-extension.yml uses. + // Pin oras to the version fetch-core.sh expects on PATH (see orasVersion). // Arch detection: uname -m on linux returns x86_64 or aarch64; oras // releases use amd64/arm64. One shell line keeps the preamble terse. "ARCH_ORAS=$(uname -m); case \"$ARCH_ORAS\" in x86_64) ORAS_ARCH=amd64 ;; aarch64) ORAS_ARCH=arm64 ;; esac; " + @@ -680,8 +680,7 @@ func tsFromPHPABI(phpABI string) string { // loadExtBuildDeps reads catalog/extensions/.yaml and returns the // .build_deps.linux list joined by single spaces (the BUILD_DEPS env -// shape build-ext.sh expects). Mirrors the yq invocation in -// build-extension.yml: +// shape build-ext.sh expects). Equivalent to: // // yq eval '.build_deps.linux // [] | join(" ")' catalog/extensions/.yaml // @@ -822,11 +821,10 @@ func UbuntuImage(osFlag string) (string, error) { case "noble", "ubuntu-24.04": return "ubuntu:24.04", nil case "linux": - // Legacy catalog abi_matrix uses "os: linux" as the family axis - // (POSIX family; actual runner flavor comes separately). The old - // build-php-core.yml / build-extension.yml workflows pass this - // value straight through to phpup build. Default to jammy for - // back-compat; callers that need noble pass "noble" explicitly. + // The catalog abi_matrix uses "os: linux" as the family axis + // (POSIX family; actual runner flavor comes separately), and some + // upstream callers still pass that family label straight through. + // Default to jammy; callers that need noble pass "noble" explicitly. return "ubuntu:22.04", nil default: return "", fmt.Errorf("unknown os %q (want jammy|noble|linux)", osFlag) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index f8d5a28..96f64d7 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -43,8 +43,8 @@ func writeRepoFixture(t *testing.T, dir string) { mustWrite("catalog/php.yaml", "versions:\n \"8.4\":\n sources:\n url: https://example.com/php-8.4.0.tar.xz\n") // Extension catalog with a real build_deps.linux list so // loadExtBuildDeps has something to parse. The shape matches - // catalog/extensions/amqp.yaml's production form (see - // .github/workflows/build-extension.yml's yq invocation). + // catalog/extensions/amqp.yaml's production form — same yq-compatible + // keys the pre-unification workflows parsed. mustWrite("catalog/extensions/redis.yaml", "name: redis\nversions:\n - \"6.2.0\"\nbuild_deps:\n linux:\n - libssl-dev\n") } @@ -704,8 +704,7 @@ func TestCoreTagForFetch_MatchesFetchCoreShellLogic(t *testing.T) { } // TestLoadExtBuildDeps_JoinsLinuxList verifies loadExtBuildDeps returns -// the space-joined list that build-ext.sh's BUILD_DEPS env expects — -// matching the yq invocation in build-extension.yml. +// the space-joined list that build-ext.sh's BUILD_DEPS env expects. func TestLoadExtBuildDeps_JoinsLinuxList(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "amqp.yaml") diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 29938ef..9790f9d 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -28,7 +28,7 @@ type MatrixCell struct { // 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 + // JSON as `core_digest` so downstream callers 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"` diff --git a/internal/registry/media_types.go b/internal/registry/media_types.go index 794d7bd..d5b0aa2 100644 --- a/internal/registry/media_types.go +++ b/internal/registry/media_types.go @@ -4,38 +4,34 @@ import "strings" // Media types and artifact types for bundles published to a remote registry. // -// Keeping these byte-identical to what the existing `oras push` commands in -// .github/workflows/build-php-core.yml and .github/workflows/build-extension.yml -// emit is a hard requirement: a remoteStore-pushed artifact must be -// indistinguishable from an oras-pushed one, because downstream tooling -// (cosign signing, OCI clients, human operators running `oras discover`) -// keys off these strings. +// Keeping these byte-identical to the historical `oras push` output is a +// hard requirement: a remoteStore-pushed artifact must be indistinguishable +// from an oras-pushed one, because downstream tooling (cosign signing, OCI +// clients, human operators running `oras discover`) keys off these strings. const ( // mediaTypePhpCoreArtifact is the oras --artifact-type for php-core - // bundles. Matches build-php-core.yml. + // bundles. mediaTypePhpCoreArtifact = "application/vnd.buildrush.php-core.v1" // mediaTypePhpExtArtifact is the oras --artifact-type for php-ext-* - // bundles. Matches build-extension.yml. + // bundles. mediaTypePhpExtArtifact = "application/vnd.buildrush.php-ext.v1" // mediaTypeBundleLayer is the OCI media type for the bundle tar.zst - // blob. Matches both build-*.yml files. + // blob. mediaTypeBundleLayer = "application/vnd.oci.image.layer.v1.tar+zstd" // mediaTypeMetaSidecar is the media type for the meta.json sidecar. - // Matches both build-*.yml files. mediaTypeMetaSidecar = "application/vnd.buildrush.meta.v1+json" // annotationArtifactType is the OCI manifest annotation key `oras push` // writes when invoked with --artifact-type. Replayed here so remote - // pushes carry the same annotation shape as the CI path. + // pushes carry the same annotation shape. annotationArtifactType = "org.opencontainers.artifact.type" ) // artifactTypeForBundle maps a bundle Name to the artifact-type annotation // value that `oras push --artifact-type ` would set. // -// The split mirrors the workflow files: php-ext- → phpExt, every -// other name → phpCore. The default keeps php-core + any future php-tool-* -// bundles on the phpCore artifact-type, matching pre-merge behavior in the -// existing CI path. +// The split is: php-ext- → phpExt, every other name → phpCore. +// The default keeps php-core + any future php-tool-* bundles on the phpCore +// artifact-type. func artifactTypeForBundle(name string) string { if strings.HasPrefix(name, "php-ext-") { return mediaTypePhpExtArtifact diff --git a/internal/registry/remote.go b/internal/registry/remote.go index f6db3f0..4216179 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -146,14 +146,14 @@ func (s *remoteStore) Fetch(ctx context.Context, ref Ref) (io.ReadCloser, *Meta, // Digest-only Refs are a Fetch/Has concept; for Push the caller must provide // a Tag. // -// Media-type choices mirror the existing `oras push` command in -// .github/workflows/build-php-core.yml + build-extension.yml: layer 0 is the -// bundle at application/vnd.oci.image.layer.v1.tar+zstd, layer 1 (when meta -// is non-nil) is the meta sidecar at application/vnd.buildrush.meta.v1+json, +// Media-type choices match the historical `oras push` layout the pre- +// unification workflows emitted: layer 0 is the bundle at +// application/vnd.oci.image.layer.v1.tar+zstd, layer 1 (when meta is +// non-nil) is the meta sidecar at application/vnd.buildrush.meta.v1+json, // and the manifest carries the OCI artifact-type annotation // (org.opencontainers.artifact.type) that matches --artifact-type. Keeping -// this byte-identical with the CI path lets cosign + downstream OCI tooling -// treat remoteStore-pushed bundles exactly like oras-pushed ones. +// this byte-identical with the old path lets cosign + downstream OCI +// tooling treat remoteStore-pushed bundles exactly like oras-pushed ones. func (s *remoteStore) Push(ctx context.Context, ref Ref, body io.Reader, meta *Meta, ann Annotations) error { if ref.Name == "" { return errors.New("remote.Push: ref.Name required") diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 73ac062..a25f208 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -206,7 +206,7 @@ func TestRemoteStore_Push_RequiresTag(t *testing.T) { // TestRemoteStore_Push_ArtifactTypeAnnotation_PhpExt verifies the manifest // annotation matches what `oras push --artifact-type application/vnd.buildrush.php-ext.v1` -// emits for php-ext-* bundles (see .github/workflows/build-extension.yml). +// would emit for php-ext-* bundles. func TestRemoteStore_Push_ArtifactTypeAnnotation_PhpExt(t *testing.T) { ctx := context.Background() host := startTestRegistry(t) @@ -241,7 +241,7 @@ func TestRemoteStore_Push_ArtifactTypeAnnotation_PhpExt(t *testing.T) { } // TestRemoteStore_Push_ArtifactTypeAnnotation_PhpCore mirrors the php-ext -// assertion for the php-core artifact type (build-php-core.yml). +// assertion for the php-core artifact type. func TestRemoteStore_Push_ArtifactTypeAnnotation_PhpCore(t *testing.T) { ctx := context.Background() host := startTestRegistry(t) diff --git a/test/compat/fixtures.yaml b/test/compat/fixtures.yaml index 20dd8b5..1300c55 100644 --- a/test/compat/fixtures.yaml +++ b/test/compat/fixtures.yaml @@ -1,6 +1,7 @@ # test/compat/fixtures.yaml -# Each fixture feeds a matrix cell in compat-harness.yml. Field names match -# shivammathur/setup-php@v2 inputs (php-version, extensions, ini-values, coverage). +# Each fixture is exercised by `phpup test` via ci.yml::pipeline (one cell +# per OS × ARCH × PHP). Field names match shivammathur/setup-php@v2 inputs +# (php-version, extensions, ini-values, coverage). # Keep this sorted by 'name' so matrix iteration order is deterministic. fixtures: diff --git a/test/smoke/local-ci.sh b/test/smoke/local-ci.sh deleted file mode 100755 index b0d2bb5..0000000 --- a/test/smoke/local-ci.sh +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env bash -# local-ci.sh — reproduce the compat-harness's bundle-loading tests on the -# host machine via Docker, without waiting 30+ minutes for CI. -# -# What it does: -# 1. Pulls the currently-published php-core + requested extension bundles -# from GHCR (anonymous read OK for public packages). -# 2. Composes a mock /opt/buildrush tree locally. -# 3. Runs the tree inside both ubuntu:22.04 (jammy) and ubuntu:24.04 (noble) -# containers to exercise the cross-OS invariant. -# 4. For each (runner_os, fixture), installs the core runtime deps + the -# fixture's extension runtime_deps (read from catalog via yq), then -# loads php with the extension set and asserts every requested extension -# is reported as loaded. -# -# Usage: -# test/smoke/local-ci.sh # default: 8.4 + bare + hard4 fixtures -# test/smoke/local-ci.sh --php 8.3 # override PHP version -# test/smoke/local-ci.sh --arch x86_64 # override arch (requires --platform emulation on ARM hosts) -# -# Requires: docker, jq, oras, yq on the host. zstd + tar used inside containers. - -set -euo pipefail -# test/smoke/local-ci.sh — soon-to-be-deprecated. -# -# PR 3 of the local+CI unification rollout introduced `phpup test` + -# `make ci-cell` / `make ci` as the future local-smoke path. This script -# still runs the OLD shell-orchestrated path (GHCR pull via oras + bash -# loops) because the new path needs pre-populated bundles that PR 4 will -# wire via the unified ci.yml. Once that lands, this script becomes a -# thin wrapper — tracked for deletion. -# -# Prefer `make ci-cell OS=... ARCH=... PHP=...` for new work. -echo "NOTE: test/smoke/local-ci.sh will be superseded by 'make ci-cell' / 'make ci' once PR 4 lands. See CHANGELOG or docs/superpowers/specs/2026-04-23-local-ci-unification-design.md." >&2 - -PHP_VERSION="8.4" -# Default: exercise BOTH arches. x86_64 runs under docker's QEMU on ARM hosts -# (slower but catches x86-only issues like pgdg picking on GH's x86 runners). -# Override with --arch to scope to one. -ARCHES="aarch64 x86_64" -REGISTRY="ghcr.io/buildrush" -WORKSPACE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -WORK="${TMPDIR:-/tmp}/local-ci-$$" -KEEP_WORK=false - -# Fixtures to exercise. Each entry is "|". An empty extensions -# value means "bare" (core only). -FIXTURES=( - "bare|" - "hard4|imagick,mongodb,swoole,grpc" -) - -RUNNERS=("ubuntu-22.04" "ubuntu-24.04") - -# Core system-level runtime deps that GH Actions runners ship preinstalled but -# minimal Ubuntu base images do not. Scraped from the NEEDED list of a core -# bundle's php binary on 2026-04-22; keep in sync if core configure flags change. -CORE_RUNTIME_DEPS=( - libreadline8 libssl3 libcurl4 libxml2 libonig5 libpq5 libsqlite3-0 - libsodium23 libzip4 libffi8 libpng16-16 libwebp7 libjpeg8 libfreetype6 - libgomp1 libxext6 -) - -usage() { - sed -n '2,/^$/p' "$0" | sed 's/^# \?//' - exit 1 -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --php) PHP_VERSION="$2"; shift 2 ;; - --arch) ARCHES="$2"; shift 2 ;; - --keep) KEEP_WORK=true; shift ;; - -h|--help) usage ;; - *) echo "unknown arg: $1" >&2; usage ;; - esac -done - -for cmd in docker jq oras yq; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "::error::missing required command: $cmd" >&2 - exit 2 - fi -done - -mkdir -p "$WORK" -cleanup() { - if ! $KEEP_WORK; then rm -rf "$WORK"; fi -} -trap cleanup EXIT - -echo "=== local-ci: PHP $PHP_VERSION / arches=${ARCHES} ===" -echo "workspace: $WORKSPACE" -echo "scratch: $WORK (KEEP=$KEEP_WORK)" - -# Pull + extract one bundle into $WORK/bundles///. Pulls by digest -# from bundles.lock (what phpup embeds and uses at runtime), NOT by floating -# tag. This is what makes local-ci exercise the same bundle bits CI does — -# floating tags race ahead of lockfile updates on every PR push. -# Kinds: "core" (whole php-core), "ext" (extension named $2). -pull_bundle() { - local kind="$1" name="${2:-}" - local key ref dest - case "$kind" in - core) - key="php:${PHP_VERSION}:linux:${ARCH}:nts" - dest="$WORK/bundles/core" - ;; - ext) - local ver - ver=$(yq -r ".versions[0]" "${WORKSPACE}/catalog/extensions/${name}.yaml") - # bundles.lock stores ext keys with PHP minor only (e.g. "8.4"); phpup - # reconstructs the 8.4-nts PHP_ABI on the consumer side. Match the - # storage format here. - key="ext:${name}:${ver}:${PHP_VERSION}:linux:${ARCH}:nts" - dest="$WORK/bundles/ext/${name}" - ;; - *) echo "pull_bundle: unknown kind $kind" >&2; return 1 ;; - esac - - local digest - digest=$(jq -r ".bundles[\"${key}\"].digest // empty" "${WORKSPACE}/bundles.lock") - [ -n "$digest" ] || { echo "::error::lockfile has no entry for ${key}" >&2; return 1; } - - case "$kind" in - core) ref="${REGISTRY}/php-core@${digest}" ;; - ext) ref="${REGISTRY}/php-ext-${name}@${digest}" ;; - esac - - mkdir -p "$dest" - local scratch="$WORK/.pull-${kind}-${name:-core}" - rm -rf "$scratch" - mkdir -p "$scratch" - ( cd "$scratch" && oras pull --allow-path-traversal "$ref" >/dev/null ) - # oras writes /tmp/bundle.tar.zst + /tmp/meta.json because the bundle's OCI - # layer paths reference those. Grab them before the next pull overwrites. - [ -f /tmp/bundle.tar.zst ] || { echo "::error::oras produced no bundle for $ref" >&2; return 1; } - tar --zstd -xf /tmp/bundle.tar.zst -C "$dest" - cp /tmp/meta.json "$dest/meta.json" 2>/dev/null || true -} - -# Build a mock /opt/buildrush layout from the bundles pulled above, mirroring -# what `phpup` produces at runtime. -compose_mock() { - local fixture_exts="$1" mock="$WORK/mock-buildrush" - rm -rf "$mock" - mkdir -p "$mock/core" - cp -R "$WORK/bundles/core/." "$mock/core/" - - if [ -z "$fixture_exts" ]; then return 0; fi - IFS=',' read -r -a exts <<< "$fixture_exts" - for ext in "${exts[@]}"; do - ext="$(echo "$ext" | xargs)" # trim whitespace - [ -z "$ext" ] && continue - mkdir -p "$mock/bundles/${ext}" - cp -R "$WORK/bundles/ext/${ext}/." "$mock/bundles/${ext}/" - done -} - -# Assemble the apt-install line for a fixture: core deps + per-extension -# runtime_deps from the catalog. -apt_deps_for() { - local fixture_exts="$1" - local deps=("${CORE_RUNTIME_DEPS[@]}") - if [ -n "$fixture_exts" ]; then - IFS=',' read -r -a exts <<< "$fixture_exts" - for ext in "${exts[@]}"; do - ext="$(echo "$ext" | xargs)" - [ -z "$ext" ] && continue - # Extract runtime_deps.linux as one-per-line, skip empty list. - while IFS= read -r dep; do - [ -z "$dep" ] && continue - deps+=("$dep") - done < <(yq -r '.runtime_deps.linux // [] | .[]' "${WORKSPACE}/catalog/extensions/${ext}.yaml" 2>/dev/null || true) - done - fi - # De-duplicate while preserving order. - printf '%s\n' "${deps[@]}" | awk '!seen[$0]++' -} - -# Build the docker image for a runner OS: base + apt deps layered in. -run_in_container() { - local runner_os="$1" fixture_name="$2" fixture_exts="$3" mock="$WORK/mock-buildrush" - - local image - case "$runner_os" in - ubuntu-22.04) image="ubuntu:22.04" ;; - ubuntu-24.04) image="ubuntu:24.04" ;; - *) echo "::error::unknown runner_os: $runner_os" >&2; return 1 ;; - esac - - local deps_line - deps_line="$(apt_deps_for "$fixture_exts" | tr '\n' ' ')" - - # Always pin platform so docker doesn't pick a cached non-matching image. - local docker_arch - case "$ARCH" in - x86_64) docker_arch="linux/amd64" ;; - aarch64) docker_arch="linux/arm64" ;; - *) echo "::error::unsupported ARCH: $ARCH" >&2; return 1 ;; - esac - local platform="--platform=$docker_arch" - - # Build ext-loading fragment. Empty fixture → just `php -v`. The ext path - # contains a glob (no-debug-non-zts-) that must be expanded inside the - # container, not on the host, so embed it as a subshell that resolves once. - local php_check - if [ -z "$fixture_exts" ]; then - php_check='php -v | head -1' - else - # Resolve each ext's .so path inside the container then run a single - # php invocation with all extension= flags. - php_check=$(cat <