From ef71c96b391c34001e90513cf1bfdcb33db5d5d7 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Wed, 28 Oct 2026 15:36:59 -0500 Subject: [PATCH] ci(testing): build the minimal image natively per architecture The minimal job built 3 PHP variants x {amd64, arm64} sequentially in one job with arm64 emulated by QEMU: about 27 min per variant, 88 min total. Per-step timings from the 10-27 run show why: the PHP extension compile (docker-php-ext-configure/install) took 24.3 min on emulated arm64 and 1.6 min on native amd64; the whole variant summed to 27.5 min emulated vs 2.4 min native. build-minimal is now a matrix of {php83, php84, php85} x {amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm}; each leg builds only its own architecture and pushes by digest (no tag). merge-minimal turns each variant's two digests into the published tags with 'docker buildx imagetools create' and fails unless every tag carries linux/amd64 and linux/arm64. scan-minimal (one leg per variant) keeps the Grype gate and report labels; promote needs all three scans and does the same single SSM write as before. Unchanged: triggers, concurrency, the tag set (-php83/-php84/-php85, bare and :testing on php85), the full-stack job, and the shape of the published image (amd64 + arm64 + attestations). New: a workflow_dispatch dry_run input that publishes only -nativetest-php8x tags, never the bare tag or :testing, and skips the promote. It must be dispatched from testing (the testing environment admits only that branch). Verified: actionlint clean; the merge-and-verify commands run against a local registry produce one index digest for all tags, both platforms, and 2 attestation manifests, matching the shape of 20261020-001-php85. Not run here: a real build on ubuntu-24.04-arm. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/publish-testing.yml | 246 ++++++++++++++++++-------- 1 file changed, 176 insertions(+), 70 deletions(-) diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index 516e94d..38093a5 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -14,6 +14,22 @@ name: Publish testing images + promote # same recipe the code-development `test_docker_deploy` smoke uses, but pushed # to Docker Hub. # +# The minimal image is built NATIVELY per architecture: a matrix of +# {php83, php84, php85} x {amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm} +# pushes each by digest, then merge-minimal stitches the two digests of each PHP +# variant into one multi-arch tag with `docker buildx imagetools create`. The +# arm64 half used to be QEMU-emulated in one job (about 27 min per variant, 88 min +# total, 24 of every 27 minutes being the PHP extension compile); natively it is +# about the amd64 time (about 2.5 min of build work per variant). The published +# tags, their multi-arch shape (amd64 + arm64 + attestations), the Grype gate and +# the single SSM promote are unchanged. +# +# Dry run: dispatch this workflow from `testing` with dry_run=true to build, +# merge and scan under -nativetest-php8x tags. It moves no real tag (no +# bare , no :testing) and does not promote. The `testing` environment +# only admits the `testing` branch, so a dry run cannot be dispatched from a +# feature branch. +# # Tags (see design code-development docs/superpowers/specs/2026-07-10-release-image- # promotion-design): every release image is built ONCE here from the testing # bundle and later PROMOTED (not rebuilt) to prod by promote-latest.yml on @@ -36,6 +52,13 @@ on: push: branches: [testing] workflow_dispatch: + inputs: + dry_run: + description: > + Build, merge and scan under -nativetest-php8x tags only: no bare + , no :testing, no SSM promote. + type: boolean + default: false permissions: contents: read @@ -60,8 +83,14 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.ver.outputs.version }} + # "" for a real publish, "-nativetest" for a dry run: tags are + # -php8x, so a dry run can never collide with a real tag. + infix: ${{ steps.ver.outputs.infix }} + dry_run: ${{ steps.ver.outputs.dry_run }} steps: - name: Derive VERSION from the testing channel + env: + DRY_RUN: ${{ inputs.dry_run }} id: ver run: | set -euo pipefail @@ -80,14 +109,35 @@ jobs: fi echo "testing bundle: $BUNDLE (version $VERSION)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "${DRY_RUN:-}" = "true" ]; then + echo "infix=-nativetest" >> "$GITHUB_OUTPUT" + echo "dry_run=true" >> "$GITHUB_OUTPUT" + echo "::notice::DRY RUN: tags will be ${VERSION}-nativetest-php8x; no real tag moves and nothing is promoted" + else + echo "infix=" >> "$GITHUB_OUTPUT" + echo "dry_run=false" >> "$GITHUB_OUTPUT" + fi - publish: - name: Publish simplerisk-minimal RC images + # One leg per {PHP variant} x {architecture}, each on a runner of that architecture, so + # nothing is emulated. Each leg pushes its image BY DIGEST (no tag) and uploads the digest; + # merge-minimal below turns the two digests of a variant into the multi-arch tag. + build-minimal: + name: Build minimal php${{ matrix.php }} ${{ matrix.arch }} needs: resolve environment: testing - runs-on: ubuntu-latest - # 3 sequential multi-arch (arm64 = QEMU-emulated, slow) variant builds run here. - timeout-minutes: 120 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + # One failed leg means there is no complete release; do not spend the rest. + fail-fast: true + matrix: + php: ['83', '84', '85'] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm steps: - name: Checkout (docker@testing) uses: actions/checkout@v6 @@ -131,9 +181,7 @@ jobs: cp /tmp/testing.sql common/simplerisk.sql test -d simplerisk || { echo "::error::bundle did not extract a simplerisk/ dir"; exit 1; } - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - + # No QEMU: this leg builds only its own architecture, on a runner of that architecture. - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -143,76 +191,134 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Build and push php83 — -php83 - uses: docker/build-push-action@v7 - with: - context: simplerisk-minimal - file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.3 - tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83 - cache-from: type=gha,scope=minimal-testing-php83 - cache-to: type=gha,mode=max,scope=minimal-testing-php83 + - name: Derive the PHP version (8.3 from 83) + id: php + env: + PHP: ${{ matrix.php }} + run: echo "version=${PHP:0:1}.${PHP:1}" >> "$GITHUB_OUTPUT" - - name: Build and push php84 — -php84 + - name: Build and push by digest — php${{ matrix.php }} ${{ matrix.arch }} + id: build uses: docker/build-push-action@v7 with: context: simplerisk-minimal file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.4 - tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84 - cache-from: type=gha,scope=minimal-testing-php84 - cache-to: type=gha,mode=max,scope=minimal-testing-php84 + platforms: linux/${{ matrix.arch }} + build-args: php_version=${{ steps.php.outputs.version }} + # No tag: the digest is merged into the tags by merge-minimal. + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=minimal-testing-php${{ matrix.php }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=minimal-testing-php${{ matrix.php }}-${{ matrix.arch }} - - name: Build and push php85 (default) — -php85 + + :testing - uses: docker/build-push-action@v7 + - name: Record the digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + printf '%s' "$DIGEST" | grep -qE '^sha256:[a-f0-9]{64}$' \ + || { echo "::error::build produced no image digest (got '$DIGEST')"; exit 1; } + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload the digest + uses: actions/upload-artifact@v4 with: - context: simplerisk-minimal - file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.5 - tags: | - ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85 - ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }} - ${{ env.IMAGE_NAME }}:testing - cache-from: type=gha,scope=minimal-testing-php85 - cache-to: type=gha,mode=max,scope=minimal-testing-php85 - - # Scan the images exactly as published, BEFORE the SSM promote below rolls them - # out to tier=testing customers. Each scan is continue-on-error so all three - # reports are produced and uploaded; the gate step then fails the job (and so - # skips the promote) if any of them hit a critical finding. - - name: Scan php83 (published) - id: scan83 - continue-on-error: true - uses: ./.github/actions/grype-scan + name: digests-php${{ matrix.php }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Stitch the amd64 and arm64 digests of each PHP variant into the published tags. + merge-minimal: + name: Merge minimal php${{ matrix.php }} (amd64 + arm64) + needs: [resolve, build-minimal] + environment: testing + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: true + matrix: + php: ['83', '84', '85'] + steps: + - name: Download the digests + uses: actions/download-artifact@v4 with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83 - label: published-minimal-php83 - - name: Scan php84 (published) - id: scan84 - continue-on-error: true - uses: ./.github/actions/grype-scan + path: /tmp/digests + pattern: digests-php${{ matrix.php }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84 - label: published-minimal-php84 - - name: Scan php85 (published) - id: scan85 - continue-on-error: true + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Create the multi-arch tags + env: + VERSION: ${{ needs.resolve.outputs.version }} + INFIX: ${{ needs.resolve.outputs.infix }} + PHP: ${{ matrix.php }} + run: | + set -euo pipefail + cd /tmp/digests + mapfile -t digests < <(ls) + if [ "${#digests[@]}" -ne 2 ]; then + echo "::error::php${PHP}: expected 2 digests (amd64, arm64), found ${#digests[@]}: ${digests[*]:-none}"; exit 1 + fi + refs=() + for d in "${digests[@]}"; do refs+=("${IMAGE_NAME}@sha256:${d}"); done + + # -php8x always; php85 (the default) also carries the bare and + # :testing. A dry run (INFIX=-nativetest) never touches the bare tag or :testing. + tags=("${IMAGE_NAME}:${VERSION}${INFIX}-php${PHP}") + if [ "$PHP" = "85" ] && [ -z "$INFIX" ]; then + tags+=("${IMAGE_NAME}:${VERSION}" "${IMAGE_NAME}:testing") + fi + args=(); for t in "${tags[@]}"; do args+=(-t "$t"); done + docker buildx imagetools create "${args[@]}" "${refs[@]}" + + # Do not publish a half-built release: every tag must carry BOTH architectures. + for t in "${tags[@]}"; do + plats=$(docker buildx imagetools inspect "$t" --format '{{json .Manifest}}' \ + | jq -r '[.manifests[].platform | select(.os == "linux") | "\(.os)/\(.architecture)"] | unique | join(",")') + echo "$t -> $plats" + [ "$plats" = "linux/amd64,linux/arm64" ] \ + || { echo "::error::$t is missing an architecture (got '$plats')"; exit 1; } + done + + # Scan the images exactly as published, BEFORE the SSM promote rolls them out to + # tier=testing customers. Every leg uploads its report; any critical finding fails the + # leg, and promote needs all three legs, so a critical finding blocks the promote. + scan-minimal: + name: Scan minimal php${{ matrix.php }} (published) + needs: [resolve, merge-minimal] + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + php: ['83', '84', '85'] + steps: + - name: Checkout (docker@testing) + uses: actions/checkout@v6 + - name: Scan php${{ matrix.php }} (published) uses: ./.github/actions/grype-scan with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85 - label: published-minimal-php85 - - name: Block the promote on critical findings - if: ${{ steps.scan83.outcome == 'failure' || steps.scan84.outcome == 'failure' || steps.scan85.outcome == 'failure' }} - run: | - echo "::error::Grype found critical vulnerabilities in the published images; not promoting to the testing tier. See the scan steps above." - exit 1 + image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}${{ needs.resolve.outputs.infix }}-php${{ matrix.php }} + label: published-minimal-php${{ matrix.php }} + promote: + name: Promote — SSM /image-tag/testing + needs: [resolve, scan-minimal] + # A dry run publishes under -nativetest tags only; nothing may roll out to tier=testing. + if: ${{ needs.resolve.outputs.dry_run != 'true' }} + environment: testing + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: - name: Configure AWS credentials (OIDC → customers account) uses: aws-actions/configure-aws-credentials@v4 with: @@ -340,7 +446,7 @@ jobs: # always(): a critical finding fails a publish job (and blocks the promote), and # that is exactly when the report matters. if: ${{ always() }} - needs: [publish, publish-full] + needs: [scan-minimal, publish-full] runs-on: ubuntu-latest permissions: contents: read @@ -353,8 +459,8 @@ jobs: slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} context: ${{ github.workflow }} · ${{ github.ref_name }} results: | - published-minimal-php83=${{ needs.publish.result }} - published-minimal-php84=${{ needs.publish.result }} - published-minimal-php85=${{ needs.publish.result }} + published-minimal-php83=${{ needs.scan-minimal.result }} + published-minimal-php84=${{ needs.scan-minimal.result }} + published-minimal-php85=${{ needs.scan-minimal.result }} published-full-jammy=${{ needs.publish-full.result }} published-full-noble=${{ needs.publish-full.result }}