diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0a476d09b..319e5f69b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -302,6 +302,102 @@ jobs: PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} VERSION=${{ needs.prepare.outputs.tag }} + helm_smoke: + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.15.2 + + - name: Build local Pulse runtime image for Helm smoke + run: | + docker build \ + --target runtime \ + --build-arg PULSE_LICENSE_PUBLIC_KEY="${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}" \ + --build-arg VERSION="${{ needs.prepare.outputs.tag }}" \ + -t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \ + . + + - name: Helm smoke test with local release-line image + env: + SMOKE_IMAGE_REPOSITORY: pulse-helm-smoke + SMOKE_IMAGE_TAG: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + + cleanup() { + kind delete cluster --name pulse-test >/dev/null 2>&1 || true + } + + diagnose() { + echo "::group::helm status" + helm status pulse || true + echo "::endgroup::" + + echo "::group::kubectl get all" + kubectl get all -A || true + echo "::endgroup::" + + echo "::group::kubectl describe pods" + kubectl describe pods -A || true + echo "::endgroup::" + + echo "::group::pod logs" + pods=$(kubectl get pods -A -o name 2>/dev/null || true) + for pod in $pods; do + echo "### ${pod}" + kubectl logs --all-containers=true --tail=200 "$pod" || true + done + echo "::endgroup::" + + echo "::group::events" + kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true + echo "::endgroup::" + + cleanup + } + + trap 'diagnose' ERR + + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + kind create cluster --name pulse-test --wait 5m + kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test + + helm install pulse deploy/helm/pulse \ + --set persistence.enabled=false \ + --set server.secretEnv.create=true \ + --set server.secretEnv.data.API_TOKENS=test-token \ + --set image.repository="${SMOKE_IMAGE_REPOSITORY}" \ + --set image.tag="${SMOKE_IMAGE_TAG}" \ + --set image.pullPolicy=Never \ + --wait --timeout 5m --debug + + kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=pulse --timeout=180s || (kubectl describe pods -l app.kubernetes.io/name=pulse && exit 1) + kubectl get pods -l app.kubernetes.io/name=pulse + + helm upgrade pulse deploy/helm/pulse \ + --set persistence.enabled=false \ + --set server.secretEnv.create=true \ + --set server.secretEnv.data.API_TOKENS=test-token \ + --set image.repository="${SMOKE_IMAGE_REPOSITORY}" \ + --set image.tag="${SMOKE_IMAGE_TAG}" \ + --set image.pullPolicy=Never \ + --wait --timeout 5m --debug + + trap - ERR + cleanup + + echo "✓ Helm smoke test passed" + # Integration tests - skipped for prereleases (they've been tested in CI) integration_tests: needs: @@ -413,9 +509,10 @@ jobs: - frontend_checks - backend_tests - docker_build + - helm_smoke - integration_tests # Run if integration_tests passed OR was skipped (prereleases) - if: ${{ always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} + if: ${{ always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f877295ab..2a802c67a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -320,6 +320,12 @@ Helm release workflows must derive the owning branch from the target version via must check out either that governed release branch or the validated release tag before touching chart contents, and must never hardcode `main` as the push or package source for prerelease Helm publication. +Pre-publication release proof and post-publication chart publication have +different trust jobs and must stay that way: `.github/workflows/create-release.yml` +must smoke the Helm chart against a locally built release-line image before the +tag is published, while `.github/workflows/helm-pages.yml` must continue +smoking the immutable published tag image so chart publication cannot silently +pass on branch-only fixes that never made it into the released artifact. That same promotion-governance package also owns the dated rehearsal-record materialization path. The public recorder `scripts/release_control/record_rc_to_ga_rehearsal.py` and its internal module diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 1782ae0f6..d22cbf8c5 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -340,12 +340,18 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("does not descend from any matching prerelease tag", publish) self.assertIn("does not descend from any matching prerelease tag", promote) self.assertIn("Refusing cross-line Helm pages release", helm_pages) + self.assertIn("Build local Pulse runtime image for Helm smoke", release_workflow) + self.assertIn('kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test', release_workflow) + self.assertIn('--set image.repository="${SMOKE_IMAGE_REPOSITORY}"', release_workflow) + self.assertIn('--set image.pullPolicy=Never', release_workflow) + self.assertIn("needs.helm_smoke.result == 'success'", release_workflow) self.assertIn('echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"', helm_pages) self.assertIn('git checkout -B "$REQUIRED_BRANCH" "origin/$REQUIRED_BRANCH"', helm_pages) self.assertIn('git pull --rebase origin "$REQUIRED_BRANCH"', helm_pages) self.assertIn('git push origin HEAD:"$REQUIRED_BRANCH"', helm_pages) self.assertNotIn("git pull --rebase origin main", helm_pages) self.assertNotIn("git push origin main", helm_pages) + self.assertNotIn("kind load docker-image", helm_pages) self.assertIn("helm status pulse || true", helm_pages) self.assertIn("kubectl describe pods -A || true", helm_pages) self.assertIn("kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true", helm_pages)