Publish images from exact candidate payloads

This commit is contained in:
rcourtman
2026-08-21 14:17:12 +01:00
parent 97cf30aed6
commit ae317c96bb
11 changed files with 231 additions and 90 deletions
+57 -19
View File
@@ -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
+4 -1
View File
@@ -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:
+33 -35
View File
@@ -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 }}
+9 -2
View File
@@ -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
@@ -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/<owner>/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
+9 -1
View File
@@ -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[@]}"
@@ -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)
}
}
}
@@ -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)
@@ -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}."
+6
View File
@@ -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
@@ -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)