diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 129249ebf..cc4ef8c44 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -53,6 +53,9 @@ on: manifest_artifact_name: description: 'Release candidate manifest artifact name' value: ${{ jobs.build.outputs.manifest_artifact_name }} + container_artifact_name: + description: 'Exact-candidate container payload artifact name' + value: ${{ jobs.build.outputs.container_artifact_name }} windows_signing_backend: description: 'Windows signing backend used for the candidate' value: ${{ jobs.collect-windows-signing.outputs.signing_backend }} @@ -560,6 +563,7 @@ jobs: outputs: artifact_name: ${{ steps.identity.outputs.artifact_name }} manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }} + container_artifact_name: ${{ steps.identity.outputs.container_artifact_name }} steps: - name: Resolve candidate identity id: identity @@ -567,6 +571,7 @@ jobs: set -euo pipefail echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "container_artifact_name=release-container-payload-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -690,6 +695,26 @@ jobs: --source-sha "${GITHUB_SHA}" \ --output release-candidate-manifest/release-candidate.json + - name: Create exact-candidate container payload + run: | + set -euo pipefail + payload_root="$RUNNER_TEMP/release-container-payload" + ./scripts/prepare-release-container-context.sh \ + release \ + "${{ inputs.version }}" \ + "${payload_root}/payload/release" + mkdir -p "${payload_root}/payload/compiled/binaries" + for arch in amd64 arm64; do + install -m 0755 \ + "release-compiled/payload/binaries/pulse-control-plane-linux-${arch}" \ + "${payload_root}/payload/compiled/binaries/pulse-control-plane-linux-${arch}" + done + python3 scripts/release_candidate_manifest.py create \ + --release-dir "${payload_root}/payload" \ + --version "${{ inputs.version }}" \ + --source-sha "${GITHUB_SHA}" \ + --output "${payload_root}/release-container-payload.json" + - name: Upload immutable release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -709,65 +734,78 @@ jobs: retention-days: 1 overwrite: true + - name: Upload exact-candidate container payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.identity.outputs.container_artifact_name }} + path: ${{ runner.temp }}/release-container-payload/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: true + qualify-release-containers: name: Exact-Candidate Container and Helm Smoke needs: build + if: ${{ always() && needs.build.result == 'success' }} runs-on: [self-hosted, Linux, X64, pulse-pve-build] timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Download immutable release candidate + - name: Download exact-candidate container payload uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ needs.build.outputs.artifact_name }} - path: release + name: ${{ needs.build.outputs.container_artifact_name }} + path: ${{ runner.temp }}/release-container-payload - - name: Download release candidate manifest - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ needs.build.outputs.manifest_artifact_name }} - path: release-candidate-manifest - - - name: Verify immutable release candidate + - name: Verify exact-candidate container payload run: | python3 scripts/release_candidate_manifest.py verify-local \ - --release-dir release \ - --manifest release-candidate-manifest/release-candidate.json \ + --release-dir "$RUNNER_TEMP/release-container-payload/payload" \ + --manifest "$RUNNER_TEMP/release-container-payload/release-container-payload.json" \ --version "${{ inputs.version }}" \ --source-sha "${GITHUB_SHA}" - - name: Prepare exact-candidate container context - run: ./scripts/prepare-release-container-context.sh release "${{ inputs.version }}" "$RUNNER_TEMP/release-container-context" - - name: Assemble exact-candidate runtime and agent images run: | set -euo pipefail docker buildx build \ --target runtime_prebuilt \ --platform linux/amd64 \ - --build-context "release_payload=${RUNNER_TEMP}/release-container-context" \ + --build-context "release_payload=${RUNNER_TEMP}/release-container-payload/payload/release" \ --load \ --tag "pulse-helm-smoke:${{ inputs.version }}" \ . docker buildx build \ --target agent_runtime_prebuilt \ --platform linux/amd64 \ - --build-context "release_payload=${RUNNER_TEMP}/release-container-context" \ + --build-context "release_payload=${RUNNER_TEMP}/release-container-payload/payload/release" \ --load \ --tag "pulse-agent-candidate:${{ inputs.version }}" \ . + docker buildx build \ + --file deploy/provider-msp/Dockerfile.control-plane \ + --target control_plane_prebuilt \ + --platform linux/amd64 \ + --build-context "compiled_payload=${RUNNER_TEMP}/release-container-payload/payload/compiled" \ + --load \ + --tag "pulse-control-plane-candidate:${{ inputs.version }}" \ + . - name: Verify container binaries match immutable candidate run: | set -euo pipefail - expected_server="$(sha256sum "$RUNNER_TEMP/release-container-context/amd64/bin/pulse" | awk '{print $1}')" + expected_server="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/release/amd64/bin/pulse" | awk '{print $1}')" actual_server="$(docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${{ inputs.version }}" -c 'sha256sum /app/pulse' | awk '{print $1}')" - expected_agent="$(sha256sum "$RUNNER_TEMP/release-container-context/amd64/bin/pulse-agent-linux-amd64" | awk '{print $1}')" + expected_agent="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/release/amd64/bin/pulse-agent-linux-amd64" | awk '{print $1}')" actual_agent="$(docker run --rm --entrypoint /bin/sh "pulse-agent-candidate:${{ inputs.version }}" -c 'sha256sum /usr/local/bin/pulse-agent' | awk '{print $1}')" + expected_control_plane="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/compiled/binaries/pulse-control-plane-linux-amd64" | awk '{print $1}')" + actual_control_plane="$(docker run --rm --entrypoint /bin/sh "pulse-control-plane-candidate:${{ inputs.version }}" -c 'sha256sum /usr/local/bin/pulse-control-plane' | awk '{print $1}')" test "${actual_server}" = "${expected_server}" test "${actual_agent}" = "${expected_agent}" + test "${actual_control_plane}" = "${expected_control_plane}" - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 39acb0722..6f609b911 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1077,8 +1077,9 @@ jobs: publish_docker: needs: - prepare + - build_release_candidate - create_release - if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write @@ -1088,6 +1089,8 @@ jobs: secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} + container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }} + source_sha: ${{ needs.create_release.outputs.target_commitish }} validate_release_assets: needs: diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 4a8924a07..8f52dba63 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -1,8 +1,9 @@ name: Publish Docker Images run-name: Publish Docker Images ${{ inputs.tag }} -# Triggered by create-release.yml after staging images pass tests. -# Builds multi-arch images (amd64+arm64) from source and publishes to Docker Hub and GHCR. +# Called by create-release.yml after exact-candidate image qualification passes. +# Assembles multi-arch images (amd64+arm64) from the verified candidate payload +# and publishes them to Docker Hub and GHCR. on: workflow_call: inputs: @@ -10,10 +11,12 @@ on: description: 'Release tag (e.g., v4.34.0)' required: true type: string - workflow_dispatch: - inputs: - tag: - description: 'Release tag (e.g., v4.34.0)' + container_artifact: + description: 'Exact-candidate container payload artifact from this release run' + required: true + type: string + source_sha: + description: 'Exact source commit bound to the candidate payload' required: true type: string @@ -71,9 +74,25 @@ jobs: - name: Check out validated release tag env: TAG: ${{ steps.version.outputs.tag }} + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} run: | set -euo pipefail git checkout --detach "refs/tags/${TAG}" + test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}" + + - name: Download exact-candidate container payload + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.container_artifact }} + path: ${{ runner.temp }}/release-container-payload + + - name: Verify exact-candidate container payload + run: | + python3 scripts/release_candidate_manifest.py verify-local \ + --release-dir "$RUNNER_TEMP/release-container-payload/payload" \ + --manifest "$RUNNER_TEMP/release-container-payload/release-container-payload.json" \ + --version "${{ steps.version.outputs.version }}" \ + --source-sha "${{ inputs.source_sha }}" - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 @@ -94,38 +113,19 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Derive license public key Docker cache key - id: license_key_cache - env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - run: | - set -euo pipefail - decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" - if [ "${decoded_len}" != "32" ]; then - echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2 - exit 1 - fi - key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" - echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}" - - - name: Build and push Pulse server image (multi-arch) + - name: Assemble and push Pulse server image (multi-arch) id: build_server_image uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . - target: runtime + target: runtime_prebuilt + build-contexts: | + release_payload=${{ runner.temp }}/release-container-payload/payload/release platforms: linux/amd64,linux/arm64 push: true provenance: mode=max sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache - build-args: | - VERSION=${{ steps.version.outputs.tag }} - PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} - PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - secrets: | - pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} tags: | rcourtman/pulse:${{ steps.version.outputs.tag }} rcourtman/pulse:${{ steps.version.outputs.version }} @@ -148,22 +148,20 @@ jobs: push-to-registry: true create-storage-record: false - - name: Build and push Pulse control-plane image (multi-arch) + - name: Assemble and push Pulse control-plane image (multi-arch) id: build_control_plane_image uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: deploy/provider-msp/Dockerfile.control-plane + target: control_plane_prebuilt + build-contexts: | + compiled_payload=${{ runner.temp }}/release-container-payload/payload/compiled platforms: linux/amd64,linux/arm64 push: true provenance: mode=max sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-control-plane:buildcache - build-args: | - VERSION=${{ steps.version.outputs.tag }} - PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} - secrets: | - pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }} rcourtman/pulse-control-plane:${{ steps.version.outputs.version }} diff --git a/deploy/provider-msp/Dockerfile.control-plane b/deploy/provider-msp/Dockerfile.control-plane index eabcf2d72..9d819f631 100644 --- a/deploy/provider-msp/Dockerfile.control-plane +++ b/deploy/provider-msp/Dockerfile.control-plane @@ -51,7 +51,14 @@ RUN --mount=type=cache,id=pulse-control-plane-go-mod,target=/go/pkg/mod \ -trimpath \ -o /pulse-control-plane ./cmd/pulse-control-plane -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS control-plane-runtime-foundation RUN apk add --no-cache ca-certificates docker-cli -COPY --from=builder /pulse-control-plane /usr/local/bin/pulse-control-plane ENTRYPOINT ["pulse-control-plane"] + +FROM control-plane-runtime-foundation AS control_plane_prebuilt +ARG TARGETARCH +COPY --from=compiled_payload /binaries/pulse-control-plane-linux-${TARGETARCH:-amd64} /usr/local/bin/pulse-control-plane +RUN chmod 755 /usr/local/bin/pulse-control-plane + +FROM control-plane-runtime-foundation AS runtime +COPY --from=builder /pulse-control-plane /usr/local/bin/pulse-control-plane diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0fa55c415..772404bd1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -551,8 +551,9 @@ upgrade, update, release, or artifact-selection behavior. not build, sign, and then discard a complete public release packet first. Container qualification must consume the verified immutable candidate archives through `scripts/prepare-release-container-context.sh`, assemble - the prebuilt runtime and agent targets without recompiling source, and - compare the embedded server and agent digests with the candidate bytes + the prebuilt runtime, agent, and provider control-plane targets without + recompiling source, and compare every embedded executable digest with the + candidate bytes before exercising the same local runtime through the Helm install/upgrade smoke. The reusable build-release-candidate workflow owns this proof so a standalone candidate dispatch, a release dry run, and a publishing release @@ -1904,26 +1905,29 @@ updated last. That same governed release lineage now also owns artifact attestation and secret-safe container builds. Release workflows must publish max-level image provenance plus SBOM attestations, push keyless GitHub/Sigstore attestations -for the published server and agent images, attest the generated release packet -assets from the `release/` directory, and pass the embedded license public key -through BuildKit secret mounts instead of Docker build arguments so release -metadata and image history cannot re-expose it. -Because BuildKit secret contents are intentionally excluded from layer cache -keys, those Docker builds must also pass a non-secret SHA-256 fingerprint of -the mounted license public key through `PULSE_LICENSE_PUBLIC_KEY_SHA256` and -the `Dockerfile` must verify that fingerprint before embedding the key. A -release image build must fail closed if the fingerprint is present but the -secret is missing, malformed, or mismatched, so cached no-key binaries cannot -be reused for release-grade hosted or self-hosted runtime images. The matching -installability proof lives in `scripts/installtests/build_release_assets_test.go` -and `scripts/release_control/release_promotion_policy_test.py`, and both must -assert the secret mount and non-secret fingerprint argument together. +for the published server and provider control-plane images, and attest the +generated release packet assets from the `release/` directory. The exact-SHA +candidate compiler must validate and embed the governed license and update +public keys once, record the resulting runtime and control-plane binaries in +the immutable candidate manifest, and send only those verified bytes to image +qualification and publication. `publish-docker.yml` must not receive release +signing material or license-key build inputs and must not recompile either +binary. +Source-built release-grade Docker targets remain a fail-closed diagnostic and +development boundary. When those targets are used, they must pass the license +public key through a BuildKit secret mount rather than a Docker build argument, +pair it with the non-secret `PULSE_LICENSE_PUBLIC_KEY_SHA256` cache key, and +verify the fingerprint before embedding the key. The matching installability +proof lives in `scripts/installtests/build_release_assets_test.go` and +`scripts/release_control/release_promotion_policy_test.py`. The standalone hosted control-plane image is part of the same release-license boundary. `deploy/provider-msp/Dockerfile.control-plane` must build `cmd/pulse-control-plane` with `-tags release`, canonical -`scripts/release_ldflags.sh server` metadata, an embedded license public key -from the BuildKit `pulse_license_public_key` secret, and the same -`PULSE_LICENSE_PUBLIC_KEY_SHA256` fingerprint gate. Provider-hosted MSP uses +`scripts/release_ldflags.sh server` metadata, and the embedded governed license +public key. Its source-built target must retain the BuildKit secret and +`PULSE_LICENSE_PUBLIC_KEY_SHA256` fingerprint gate, while its published +prebuilt target must consume the manifest-bound control-plane binaries from +the exact candidate. Provider-hosted MSP uses that control-plane image for signed MSP-license enforcement, so it must not be possible to publish a provider MSP control-plane image that accepts `PULSE_LICENSE_DEV_MODE` or `PULSE_LICENSE_PUBLIC_KEY` runtime overrides. @@ -1931,6 +1935,10 @@ possible to publish a provider MSP control-plane image that accepts `rcourtman/pulse-control-plane` and `ghcr.io//pulse-control-plane` from that Dockerfile, with the same version tags and prerelease/latest tag policy as the main Pulse runtime image. +The reusable Docker publisher must accept the exact container artifact name +and source SHA from its owning release run, verify both against the checked-out +tag and candidate manifest, and expose no standalone dispatch that could +silently rebuild different bytes for an existing release tag. That same supply-chain boundary also owns the checked-in build roots themselves. `Dockerfile` must pin its Node, Go, and Alpine bases by immutable manifest-list digest so multi-arch release builds do not silently drift onto a diff --git a/scripts/build-release-binaries.sh b/scripts/build-release-binaries.sh index dab87c99e..a40fb01b3 100755 --- a/scripts/build-release-binaries.sh +++ b/scripts/build-release-binaries.sh @@ -108,6 +108,10 @@ for target in "${PULSE_RELEASE_SERVER_TARGETS[@]}"; do task_components+=(server) task_targets+=("${target}") done +for target in "${PULSE_RELEASE_CONTROL_PLANE_TARGETS[@]}"; do + task_components+=(control-plane) + task_targets+=("${target}") +done build_one() { local component="$1" @@ -129,10 +133,14 @@ build_one() { package=./cmd/pulse ldflags="${server_ldflags}" ;; + control-plane) + package=./cmd/pulse-control-plane + ldflags="${server_ldflags}" + ;; esac read -r -a target_env_parts <<<"${target_env}" command=(go build "${release_go_build_args[@]}") - if [[ "${component}" == server ]]; then command+=(-tags release); fi + if [[ "${component}" == server || "${component}" == control-plane ]]; then command+=(-tags release); fi if [[ -n "${ldflags}" ]]; then command+=("-ldflags=${ldflags}"); fi command+=(-o "${output}" "${package}") env "${target_env_parts[@]}" GOMAXPROCS="${go_procs}" "${command[@]}" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 881fa1d59..9a628eeee 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -91,6 +91,8 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { for _, needle := range []string{ `release_go_build_args=(-buildvcs=false -trimpath)`, `command=(go build "${release_go_build_args[@]}")`, + `package=./cmd/pulse-control-plane`, + `task_components+=(control-plane)`, } { if !strings.Contains(compileScript, needle) { t.Fatalf("build-release-binaries.sh missing clean compilation contract: %s", needle) @@ -168,6 +170,7 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) { `validate_archive_entries "${archive}"`, `tar --no-same-owner --no-same-permissions -xzf`, `diff -qr --exclude=pulse`, + `find "${output_dir}/arm64" -depth -mindepth 1`, } { if !strings.Contains(prepareScript, needle) { t.Fatalf("prepare-release-container-context.sh missing candidate guard: %s", needle) @@ -1195,8 +1198,13 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { createReleaseRequired := []string{ `Exact-Candidate Container and Helm Smoke`, `./scripts/prepare-release-container-context.sh`, + `container_artifact_name`, + `container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }}`, + `source_sha: ${{ needs.create_release.outputs.target_commitish }}`, + `release-container-payload.json`, `--target runtime_prebuilt`, `--target agent_runtime_prebuilt`, + `--target control_plane_prebuilt`, `Verify container binaries match immutable candidate`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `Validate installer signing key pins`, @@ -1208,6 +1216,9 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4`, } containerJob := workflowJobBlock(t, string(candidateWorkflowBytes), "qualify-release-containers") + if !strings.Contains(containerJob, "always() && needs.build.result == 'success'") { + t.Fatal("exact-candidate container qualification must not inherit skipped native-signing dependencies") + } for _, forbidden := range []string{ "PULSE_UPDATE_SIGNING_KEY", "PULSE_LICENSE_PUBLIC_KEY", @@ -1218,6 +1229,20 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { t.Fatalf("PVE exact-candidate container qualification must not receive release authority: %s", forbidden) } } + controlPlaneDockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane")) + if err != nil { + t.Fatalf("read Dockerfile.control-plane: %v", err) + } + controlPlaneDockerfile := string(controlPlaneDockerfileBytes) + for _, needle := range []string{ + "FROM control-plane-runtime-foundation AS control_plane_prebuilt", + "COPY --from=compiled_payload /binaries/pulse-control-plane-linux-${TARGETARCH:-amd64}", + "FROM control-plane-runtime-foundation AS runtime", + } { + if !strings.Contains(controlPlaneDockerfile, needle) { + t.Fatalf("Dockerfile.control-plane missing exact-candidate target: %s", needle) + } + } for _, needle := range createReleaseRequired { if !strings.Contains(createRelease, needle) { t.Fatalf("create-release.yml missing attested secret-safe release build contract: %s", needle) @@ -1235,14 +1260,16 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { publishRequired := []string{ `provenance: mode=max`, `sbom: true`, - `secrets: |`, - `id: license_key_cache`, + `container_artifact:`, + `source_sha:`, + `Download exact-candidate container payload`, + `Verify exact-candidate container payload`, + `target: runtime_prebuilt`, + `release_payload=${{ runner.temp }}/release-container-payload/payload/release`, `id: build_control_plane_image`, `file: deploy/provider-msp/Dockerfile.control-plane`, - `PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}`, - `PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, - `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, - `pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, + `target: control_plane_prebuilt`, + `compiled_payload=${{ runner.temp }}/release-container-payload/payload/compiled`, `subject-name: docker.io/rcourtman/pulse`, `subject-name: ghcr.io/${{ github.repository_owner }}/pulse`, `subject-name: docker.io/rcourtman/pulse-control-plane`, @@ -1263,8 +1290,15 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { t.Fatalf("publish-docker.yml missing attested secret-safe publish contract: %s", needle) } } - if strings.Contains(publish, `PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`) { - t.Fatal("publish-docker.yml must not pass the license public key through docker build args") + for _, forbidden := range []string{ + "PULSE_LICENSE_PUBLIC_KEY", + "PULSE_UPDATE_SIGNING_KEY", + "pulse_license_public_key", + "pulse_update_signing_key", + } { + if strings.Contains(publish, forbidden) { + t.Fatalf("publish-docker.yml must assemble the verified candidate without release build secrets: %s", forbidden) + } } } diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 24e082c82..134a777ff 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -10,6 +10,35 @@ import ( "gopkg.in/yaml.v3" ) +func TestProviderMSPControlPlaneImageConsumesExactCandidate(t *testing.T) { + dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane")) + if err != nil { + t.Fatalf("read provider MSP control-plane Dockerfile: %v", err) + } + publishBytes, err := os.ReadFile(repoFile(".github", "workflows", "publish-docker.yml")) + if err != nil { + t.Fatalf("read Docker publisher: %v", err) + } + dockerfile := string(dockerfileBytes) + publish := string(publishBytes) + assertContainsAll(t, dockerfile, + "FROM control-plane-runtime-foundation AS control_plane_prebuilt", + "COPY --from=compiled_payload /binaries/pulse-control-plane-linux-${TARGETARCH:-amd64}", + "FROM control-plane-runtime-foundation AS runtime", + ) + assertContainsAll(t, publish, + "Verify exact-candidate container payload", + "target: control_plane_prebuilt", + "compiled_payload=${{ runner.temp }}/release-container-payload/payload/compiled", + ) + assertNotContainsAny(t, publish, + "PULSE_LICENSE_PUBLIC_KEY", + "PULSE_UPDATE_SIGNING_KEY", + "pulse_license_public_key", + "pulse_update_signing_key", + ) +} + func TestProviderMSPDeployComposeIsProviderModeAndStripeFree(t *testing.T) { composePath := repoFile("deploy", "provider-msp", "docker-compose.yml") composeBytes, err := os.ReadFile(composePath) diff --git a/scripts/prepare-release-container-context.sh b/scripts/prepare-release-container-context.sh index 0f4bc9336..78eb83b75 100755 --- a/scripts/prepare-release-container-context.sh +++ b/scripts/prepare-release-container-context.sh @@ -108,4 +108,13 @@ if ! diff -qr --exclude=pulse "${output_dir}/amd64" "${output_dir}/arm64"; then exit 1 fi +# The universal agent/script payload is identical in both server archives. +# Keep one copy plus the arm64 server and VERSION needed by the multi-arch +# runtime target so Actions transfers do not carry the same payload twice. +find "${output_dir}/arm64" -depth -mindepth 1 \ + ! -path "${output_dir}/arm64/bin" \ + ! -path "${output_dir}/arm64/bin/pulse" \ + ! -path "${output_dir}/arm64/VERSION" \ + -delete + echo "Prepared exact-candidate container context at ${output_dir}." diff --git a/scripts/release_build_targets.sh b/scripts/release_build_targets.sh index c7853034d..844b70aec 100755 --- a/scripts/release_build_targets.sh +++ b/scripts/release_build_targets.sh @@ -26,6 +26,11 @@ PULSE_RELEASE_SERVER_TARGETS=( linux-386 ) +PULSE_RELEASE_CONTROL_PLANE_TARGETS=( + linux-amd64 + linux-arm64 +) + pulse_release_target_env() { case "$1" in linux-amd64) printf '%s\n' 'GOOS=linux GOARCH=amd64' ;; @@ -56,6 +61,7 @@ pulse_release_binary_filename() { agent) filename="pulse-agent-${target}" ;; mcp) filename="pulse-mcp-${target}" ;; server) filename="pulse-${target}" ;; + control-plane) filename="pulse-control-plane-${target}" ;; *) echo "Error: unsupported release component: ${component}" >&2 return 1 diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 18bd6288c..54f455cb5 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1561,7 +1561,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"', backfill_workflow) self.assertIn('./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"', backfill_workflow) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", backfill_workflow) - self.assertIn("./scripts/prepare-release-container-context.sh", docker_build) + self.assertIn("./scripts/prepare-release-container-context.sh", candidate_workflow) self.assertIn('test "${actual_server}" = "${expected_server}"', docker_build) self.assertIn('test "${actual_agent}" = "${expected_agent}"', docker_build) self.assertNotIn("secrets.", docker_build) @@ -1713,10 +1713,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("subject-name: docker.io/rcourtman/pulse-agent", publish) self.assertNotIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent", publish) self.assertIn("create-storage-record: false", publish) - self.assertIn("id: license_key_cache", publish) - self.assertIn("PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}", publish) - self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", publish) - self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", publish) + self.assertIn("target: runtime_prebuilt", publish) + self.assertIn("target: control_plane_prebuilt", publish) + self.assertIn("Verify exact-candidate container payload", publish) + self.assertNotIn("PULSE_LICENSE_PUBLIC_KEY", publish) + self.assertNotIn("PULSE_UPDATE_SIGNING_KEY", publish) self.assertNotIn("provenance: false", publish) self.assertIn("validate_artifact_release_line.py", promote) self.assertIn("control_plane.py --branch-for-version", demo)