From 96034f5e10eb601916739437d48a7cce2889a973 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 03:22:29 +0100 Subject: [PATCH] Attest release artifacts and harden image provenance --- .github/workflows/create-release.yml | 24 +++++-- .github/workflows/publish-docker.yml | 48 ++++++++++++-- Dockerfile | 7 ++- .../subsystems/deployment-installability.md | 7 +++ .../installtests/build_release_assets_test.go | 62 +++++++++++++++++++ .../release_promotion_policy_test.py | 19 ++++++ 6 files changed, 155 insertions(+), 12 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4c953a4e78..3c41c6e988 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -283,12 +283,14 @@ jobs: # amd64 only for prereleases (faster), multi-arch for stable releases platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: false # Don't push staging images, just verify build - provenance: false + provenance: mode=max + sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max build-args: | - PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} VERSION=${{ needs.prepare.outputs.tag }} + secrets: | + pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Build Pulse agent image (verify only) uses: docker/build-push-action@v6 @@ -298,12 +300,14 @@ jobs: target: agent_runtime platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: false - provenance: false + provenance: mode=max + sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max build-args: | - PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} VERSION=${{ needs.prepare.outputs.tag }} + secrets: | + pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} helm_smoke: needs: prepare @@ -319,10 +323,13 @@ jobs: version: v3.15.2 - name: Build local Pulse runtime image for Helm smoke + env: + DOCKER_BUILDKIT: 1 + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} run: | docker build \ --target runtime \ - --build-arg PULSE_LICENSE_PUBLIC_KEY="${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}" \ + --secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY \ --build-arg VERSION="${{ needs.prepare.outputs.tag }}" \ -t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \ . @@ -520,6 +527,8 @@ jobs: timeout-minutes: 30 permissions: contents: write + id-token: write + attestations: write outputs: release_id: ${{ steps.create_release.outputs.release_id }} release_url: ${{ steps.create_release.outputs.release_url }} @@ -570,6 +579,11 @@ jobs: ./cmd/pulse/pulse --version fi + - name: Attest release assets + uses: actions/attest@v4 + with: + subject-path: release/* + - name: Prepare release notes id: generate_notes run: | diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index e2150ae1ed..1b90996bf9 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -25,6 +25,8 @@ jobs: permissions: contents: read packages: write + id-token: write + attestations: write steps: - name: Checkout repository @@ -126,17 +128,20 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Pulse server image (multi-arch) + id: build_server_image uses: docker/build-push-action@v6 with: context: . target: runtime platforms: linux/amd64,linux/arm64 push: true - provenance: false + provenance: mode=max + sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache build-args: | - PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} VERSION=${{ steps.version.outputs.tag }} + secrets: | + pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | rcourtman/pulse:${{ steps.version.outputs.tag }} rcourtman/pulse:${{ steps.version.outputs.version }} @@ -145,7 +150,24 @@ jobs: ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.version }} ${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse:latest', github.repository_owner) || '' }} + - name: Attest Pulse server image on Docker Hub + uses: actions/attest@v4 + with: + subject-name: docker.io/rcourtman/pulse + subject-digest: ${{ steps.build_server_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest Pulse server image on GHCR + uses: actions/attest@v4 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/pulse + subject-digest: ${{ steps.build_server_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + - name: Build and push Pulse agent image (multi-arch) + id: build_agent_image uses: docker/build-push-action@v6 with: context: . @@ -153,11 +175,13 @@ jobs: target: agent_runtime platforms: linux/amd64,linux/arm64 push: true - provenance: false + provenance: mode=max + sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache build-args: | - PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} VERSION=${{ steps.version.outputs.tag }} + secrets: | + pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | rcourtman/pulse-agent:${{ steps.version.outputs.tag }} rcourtman/pulse-agent:${{ steps.version.outputs.version }} @@ -166,6 +190,22 @@ jobs: ghcr.io/${{ github.repository_owner }}/pulse-agent:${{ steps.version.outputs.version }} ${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse-agent:latest', github.repository_owner) || '' }} + - name: Attest Pulse agent image on Docker Hub + uses: actions/attest@v4 + with: + subject-name: docker.io/rcourtman/pulse-agent + subject-digest: ${{ steps.build_agent_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest Pulse agent image on GHCR + uses: actions/attest@v4 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent + subject-digest: ${{ steps.build_agent_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + - name: Output image information run: | IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" diff --git a/Dockerfile b/Dockerfile index deff63943e..84fd2d3193 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ # syntax=docker/dockerfile:1.7-labs ARG BUILD_AGENT=1 -ARG PULSE_LICENSE_PUBLIC_KEY # Build stage for frontend (must be built first for embedding) # Force amd64 platform to avoid slow QEMU emulation during multi-arch builds @@ -29,7 +28,6 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \ FROM --platform=linux/amd64 golang:1.25.9-alpine AS backend-builder ARG BUILD_AGENT -ARG PULSE_LICENSE_PUBLIC_KEY ARG VERSION WORKDIR /app @@ -55,10 +53,13 @@ COPY --from=frontend-builder /app/internal/api/frontend-modern/dist ./internal/a # Build the main pulse binary for all target architectures RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ + --mount=type=secret,id=pulse_license_public_key,required=false \ VERSION="${VERSION:-v$(cat VERSION | tr -d '\n')}" && \ BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \ GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \ - SERVER_LDFLAGS="$(if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${PULSE_LICENSE_PUBLIC_KEY}"; else ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"; fi)" && \ + LICENSE_PUBLIC_KEY="" && \ + if [ -f /run/secrets/pulse_license_public_key ]; then LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"; fi && \ + SERVER_LDFLAGS="$(if [ -n "${LICENSE_PUBLIC_KEY}" ]; then ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${LICENSE_PUBLIC_KEY}"; else ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"; fi)" && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -tags release \ -ldflags="${SERVER_LDFLAGS}" \ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 76f0b81d4d..81db379543 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -243,6 +243,13 @@ demo-deploy workflow. Shipped binaries, installable container images, and governed deployment-build workflows must all carry the same build metadata contract rather than depending on whichever local ldflags string happened to be 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. That same dev-runtime dependency-manifest boundary now also owns the maintained Docker engine module floor. `go.mod`, `go.sum`, and `internal/cloudcp/docker/manager.go` must route hosted runtime orchestration diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index b8e7612e2e..5956c63669 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -69,6 +69,8 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `if [ -f release/install.ps1 ]; then`, `gh release upload "${TAG}" release/install.ps1 --clobber`, `gh release upload "${TAG}" release/*.sig --clobber`, + `uses: actions/attest@v4`, + `subject-path: release/*`, `gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate`, `git push origin "refs/tags/${TAG}" --force`, `-F target_commitish="${HEAD_SHA}"`, @@ -82,6 +84,9 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { if !strings.Contains(workflow, `draft: ${{ github.event.inputs.draft_only == 'true' }}`) { t.Fatal("create-release.yml must pass the actual draft_only state into validate-release-assets") } + if strings.Contains(workflow, `provenance: false`) { + t.Fatal("create-release.yml must not disable release-image provenance") + } } func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { @@ -92,6 +97,8 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { dockerfile := string(dockerfileBytes) dockerRequired := []string{ `COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh`, + `--mount=type=secret,id=pulse_license_public_key,required=false`, + `LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"`, `./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"`, `./scripts/release_ldflags.sh agent --version "${VERSION}"`, } @@ -120,6 +127,61 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { } } +func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { + createReleaseBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) + if err != nil { + t.Fatalf("read create-release.yml: %v", err) + } + createRelease := string(createReleaseBytes) + createReleaseRequired := []string{ + `provenance: mode=max`, + `sbom: true`, + `secrets: |`, + `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, + `DOCKER_BUILDKIT: 1`, + `--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY`, + `id-token: write`, + `attestations: write`, + `uses: actions/attest@v4`, + } + for _, needle := range createReleaseRequired { + if !strings.Contains(createRelease, needle) { + t.Fatalf("create-release.yml missing attested secret-safe release build contract: %s", needle) + } + } + if strings.Contains(createRelease, `PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`) { + t.Fatal("create-release.yml must not pass the license public key through docker build args") + } + + publishBytes, err := os.ReadFile(repoFile(".github", "workflows", "publish-docker.yml")) + if err != nil { + t.Fatalf("read publish-docker.yml: %v", err) + } + publish := string(publishBytes) + publishRequired := []string{ + `provenance: mode=max`, + `sbom: true`, + `secrets: |`, + `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, + `subject-name: docker.io/rcourtman/pulse`, + `subject-name: ghcr.io/${{ github.repository_owner }}/pulse`, + `subject-name: docker.io/rcourtman/pulse-agent`, + `subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent`, + `push-to-registry: true`, + `create-storage-record: false`, + `id-token: write`, + `attestations: write`, + } + for _, needle := range publishRequired { + if !strings.Contains(publish, needle) { + 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") + } +} + func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *testing.T) { workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "deploy-demo-server.yml")) if err != nil { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index f75a81b570..2648d86768 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -268,6 +268,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('-F target_commitish="${HEAD_SHA}"', content) self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn('gh release upload "${TAG}" release/*.sig --clobber', content) + self.assertIn("provenance: mode=max", content) + self.assertIn("sbom: true", content) + self.assertIn("id-token: write", content) + self.assertIn("attestations: write", content) + self.assertIn("uses: actions/attest@v4", content) + self.assertIn("subject-path: release/*", content) + self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", content) + self.assertIn("--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY", content) + self.assertNotIn("provenance: false", content) self.assertIn("Derived rollback command:", helper) self.assertIn("./scripts/install.sh --version", helper) self.assertIn("v6 GA date to publish with GA", helper) @@ -330,6 +339,16 @@ class ReleasePromotionPolicyTest(unittest.TestCase): demo_smoke = read("scripts/demo_public_browser_smoke.cjs") runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md") self.assertIn("control_plane.py --branch-for-version", publish) + self.assertIn("provenance: mode=max", publish) + self.assertIn("sbom: true", publish) + self.assertIn("uses: actions/attest@v4", publish) + self.assertIn("subject-name: docker.io/rcourtman/pulse", publish) + self.assertIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse", publish) + self.assertIn("subject-name: docker.io/rcourtman/pulse-agent", publish) + self.assertIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent", publish) + self.assertIn("create-storage-record: false", publish) + self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", publish) + self.assertNotIn("provenance: false", publish) self.assertIn("control_plane.py --branch-for-version", promote) self.assertIn("control_plane.py --branch-for-version", demo) self.assertIn("preview-v6", demo)