diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index 927b66178..c5f7b6dc0 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -107,8 +107,12 @@ jobs: - name: Install helm-docs run: | cd /tmp - wget https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_x86_64.tar.gz - tar -xzf helm-docs_1.14.2_Linux_x86_64.tar.gz + HELM_DOCS_VERSION="1.14.2" + HELM_DOCS_ARCHIVE="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" + HELM_DOCS_SHA256="a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc" + wget "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/${HELM_DOCS_ARCHIVE}" + printf '%s %s\n' "$HELM_DOCS_SHA256" "$HELM_DOCS_ARCHIVE" | sha256sum --check -- + tar -xzf "$HELM_DOCS_ARCHIVE" sudo mv helm-docs /usr/local/bin/ helm-docs --version diff --git a/docker-compose.yml b/docker-compose.yml index a09fb93da..217706585 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: pulse: - image: ${PULSE_IMAGE:-rcourtman/pulse:latest} + image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.0} container_name: pulse restart: unless-stopped logging: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 633ad720f..213edddf4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -84,6 +84,8 @@ server-side update execution surfaces. 62. `tests/integration/scripts/relay-mobile-token-helper.go` 63. `tests/integration/tests/helpers.ts` 64. `tests/integration/tests/runtime-defaults.ts` +65. `docker-compose.yml` +66. `scripts/install-docker.sh` ## Shared Boundaries @@ -96,7 +98,7 @@ server-side update execution surfaces. 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` -3. Add or change shell installer, Windows installer, container-agent installer, or auto-update script behavior through `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, and `scripts/pulse-auto-update.sh` +3. Add or change shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` 5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` 6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` @@ -149,6 +151,9 @@ server-side update execution surfaces. in `deploy/helm/pulse/README.md` output must be regenerated from the same chart metadata and release line so published Helm docs, chart version badges, and packaged archive metadata all describe the identical cut. + External helper binaries fetched by governed release workflows are part of + the same supply-chain boundary and must be checksum-verified before they are + executed. 8. Add or change operator-facing hosted tenant runtime canary rollout, batch runtime contract reconciliation, canonical hosted route/public URL generation, or control-plane runtime-registry reconciliation through `cmd/pulse-control-plane/main.go`, `internal/cloudcp/docker/manager.go`, `internal/cloudcp/docker/labels.go`, and `internal/cloudcp/tenant_runtime_rollout.go` 9. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go` @@ -209,6 +214,12 @@ so release automation, packaged chart metadata, and chart-runtime smoke no longer depend on unowned `deploy/helm/pulse/` files while the governed release workflows package and publish those artifacts. +That same lane also owns version-pinned Docker bootstrap defaults. The repo +root `docker-compose.yml` sample and `scripts/install-docker.sh` must default +to the governed `VERSION` cut instead of floating `:latest`, so self-hosted +operators only move to a newer image when they choose a newer explicit tag or +override `PULSE_IMAGE`. + `internal/updates/` is the live deployment and upgrade planner. It owns deployment-type detection, update-plan generation, adapter selection, server update sequencing, and rollback-aware update state for supported Pulse diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index e696ee690..1f8539243 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2389,6 +2389,7 @@ ".github/workflows/release-dry-run.yml", ".github/workflows/update-demo-server.yml", "cmd/pulse-control-plane/main.go", + "docker-compose.yml", "Dockerfile", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", @@ -2419,6 +2420,7 @@ "scripts/hot-dev-bg.sh", "scripts/hot-dev.sh", "scripts/install-container-agent.sh", + "scripts/install-docker.sh", "scripts/install.ps1", "scripts/install.sh", "scripts/pulse-auto-update.sh", @@ -2448,7 +2450,10 @@ "verification": { "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [], + "exact_files": [ + "scripts/installtests/build_release_assets_test.go", + "scripts/installtests/install_docker_sh_test.go" + ], "require_explicit_path_policy_coverage": true, "path_policies": [ { @@ -2589,7 +2594,9 @@ "label": "deployment script runtime proof", "match_prefixes": [], "match_files": [ + "docker-compose.yml", "scripts/install-container-agent.sh", + "scripts/install-docker.sh", "scripts/install.ps1", "scripts/pulse-auto-update.sh" ], diff --git a/scripts/install-docker.sh b/scripts/install-docker.sh index 7281b82d1..10347414c 100755 --- a/scripts/install-docker.sh +++ b/scripts/install-docker.sh @@ -4,8 +4,31 @@ set -euo pipefail +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" DOCKER_IMAGE_REPO="${DOCKER_IMAGE_REPO:-rcourtman/pulse}" -DEFAULT_PULSE_IMAGE="${DOCKER_IMAGE_REPO}:latest" +CANONICAL_DEFAULT_PULSE_VERSION="6.0.0" + +resolve_default_pulse_version() { + if [ -n "${PULSE_IMAGE_VERSION:-}" ]; then + printf '%s' "${PULSE_IMAGE_VERSION}" + return + fi + + for candidate in "${PULSE_VERSION_FILE:-}" "${SCRIPT_DIR}/../VERSION" "${PWD}/VERSION"; do + if [ -n "${candidate}" ] && [ -f "${candidate}" ]; then + local version + version=$(tr -d '\r\n' < "${candidate}" 2>/dev/null || true) + if [ -n "${version}" ]; then + printf '%s' "${version}" + return + fi + fi + done + + printf '%s' "${CANONICAL_DEFAULT_PULSE_VERSION}" +} + +DEFAULT_PULSE_IMAGE="${DOCKER_IMAGE_REPO}:$(resolve_default_pulse_version)" PULSE_IMAGE="${PULSE_IMAGE:-${DEFAULT_PULSE_IMAGE}}" PULSE_PORT="${PULSE_PORT:-7655}" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 90bd6ce88..e77ccacbf 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -199,6 +199,58 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { } } +func TestDeploymentDefaultsPinVersionedImagesAndHelmDocsChecksum(t *testing.T) { + versionBytes, err := os.ReadFile(repoFile("VERSION")) + if err != nil { + t.Fatalf("read VERSION: %v", err) + } + version := strings.TrimSpace(string(versionBytes)) + if version == "" { + t.Fatal("VERSION is empty") + } + + composeBytes, err := os.ReadFile(repoFile("docker-compose.yml")) + if err != nil { + t.Fatalf("read docker-compose.yml: %v", err) + } + compose := string(composeBytes) + if !strings.Contains(compose, "image: ${PULSE_IMAGE:-rcourtman/pulse:"+version+"}") { + t.Fatalf("docker-compose.yml must pin the governed release version:\n%s", compose) + } + if strings.Contains(compose, ":latest") { + t.Fatalf("docker-compose.yml must not default to a floating latest tag:\n%s", compose) + } + + installDockerBytes, err := os.ReadFile(repoFile("scripts", "install-docker.sh")) + if err != nil { + t.Fatalf("read install-docker.sh: %v", err) + } + installDocker := string(installDockerBytes) + if !strings.Contains(installDocker, `CANONICAL_DEFAULT_PULSE_VERSION="`+version+`"`) { + t.Fatalf("install-docker.sh must pin the governed release version:\n%s", installDocker) + } + if strings.Contains(installDocker, ":latest") { + t.Fatalf("install-docker.sh must not default to a floating latest tag:\n%s", installDocker) + } + + helmPagesBytes, err := os.ReadFile(repoFile(".github", "workflows", "helm-pages.yml")) + if err != nil { + t.Fatalf("read helm-pages.yml: %v", err) + } + helmPages := string(helmPagesBytes) + required := []string{ + `HELM_DOCS_VERSION="1.14.2"`, + `HELM_DOCS_ARCHIVE="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz"`, + `HELM_DOCS_SHA256="a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc"`, + `sha256sum --check --`, + } + for _, needle := range required { + if !strings.Contains(helmPages, needle) { + t.Fatalf("helm-pages.yml missing checksum-verified helm-docs install step: %s", needle) + } + } +} + func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *testing.T) { workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "deploy-demo-server.yml")) if err != nil { diff --git a/scripts/installtests/install_docker_sh_test.go b/scripts/installtests/install_docker_sh_test.go index 051696ed9..11829f160 100644 --- a/scripts/installtests/install_docker_sh_test.go +++ b/scripts/installtests/install_docker_sh_test.go @@ -8,8 +8,22 @@ import ( "testing" ) +func currentReleaseVersion(t *testing.T) string { + t.Helper() + content, err := os.ReadFile(repoFile("VERSION")) + if err != nil { + t.Fatalf("read VERSION: %v", err) + } + version := strings.TrimSpace(string(content)) + if version == "" { + t.Fatal("VERSION is empty") + } + return version +} + func TestInstallDockerScriptUsesConfiguredImageRepoDefault(t *testing.T) { workDir := t.TempDir() + version := currentReleaseVersion(t) runInstallDockerScript(t, workDir, "DOCKER_IMAGE_REPO=example/pulse-enterprise") composePath := filepath.Join(workDir, "docker-compose.yml") @@ -18,11 +32,11 @@ func TestInstallDockerScriptUsesConfiguredImageRepoDefault(t *testing.T) { t.Fatalf("read docker-compose.yml: %v", err) } composeText := string(composeContent) - if !strings.Contains(composeText, "image: ${PULSE_IMAGE:-example/pulse-enterprise:latest}") { + if !strings.Contains(composeText, "image: ${PULSE_IMAGE:-example/pulse-enterprise:"+version+"}") { t.Fatalf("docker-compose.yml missing configured image default:\n%s", composeText) } - if strings.Contains(composeText, "rcourtman/pulse:latest") { - t.Fatalf("docker-compose.yml still hardcodes upstream image:\n%s", composeText) + if strings.Contains(composeText, ":latest") { + t.Fatalf("docker-compose.yml must not default to a floating latest tag:\n%s", composeText) } envPath := filepath.Join(workDir, ".env") @@ -31,13 +45,14 @@ func TestInstallDockerScriptUsesConfiguredImageRepoDefault(t *testing.T) { t.Fatalf("read .env: %v", err) } envText := string(envContent) - if !strings.Contains(envText, "PULSE_IMAGE=example/pulse-enterprise:latest") { + if !strings.Contains(envText, "PULSE_IMAGE=example/pulse-enterprise:"+version) { t.Fatalf(".env missing configured image default:\n%s", envText) } } func TestInstallDockerScriptPrefersExplicitPulseImage(t *testing.T) { workDir := t.TempDir() + version := currentReleaseVersion(t) runInstallDockerScript( t, workDir, @@ -51,7 +66,7 @@ func TestInstallDockerScriptPrefersExplicitPulseImage(t *testing.T) { t.Fatalf("read docker-compose.yml: %v", err) } composeText := string(composeContent) - if !strings.Contains(composeText, "image: ${PULSE_IMAGE:-example/pulse-enterprise:latest}") { + if !strings.Contains(composeText, "image: ${PULSE_IMAGE:-example/pulse-enterprise:"+version+"}") { t.Fatalf("docker-compose.yml lost configured default image:\n%s", composeText) } @@ -66,6 +81,22 @@ func TestInstallDockerScriptPrefersExplicitPulseImage(t *testing.T) { } } +func TestRepoDockerComposeDefaultPinsCurrentVersion(t *testing.T) { + version := currentReleaseVersion(t) + content, err := os.ReadFile(repoFile("docker-compose.yml")) + if err != nil { + t.Fatalf("read docker-compose.yml: %v", err) + } + + text := string(content) + if !strings.Contains(text, "image: ${PULSE_IMAGE:-rcourtman/pulse:"+version+"}") { + t.Fatalf("repo docker-compose.yml must pin the current release version:\n%s", text) + } + if strings.Contains(text, ":latest") { + t.Fatalf("repo docker-compose.yml must not default to a floating latest tag:\n%s", text) + } +} + func runInstallDockerScript(t *testing.T, workDir string, envVars ...string) { t.Helper() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 808d3d654..82c499c80 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -402,6 +402,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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.assertIn('HELM_DOCS_VERSION="1.14.2"', helm_pages) + self.assertIn('HELM_DOCS_ARCHIVE="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz"', helm_pages) + self.assertIn( + 'HELM_DOCS_SHA256="a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc"', + helm_pages, + ) + self.assertIn('printf \'%s %s\\n\' "$HELM_DOCS_SHA256" "$HELM_DOCS_ARCHIVE" | sha256sum --check --', 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)