From ee22a969d3b64fa3a037cb1a84cdc451b806fb76 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 26 Aug 2026 03:36:46 +0100 Subject: [PATCH] Bind container qualification to caller commit Change-source: pulse-maintainer --- .github/workflows/build-release-candidate.yml | 1 - .github/workflows/create-release.yml | 1 - .../workflows/qualify-release-containers.yml | 17 ++++--- .../installtests/build_release_assets_test.go | 50 +++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index e31dc7974..59334cc2d 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -862,4 +862,3 @@ jobs: with: version: ${{ inputs.version }} container_artifact: ${{ needs.build.outputs.container_artifact_name }} - source_sha: ${{ github.sha }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6a6f83906..7f3660403 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -257,7 +257,6 @@ jobs: with: version: ${{ needs.prepare.outputs.version }} container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }} - source_sha: ${{ github.sha }} # Build the embed bundle independently so backend and smoke lanes can start # without waiting for the full frontend quality suite. diff --git a/.github/workflows/qualify-release-containers.yml b/.github/workflows/qualify-release-containers.yml index 71da772fc..714285d14 100644 --- a/.github/workflows/qualify-release-containers.yml +++ b/.github/workflows/qualify-release-containers.yml @@ -11,10 +11,6 @@ on: description: 'Exact-candidate container payload artifact from this run' required: true type: string - source_sha: - description: 'Exact source commit bound to the candidate payload' - required: true - type: string permissions: contents: read @@ -31,7 +27,16 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ inputs.source_sha }} + # A reusable workflow inherits github.sha from its caller. Do not + # accept a caller-controlled checkout ref: the caller event commit is + # the immutable source identity for both code and candidate payload. + ref: ${{ github.sha }} + persist-credentials: false + + - name: Verify exact caller source + env: + EXPECTED_SOURCE_SHA: ${{ github.sha }} + run: test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}" - name: Download exact-candidate container payload uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -45,7 +50,7 @@ jobs: --release-dir "$RUNNER_TEMP/release-container-payload/payload" \ --manifest "$RUNNER_TEMP/release-container-payload/release-container-payload.json" \ --version "${{ inputs.version }}" \ - --source-sha "${{ inputs.source_sha }}" + --source-sha "${{ github.sha }}" - name: Assemble exact-candidate runtime and agent images run: | diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index b89262e3b..9aa247cac 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1513,6 +1513,56 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { } } +func TestReleaseContainerQualificationBindsCheckoutToCallerCommit(t *testing.T) { + qualifierBytes, err := os.ReadFile(repoFile(".github", "workflows", "qualify-release-containers.yml")) + if err != nil { + t.Fatalf("read qualify-release-containers.yml: %v", err) + } + candidateBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml")) + if err != nil { + t.Fatalf("read build-release-candidate.yml: %v", err) + } + releaseBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) + if err != nil { + t.Fatalf("read create-release.yml: %v", err) + } + + qualifier := string(qualifierBytes) + qualifyJob := workflowJobBlock(t, qualifier, "qualify") + for _, required := range []string{ + `ref: ${{ github.sha }}`, + `persist-credentials: false`, + `EXPECTED_SOURCE_SHA: ${{ github.sha }}`, + `test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}"`, + `--source-sha "${{ github.sha }}"`, + } { + if !strings.Contains(qualifyJob, required) { + t.Fatalf("release container qualification does not fail closed on the exact caller commit: %s", required) + } + } + for _, forbidden := range []string{ + "inputs.source_sha", + "source_sha:", + } { + if strings.Contains(qualifier, forbidden) { + t.Fatalf("release container qualification must not accept an arbitrary source ref: %s", forbidden) + } + } + + callerJobs := map[string]string{ + "build-release-candidate.yml": workflowJobBlock(t, string(candidateBytes), "qualify-release-containers"), + "create-release.yml": workflowJobBlock(t, string(releaseBytes), "qualify_release_containers"), + } + for caller, job := range callerJobs { + if strings.Contains(job, "source_sha:") { + t.Fatalf("%s must not forward a caller-selectable source ref to container qualification", caller) + } + if !strings.Contains(job, "uses: ./.github/workflows/qualify-release-containers.yml") { + t.Fatalf("%s no longer calls the trusted container qualifier", caller) + } + } +} + func TestDeploymentDefaultsPinVersionedImagesAndHelmDocsChecksum(t *testing.T) { versionBytes, err := os.ReadFile(repoFile("VERSION")) if err != nil {