ci: parallelize image validation (#453)

- Splits the monolithic `test_image` job (~20m serial) into
`build_image` + parallel `test_image` (CST) and `scan_image` (Scout)
consumers — wall-clock drops to ~15m (~5m saved per PR).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: verified-commit[bot] <180343340+verified-commit[bot]@users.noreply.github.com>
This commit is contained in:
Eligio Mariño
2026-05-18 22:21:33 +02:00
committed by GitHub
parent 57cce30f4a
commit 87eb48fdc0
6 changed files with 187 additions and 59 deletions
+24
View File
@@ -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"
+3 -1
View File
@@ -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" }]
+121 -24
View File
@@ -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:<tag>.
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
# `<owner>/flutter-android:<version>` 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 (<owner>/flutter-android:<version>). 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/<owner>/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
@@ -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/<owner>/flutter-android:pr-N` (the action pulls under the hood).
- `docker/scout-action`: pass `image: registry://ghcr.io/<owner>/flutter-android:pr-N` (the `registry://` prefix tells Scout to skip the local store).
- `docker/scout-action`: pass `image: registry://ghcr.io/<owner>/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 <input>` 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 <ref>`.* 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 <ref> | 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 != ''`
@@ -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 <image_ref>`; 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://<image_ref>` so no local pull is needed; artifact-path uses `image: local://<image_local_tag>` 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)
@@ -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: <ref-or-loaded-tag>` — 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: <ref-or-loaded-tag>` — 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://<image_ref>` to `docker/scout-action`. For the artifact path, pass `image: local://<loaded-tag>`.
- [ ] 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://<image_local_tag>` to `docker/scout-action`.
- Empty (registry path): GHCR login (read), then pass `image: registry://<image_ref>` 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/<owner>/<repo>/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/<owner>/<repo>/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