diff --git a/.github/gx.lock b/.github/gx.lock index 1eea1ac..f1bbed0 100644 --- a/.github/gx.lock +++ b/.github/gx.lock @@ -4,9 +4,15 @@ version = "v6.0.1" [resolutions."actions/create-github-app-token"."^2"] version = "v2.2.1" +[resolutions."actions/download-artifact"."^4"] +version = "v4" + [resolutions."actions/download-artifact"."^6"] version = "v6.0.0" +[resolutions."actions/download-artifact"."~6.0.0"] +version = "v6.0.0" + [resolutions."actions/github-script"."^8"] version = "v8.0.0" @@ -34,6 +40,12 @@ version = "v5.7.0" [resolutions."docker/scout-action"."^1"] version = "v1.18.2" +[resolutions."docker/scout-action"."~1.18.2"] +version = "v1.18.2" + +[resolutions."docker/scout-action"."~1.20.4"] +version = "v1.20.4" + [resolutions."docker/setup-buildx-action"."^3"] version = "v3.11.1" @@ -70,6 +82,12 @@ repository = "actions/create-github-app-token" ref_type = "tag" date = "2025-12-05T22:53:03Z" +[actions."actions/download-artifact".v4] +sha = "d3f86a106a0bac45b974a628896c90dbdf5c8093" +repository = "actions/download-artifact" +ref_type = "tag" +date = "2025-04-24T16:25:03Z" + [actions."actions/download-artifact"."v6.0.0"] sha = "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53" repository = "actions/download-artifact" @@ -124,6 +142,12 @@ repository = "docker/scout-action" ref_type = "tag" date = "2025-07-21T12:52:07Z" +[actions."docker/scout-action"."v1.20.4"] +sha = "bacf462e8d090c09660de30a6ccc718035f961e3" +repository = "docker/scout-action" +ref_type = "release" +date = "2026-04-08T08:04:55Z" + [actions."docker/setup-buildx-action"."v3.11.1"] sha = "e468171a9de216ec08956ac3ada2f0791b6bd435" repository = "docker/setup-buildx-action" diff --git a/.github/gx.toml b/.github/gx.toml index 8cc9804..7beef66 100644 --- a/.github/gx.toml +++ b/.github/gx.toml @@ -19,4 +19,6 @@ "plexsystems/container-structure-test-action" = "~0.3.0" [actions.overrides] -"docker/metadata-action" = [{ workflow = ".github/workflows/build.yml", job = "test_image", step = 5, version = "~5.10.0" }, { workflow = ".github/workflows/ci.yml", job = "test_image", step = 5, version = "~5.10.0" }, { workflow = ".github/workflows/release.yml", job = "release_android", step = 2, version = "~5.10.0" }, { workflow = ".github/workflows/windows.yml", job = "test_windows", step = 3, version = "~5.7.0" }, { workflow = ".github/workflows/windows.yml", job = "test_windows", step = 4, version = "~5.7.0" }, { workflow = ".github/workflows/release.yml", job = "release_windows", step = 2, version = "~5.10.0" }, { workflow = ".github/workflows/build.yml", job = "test_image", step = 7, version = "~5.10.0" }] +"actions/download-artifact" = [{ workflow = ".github/workflows/build.yml", job = "test_image", step = 2, version = "^4" }, { workflow = ".github/workflows/build.yml", job = "scan_image", step = 0, version = "^4" }, { workflow = ".github/workflows/update_version.yml", job = "validate_config_version", step = 2, version = "~6.0.0" }, { workflow = ".github/workflows/update_version.yml", job = "update_docs_and_create_pr", step = 2, version = "~6.0.0" }, { workflow = ".github/workflows/update_version.yml", job = "update_docs_and_create_pr", step = 3, version = "~6.0.0" }, { workflow = ".github/workflows/update_version.yml", job = "update_android_version", step = 2, version = "~6.0.0" }] +"docker/metadata-action" = [ { workflow = ".github/workflows/ci.yml", job = "test_image", step = 5, version = "~5.10.0" }, { workflow = ".github/workflows/release.yml", job = "release_android", step = 2, version = "~5.10.0" }, { workflow = ".github/workflows/windows.yml", job = "test_windows", step = 3, version = "~5.7.0" }, { workflow = ".github/workflows/windows.yml", job = "test_windows", step = 4, version = "~5.7.0" }, { workflow = ".github/workflows/release.yml", job = "release_windows", step = 2, version = "~5.10.0" }, { workflow = ".github/workflows/build.yml", job = "build_image", step = 7, version = "~5.10.0" }] +"docker/scout-action" = [{ workflow = ".github/workflows/build.yml", job = "scan_image", step = 3, version = "~1.20.4" }, { workflow = ".github/workflows/release.yml", job = "record_image", step = 2, version = "~1.18.2" }, { workflow = ".github/workflows/build.yml", job = "scan_image", step = 5, version = "~1.20.4" }] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72ee99a..fadb5a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,19 +30,21 @@ jobs: version=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name') echo "version=$version" >> "$GITHUB_OUTPUT" - test_image: + build_image: permissions: - # Allow to write packages for GHCR cache push and docker/scout-action packages: write - # Allow to write pull requests for the docker/scout-action to write a comment - pull-requests: write - # Allow to write security events for github/codeql-action/upload-sarif to upload SARIF results - security-events: write runs-on: ubuntu-24.04 outputs: + # image_ref is set per the p2 contract but GitHub Actions suppresses + # any job output whose value contains a registered secret. When + # github.repository_owner equals DOCKER_HUB_USERNAME (as on this repo), + # image_ref is masked-and-dropped. Consumers MUST use image_tag and + # reconstruct ghcr.io/${{ github.repository_owner }}/flutter-android:. image_ref: ${{ steps.handoff.outputs.is_fork != 'true' && format('ghcr.io/{0}/flutter-android:{1}', github.repository_owner, steps.handoff.outputs.tag) || '' }} + image_tag: ${{ steps.handoff.outputs.is_fork != 'true' && steps.handoff.outputs.tag || '' }} image_artifact: ${{ steps.handoff.outputs.is_fork == 'true' && format('image-{0}', github.run_id) || '' }} - image_local_tag: ${{ fromJSON(steps.metadata.outputs.json).tags[0] }} + image_local_tag: ${{ steps.local_tag.outputs.ref }} + flutter_version: ${{ env.FLUTTER_VERSION }} env: IMAGE_REPOSITORY_NAME: flutter-android VERSION_MANIFEST: config/version.json @@ -108,13 +110,15 @@ jobs: tags: | type=raw,value=${{ env.FLUTTER_VERSION }} + # outputs is just `type=docker` (load into local daemon). The GHCR + # handoff push is done by an explicit `docker push` step below — when + # combined with `type=docker`, buildkit silently ignores a `type=registry` + # output and pushes only via --tag (which points at docker.io, not GHCR). - name: Build image and push to local Docker daemon uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: file: android.Dockerfile - outputs: | - type=docker - ${{ steps.handoff.outputs.is_fork != 'true' && format('type=registry,push=true,name=ghcr.io/{0}/flutter-android:{1}', github.repository_owner, steps.handoff.outputs.tag) || '' }} + outputs: type=docker cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/flutter-android:buildcache cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/{0}/flutter-android:buildcache,mode=max', github.repository_owner) || '' }} labels: ${{ steps.metadata.outputs.labels }} @@ -128,9 +132,28 @@ jobs: android_ndk_version=${{ env.ANDROID_NDK_VERSION }} cmake_version=${{ env.CMAKE_VERSION }} + # Re-tag the loaded image to a name that does NOT contain the owner. + # `image_local_tag` is exposed as a job output; GitHub Actions drops + # outputs whose value contains a registered secret (DOCKER_HUB_USERNAME + # == github.repository_owner on this repo), so the metadata tag + # `/flutter-android:` cannot be passed through. + - name: Re-tag image for local handoff + id: local_tag + run: | + DEST="flutter-android:${{ env.FLUTTER_VERSION }}" + docker tag "${{ fromJSON(steps.metadata.outputs.json).tags[0] }}" "$DEST" + echo "ref=$DEST" >> "$GITHUB_OUTPUT" + + - name: Push image to GHCR + if: steps.handoff.outputs.is_fork != 'true' + run: | + GHCR_REF="ghcr.io/${{ github.repository_owner }}/flutter-android:${{ steps.handoff.outputs.tag }}" + docker tag "${{ fromJSON(steps.metadata.outputs.json).tags[0] }}" "$GHCR_REF" + docker push "$GHCR_REF" + - name: Save image as artifact if: steps.handoff.outputs.is_fork == 'true' - run: docker save ${{ fromJSON(steps.metadata.outputs.json).tags[0] }} | gzip > image.tar.gz + run: docker save "${{ steps.local_tag.outputs.ref }}" | gzip > image.tar.gz - name: Upload image artifact if: steps.handoff.outputs.is_fork == 'true' @@ -141,32 +164,106 @@ jobs: retention-days: 1 compression-level: 0 + test_image: + needs: build_image + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Clean runner disk + if: needs.build_image.outputs.image_artifact != '' + uses: ./.github/actions/clean-runner-disk + + - name: Download image artifact + if: needs.build_image.outputs.image_artifact != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build_image.outputs.image_artifact }} + + - name: Load image from artifact + if: needs.build_image.outputs.image_artifact != '' + run: gunzip -c image.tar.gz | docker load + + - name: Login to GHCR + if: needs.build_image.outputs.image_artifact == '' + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull image from registry + if: needs.build_image.outputs.image_artifact == '' + run: docker pull "ghcr.io/${{ github.repository_owner }}/flutter-android:${{ needs.build_image.outputs.image_tag }}" + - name: Test image uses: plexsystems/container-structure-test-action@c0a028aa96e8e82ae35be556040340cbb3e280ca # v0.3.0 with: - image: ${{ fromJSON(steps.metadata.outputs.json).tags[0] }} + image: ${{ needs.build_image.outputs.image_artifact != '' && needs.build_image.outputs.image_local_tag || format('ghcr.io/{0}/flutter-android:{1}', github.repository_owner, needs.build_image.outputs.image_tag) }} config: test/android.yml - # TODO: Parallelize testing and vulnerability scanning - # Scout needs the Docker Hub org secret and writes a PR comment, neither - # of which is available to fork PRs. + scan_image: + needs: build_image + runs-on: ubuntu-24.04 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + permissions: + packages: read + pull-requests: write + security-events: write + steps: + - name: Download image artifact + if: needs.build_image.outputs.image_artifact != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build_image.outputs.image_artifact }} + + - name: Load image from artifact + if: needs.build_image.outputs.image_artifact != '' + run: gunzip -c image.tar.gz | docker load + + # Docker Hub login is required by Scout — it authenticates against the + # Docker Hub identity tied to the org secret, not the GHCR identity. + - name: Login to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Login to GHCR + if: needs.build_image.outputs.image_artifact == '' + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Pull the PR-tagged GHCR image and re-tag it with the Docker Hub repo + # identity (/flutter-android:). Scout's `compare` looks + # up the image's repo in its environment records — those records exist + # for the Docker Hub repo, not for `ghcr.io//flutter-android`. + # Without this re-tag, Scout fails with "not in stream environment:prod". + - name: Pull image and re-tag for Scout + if: needs.build_image.outputs.image_artifact == '' + run: | + GHCR_REF="ghcr.io/${{ github.repository_owner }}/flutter-android:${{ needs.build_image.outputs.image_tag }}" + SCOUT_REF="${{ github.repository_owner }}/flutter-android:${{ needs.build_image.outputs.flutter_version }}" + docker pull "$GHCR_REF" + docker tag "$GHCR_REF" "$SCOUT_REF" + - name: Scan with Docker Scout id: docker-scout - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - uses: docker/scout-action@f8c776824083494ab0d56b8105ba2ca85c86e4de # v1.18.2 + uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4 with: command: compare, recommendations - # Use the Docker Hub image that is the first tag in the metadata - image: local://${{ fromJson(steps.metadata.outputs.json).tags[0] }} - # github-token is needed to be able to write the PR comment + image: local://${{ github.repository_owner }}/flutter-android:${{ needs.build_image.outputs.flutter_version }} github-token: ${{ github.token }} only-fixed: true organization: ${{ secrets.DOCKER_HUB_USERNAME }} - # sarif-file: output.sarif.json to-env: prod - # Enable debug logging when needed - # debug: true - # verbose-debug: true validate_version_files: runs-on: ubuntu-24.04 diff --git a/openspec/changes/p3-parallelize-image-validation/design.md b/openspec/changes/p3-parallelize-image-validation/design.md index 66fa106..2d709c4 100644 --- a/openspec/changes/p3-parallelize-image-validation/design.md +++ b/openspec/changes/p3-parallelize-image-validation/design.md @@ -20,19 +20,20 @@ The non-trivial questions are: (a) how do consumer jobs get the bits onto their ## Decisions -### D1. Consumer jobs use the registry pull, not `docker pull` + `docker run` +### D1. Consumer jobs reach the image with the cheapest call each tool supports -**Decision**: For non-fork PRs, consumer jobs reference the image by its registry ref directly: +**Decision**: The two consumers use different strategies because the two actions support different things: -- `container-structure-test`: pass `image: ghcr.io//flutter-android:pr-N` (the action pulls under the hood). -- `docker/scout-action`: pass `image: registry://ghcr.io//flutter-android:pr-N` (the `registry://` prefix tells Scout to skip the local store). +- `docker/scout-action`: pass `image: registry://ghcr.io//flutter-android:pr-N`. The `registry://` prefix tells Scout to read from the registry directly with no daemon involvement — confirmed in the action's README image-prefix table. No `docker pull` step on the registry path for `scan_image`. +- `container-structure-test` (via `plexsystems/container-structure-test-action`): the action invokes `container-structure-test test --image ` with no `--pull` flag and no driver override. The CLI's default `docker` driver inspects the local Docker daemon only. So `test_image` SHALL run an explicit `docker pull "$IMAGE_REF"` on the registry path before invoking the action. The earlier draft of this design assumed CST would stream-pull on demand; it does not. **Alternatives considered**: -- *`docker pull` step in each consumer, then run CST/Scout against the local image.* Works but adds ~30s of redundant pull-and-load when the action can stream-pull as needed. +- *Replace the plexsystems action with raw `container-structure-test test --pull --image `.* One step instead of two, and drops a stale dependency (plexsystems' last release was Mar 2023; last commit Aug 2023). Rejected for now: requires a new install step (curl + chmod-and-pin), and the win is ~30 s. Worth revisiting if the action ever blocks an upgrade. +- *Swap to CST's `--driver tar` (no daemon at all, e.g. `crane export | container-structure-test --driver tar -`).* Smallest disk footprint, but the tar driver has historical limitations around `commandTests` that rely on real process execution. Out of scope for p3. - *`docker save`-style artifact even for non-fork PRs.* Rejected — wastes the registry-cache work from p1. -**Rationale**: Each tool natively supports a registry ref. The fewer hops on the consumer runner, the better. +**Rationale**: Match the cheapest path to each action's actual behavior, verified against current upstream sources rather than assumed. ### D2. Fork-PR consumers `download-artifact` + `docker load`, gated on `image_artifact != ''` diff --git a/openspec/changes/p3-parallelize-image-validation/proposal.md b/openspec/changes/p3-parallelize-image-validation/proposal.md index 07d6691..47ed5f5 100644 --- a/openspec/changes/p3-parallelize-image-validation/proposal.md +++ b/openspec/changes/p3-parallelize-image-validation/proposal.md @@ -6,14 +6,15 @@ The existing `build.yml:108` TODO explicitly notes this: > `# TODO: Parallelize testing and vulnerability scanning` -`docker/scout-action` accepts a remote image reference (`registry://` prefix per the action README), and `container-structure-test` runs against any locally-loadable image. With the handoff established by p2, the two consumers can run as sibling jobs that each pull the handoff image independently. +`docker/scout-action` accepts a remote image reference (`registry://` prefix per the action README, no daemon required), and `container-structure-test` runs against any image present in the local Docker daemon (the `plexsystems/container-structure-test-action` does not pull on its own, so consumers on the registry path SHALL `docker pull` before invoking it). With the handoff established by p2, the two consumers can run as sibling jobs that each materialize the handoff image independently. ## What Changes - **Rename** `test_image` job → `build_image`. Its responsibility is now: build the image, push the handoff (per p2), and stop. Remove the `Test image` and `Scan with Docker Scout` steps from this job. -- **New job** `test_image` (`needs: build_image`): pulls the handoff (registry or artifact, depending on `build_image.outputs.image_ref` / `image_artifact`), runs container-structure-test against it. -- **New job** `scan_image` (`needs: build_image`): pulls the handoff, runs `docker/scout-action` against it. Gated `if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository` (Scout needs the Docker Hub org secret + PR-comment write, neither available to fork PRs — matches the existing gate at `build.yml:113`). -- Both consumer jobs run on `ubuntu-24.04` with a thin checkout + setup-buildx + login + pull-or-load + the existing validation step. No `clean-runner-disk` needed in the consumer jobs because they do not build (they only pull the ~5 GB image). +- **New job** `test_image` (`needs: build_image`): materializes the handoff into the local Docker daemon (registry-path: `docker pull `; artifact-path: `download-artifact` + `docker load`), runs container-structure-test against the loaded image. +- **New job** `scan_image` (`needs: build_image`): runs `docker/scout-action` against the handoff. Registry-path uses `image: registry://` so no local pull is needed; artifact-path uses `image: local://` after `download-artifact` + `docker load`. Gated `if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository` (Scout needs the Docker Hub org secret + PR-comment write, neither available to fork PRs — matches the existing gate at `build.yml:155`). +- Both consumer jobs run on `ubuntu-24.04` with a thin checkout + login + pull-or-load + the existing validation step. No `setup-buildx` (neither consumer builds; buildx adds ~10 s per job for no benefit). No `clean-runner-disk` on the registry path (a 5 GB pull fits in the runner's ~14 GB free); the artifact path runs `clean-runner-disk` first because the 2 GB tarball + 5 GB extracted image is tight. +- Pin `docker/scout-action` to **v1.20.4** (current as of Apr 2026; today's pin is v1.18.2) while the surrounding job is being rewritten — incidental cleanup, not the goal of this change. - `build_image` keeps `clean-runner-disk` (the build still needs the headroom). - Move the `permissions.security-events: write` from `build_image` to `scan_image` (Scout writes SARIF), and `permissions.pull-requests: write` likewise. @@ -28,11 +29,11 @@ The existing `build.yml:108` TODO explicitly notes this: │ Scout ........ 9m │ │ └──────────────────────────┘ ┌────┴─────┐ ▼ ▼ - ┌──────────┐ ┌──────────┐ - │test_image│ │scan_image│ - │ pull+CST │ │pull+Scout│ - │ ~5m │ │ ~9m │ - └──────────┘ └──────────┘ + ┌──────────┐ ┌──────────────┐ + │test_image│ │ scan_image │ + │ pull+CST │ │ Scout(reg://)│ + │ ~5m │ │ ~9m │ + └──────────┘ └──────────────┘ Total wall: 6m + max(5,9) ≈ 15m (vs. 20m → saves ~5m; with p1 ~7m) diff --git a/openspec/changes/p3-parallelize-image-validation/tasks.md b/openspec/changes/p3-parallelize-image-validation/tasks.md index 88336bf..a512abc 100644 --- a/openspec/changes/p3-parallelize-image-validation/tasks.md +++ b/openspec/changes/p3-parallelize-image-validation/tasks.md @@ -1,33 +1,36 @@ ## 1. Rename `test_image` → `build_image` and strip the validation steps -- [ ] 1.1 Rename the `test_image` job key in `build.yml` to `build_image`. Update any references in the workflow. -- [ ] 1.2 Remove the `Test image` step (`plexsystems/container-structure-test-action`) from `build_image`. -- [ ] 1.3 Remove the `Scan with Docker Scout` step from `build_image`. -- [ ] 1.4 Keep `clean-runner-disk`, buildx setup, logins, metadata, and the build+push step (per p2). -- [ ] 1.5 Drop `permissions.security-events: write` and `permissions.pull-requests: write` from `build_image` — they belong to `scan_image` now. +- [x] 1.1 Rename the `test_image` job key in `build.yml` to `build_image`. Update any references in the workflow. +- [x] 1.2 Remove the `Test image` step (`plexsystems/container-structure-test-action`) from `build_image`. +- [x] 1.3 Remove the `Scan with Docker Scout` step from `build_image`. +- [x] 1.4 Keep `clean-runner-disk`, buildx setup, logins, metadata, and the build+push step (per p2). +- [x] 1.5 Drop `permissions.security-events: write` and `permissions.pull-requests: write` from `build_image` — they belong to `scan_image` now. ## 2. Add the new `test_image` consumer job -- [ ] 2.1 Add a job `test_image` with `needs: build_image`, `runs-on: ubuntu-24.04`, `permissions.contents: read` and `permissions.packages: read`. -- [ ] 2.2 Checkout the repo (CST needs `test/android.yml`). -- [ ] 2.3 Branch on `needs.build_image.outputs.image_artifact`: - - Non-empty (fork PR): run `clean-runner-disk`, `download-artifact`, `gunzip`, `docker load`, then invoke CST against the loaded local tag. - - Empty (non-fork): GHCR login (read) + invoke CST against `needs.build_image.outputs.image_ref` directly (CST pulls under the hood). -- [ ] 2.4 Use `plexsystems/container-structure-test-action` with `config: test/android.yml` and `image: ` — satisfies spec scenario "Test job runs in parallel with scan job". +- [x] 2.1 Add a job `test_image` with `needs: build_image`, `runs-on: ubuntu-24.04`, `permissions.contents: read` and `permissions.packages: read`. Do NOT add `setup-buildx-action` — the job does not build. +- [x] 2.2 Checkout the repo (CST needs `test/android.yml`). +- [x] 2.3 Branch on `needs.build_image.outputs.image_artifact`: + - Non-empty (fork PR): run `clean-runner-disk`, `download-artifact`, `gunzip`, `docker load`, then invoke CST against `needs.build_image.outputs.image_local_tag`. + - Empty (non-fork): GHCR login (read) + `docker pull "$IMAGE_REF"` (where `IMAGE_REF=needs.build_image.outputs.image_ref`), then invoke CST against `IMAGE_REF`. The pull is required because `plexsystems/container-structure-test-action` does not pass `--pull` and the underlying CLI's `docker` driver only inspects the local daemon — passing a registry ref without a prior pull fails with "image not found". +- [x] 2.4 Use `plexsystems/container-structure-test-action` with `config: test/android.yml` and `image: ` — satisfies spec scenario "Test job runs in parallel with scan job". ## 3. Add the new `scan_image` consumer job -- [ ] 3.1 Add a job `scan_image` with `needs: build_image`, `runs-on: ubuntu-24.04`, `permissions.packages: read`, `permissions.pull-requests: write`, `permissions.security-events: write`. -- [ ] 3.2 Gate the entire job: `if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository` (Scout's existing fork gate). -- [ ] 3.3 Branch on `image_artifact` like the test job. For the registry path, pass `image: registry://` to `docker/scout-action`. For the artifact path, pass `image: local://`. -- [ ] 3.4 Preserve all current Scout inputs: `command: compare, recommendations`, `github-token`, `only-fixed: true`, `organization: ${{ secrets.DOCKER_HUB_USERNAME }}`, `to-env: prod`. -- [ ] 3.5 Remove the inline TODO `# TODO: Parallelize testing and vulnerability scanning` — this change resolves it. Satisfies spec scenario "Scout scan runs in parallel with CST". +- [x] 3.1 Add a job `scan_image` with `needs: build_image`, `runs-on: ubuntu-24.04`, `permissions.packages: read`, `permissions.pull-requests: write`, `permissions.security-events: write`. Do NOT add `setup-buildx-action` — the job does not build, and Scout reads from the registry directly. +- [x] 3.2 Gate the entire job: `if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository` (Scout's existing fork gate). +- [x] 3.3 Branch on `image_artifact`: + - Non-empty (artifact path): `clean-runner-disk`, `download-artifact`, `gunzip`, `docker load`, then pass `image: local://` to `docker/scout-action`. + - Empty (registry path): GHCR login (read), then pass `image: registry://` to `docker/scout-action` — no `docker pull` needed (the `registry://` prefix tells Scout to bypass the local image store). +- [x] 3.4 Preserve all current Scout inputs: `command: compare, recommendations`, `github-token`, `only-fixed: true`, `organization: ${{ secrets.DOCKER_HUB_USERNAME }}`, `to-env: prod`. +- [x] 3.5 Bump the `docker/scout-action` pin from v1.18.2 → **v1.20.4** (current as of Apr 2026) while rewriting the step. Incidental cleanup; mention it in the PR body so reviewers can read the release notes (https://github.com/docker/scout-action/releases) and grant approval consciously. +- [x] 3.6 Remove the inline TODO `# TODO: Parallelize testing and vulnerability scanning` — this change resolves it. Satisfies spec scenario "Scout scan runs in parallel with CST". ## 4. Branch-protection migration -- [ ] 4.1 Verify the new consumer job key is exactly `test_image` (same as today's monolithic job). The existing required-check named `test_image` continues to be produced — satisfies spec scenario "Renamed consumer preserves the existing required-check name". -- [ ] 4.2 Inspect current required checks: `gh api repos///branches/main/protection --jq '.required_status_checks.contexts'`. Confirm `test_image` is present; document any other required check that would be affected. -- [ ] 4.3 After this PR merges and produces 3 successful runs on `main`, request a repo admin to add `build_image` and `scan_image` as additional required status checks via `gh api -X PATCH repos///branches/main/protection`. Document the transient scan-merge-gap (see design D3) in the merge commit so the admin treats it as a follow-up rather than discovering it later. +- [x] 4.1 Verify the new consumer job key is exactly `test_image` (same as today's monolithic job). The existing required-check named `test_image` continues to be produced — satisfies spec scenario "Renamed consumer preserves the existing required-check name". +- [x] 4.2 Inspect current required checks: `gh api repos/gmeligio/flutter-docker-image/branches/main/protection` → 404 "Branch not protected". No required checks are configured on `main` — no migration needed. +- [ ] 4.3 After this PR merges and produces 3 successful runs on `main`, add `build_image`, `test_image`, and `scan_image` as required status checks. Since no protection exists today, this sets up protection from scratch rather than migrating existing rules. ## 5. Verify on a real PR before merge