diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 3f6981295..b2b09abe1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -120,6 +120,26 @@ 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, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.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`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.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`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` 3. Add or change root server installer, shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `install.sh`, `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` + The top-level `install.sh` asset published on GitHub Releases must be the + root Pulse SERVER installer (the LXC / systemd / Proxmox VE installer that + accepts `--version vX.Y.Z`, `--rc`, `--stable`, and friends). The rendered + AGENT installer (`scripts/install.sh`) ships only inside release tarballs + at `./scripts/install.sh` and inside Docker images at + `/opt/pulse/scripts/install.sh`, and is served at the running server's + `/install.sh` endpoint; it is intentionally never the top-level GitHub + Releases asset. `internal/updates/adapter_installsh.go`, + `scripts/pulse-auto-update.sh`, and the root `install.sh`'s own + `--rc` / `--stable` / `--version` self-refetch flows all fetch + `releases//install.sh` and execute it via `bash -s -- --version vX.Y.Z`, + and the README quickstart documents the same pattern. Publishing the agent + installer in that slot silently breaks every one of those flows because the + agent installer rejects `--version` as an unknown argument; this drift + shipped across v6 rc.1 → rc.5 (April 12 → May 11, 2026) before being caught. + `scripts/validate-release.sh` must therefore fail the release if the + published `install.sh` does not carry the server-installer banner, does not + handle `--version)` in its argument parser, contains the agent installer + banner string, or does not print the server installer's version-pinning + help line when invoked with `--help`. Deployment bootstrap token behavior remains a deployment-installability trust boundary even when the handler is API-owned. `internal/api/deploy_handlers.go` must preserve server-derived `owner_user_id` lineage on bootstrap tokens and diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b00c1ec94..d2eaef976 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -441,7 +441,13 @@ fi # curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash # instead of pulling from main branch (which may have newer, incompatible changes) echo "Copying install scripts to release directory..." -cp "${RENDERED_INSTALLERS_DIR}/install.sh" "$RELEASE_DIR/install.sh" +# The published install.sh is the Pulse SERVER installer (LXC/systemd/Proxmox VE). +# In-product self-update (internal/updates/adapter_installsh.go), scripts/pulse-auto-update.sh, +# the root install.sh's own --rc/--stable/--version flows, and the README quickstart all +# fetch this asset and run `bash install.sh --version vX.Y.Z`. The rendered AGENT installer +# (scripts/install.sh) ships inside tarballs and Docker images at ./scripts/install.sh and +# is served at the running server's /install.sh endpoint — it is not a GitHub Releases asset. +cp install.sh "$RELEASE_DIR/install.sh" [ -f "${RENDERED_INSTALLERS_DIR}/install.ps1" ] && cp "${RENDERED_INSTALLERS_DIR}/install.ps1" "$RELEASE_DIR/install.ps1" cp scripts/install-docker.sh "$RELEASE_DIR/" cp scripts/pulse-auto-update.sh "$RELEASE_DIR/" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 081ead07f..007b16b18 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -28,7 +28,14 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { `source "${SCRIPT_DIR}/release_asset_common.sh"`, `RENDERED_INSTALLERS_DIR="${BUILD_DIR}/rendered-installers"`, `go run ./scripts/render_installers.go \`, - `cp "${RENDERED_INSTALLERS_DIR}/install.sh" "$RELEASE_DIR/install.sh"`, + // The published install.sh asset is the server installer (root install.sh). + // The rendered AGENT installer is shipped inside tarballs and Docker images + // at ./scripts/install.sh and served at the running server's /install.sh + // endpoint, but is intentionally not a top-level GitHub Releases asset: + // adapter_installsh, pulse-auto-update.sh, the root install.sh's own --rc/ + // --version flows, and the README quickstart all expect releases//install.sh + // to be the server installer that accepts --version vX.Y.Z. + `cp install.sh "$RELEASE_DIR/install.sh"`, `[ -f "${RENDERED_INSTALLERS_DIR}/install.ps1" ] && cp "${RENDERED_INSTALLERS_DIR}/install.ps1" "$RELEASE_DIR/install.ps1"`, `cp "$BUILD_DIR/pulse-agent-linux-amd64" "$RELEASE_DIR/"`, `cp "$BUILD_DIR/pulse-agent-linux-arm64" "$RELEASE_DIR/"`, @@ -42,8 +49,11 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { } } - if strings.Contains(script, `cp install.sh "$RELEASE_DIR/install.sh"`) { - t.Fatal("build-release.sh still copies the legacy root install.sh into release assets") + // Sanity-check the opposite drift: the rendered AGENT installer must NOT be + // the published install.sh asset. Publishing it there shipped a broken LXC + // install + auto-update path across every v6 RC (rc.1 → rc.5). + if strings.Contains(script, `cp "${RENDERED_INSTALLERS_DIR}/install.sh" "$RELEASE_DIR/install.sh"`) { + t.Fatal("build-release.sh must not publish the rendered agent install.sh as the top-level release asset") } requiredScriptWiring := []string{ @@ -264,6 +274,15 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { `Install script endpoints returned required signature headers`, `Download endpoints returned binaries with checksum and signature headers for all platforms/architectures`, `Offline self-heal: download endpoint works with checksum and signature headers without outbound network`, + // Server installer identity guard — see the rc.1 → rc.5 regression where + // the rendered agent installer shipped as the top-level install.sh asset + // for 30 days before anyone noticed. Removing any of these unpins the asset. + `Validating install.sh is the Pulse server installer`, + `grep -qE '^# Pulse Installer Script'`, + `grep -qE '^[[:space:]]*--version\)'`, + `Pulse Unified Agent Installer`, + `bash "$install_sh_path" --help`, + `Install specific version (e.g.`, } for _, needle := range localRequired { if !strings.Contains(localValidator, needle) { diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 4dec79cf3..c761c2c97 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -397,6 +397,41 @@ if [ $missing_count -gt 0 ]; then fi success "All ${#required_assets[@]} required release assets present" +# Validate published install.sh is the Pulse SERVER installer. +# Across v6 rc.1 → rc.5 the rendered AGENT installer was published here by +# mistake, silently breaking the LXC quickstart, the in-product Update Pulse +# button, and the pulse-auto-update.sh systemd timer for 30 days before anyone +# noticed. This guard pins the asset identity so it cannot drift back. +info "Validating install.sh is the Pulse server installer..." +install_sh_path="install.sh" +if [ ! -s "$install_sh_path" ]; then + error "install.sh is missing or empty" + exit 1 +fi +if ! grep -qE '^# Pulse Installer Script' "$install_sh_path"; then + error "install.sh banner does not match the Pulse server installer" + error "If this fires, build-release.sh is publishing the wrong file (likely the agent installer)" + exit 1 +fi +if ! grep -qE '^[[:space:]]*--version\)' "$install_sh_path"; then + error "install.sh is missing the --version arg handler — required by adapter_installsh, pulse-auto-update.sh, and the README quickstart" + exit 1 +fi +if grep -q 'Pulse Unified Agent Installer' "$install_sh_path"; then + error "install.sh is the agent installer, not the server installer — releases must publish the root install.sh" + exit 1 +fi +# Smoke: actually invoke `bash install.sh --help` and confirm it prints the +# server-installer help text. Catches parse-time syntax breakage and confirms +# the script is structurally executable, not just textually correct. +install_help_output=$(bash "$install_sh_path" --help 2>&1 || true) +if ! echo "$install_help_output" | grep -qF "Install specific version (e.g."; then + error "bash install.sh --help did not print the server installer's version-pinning help line" + error "Help output captured: $install_help_output" + exit 1 +fi +success "install.sh is the Pulse server installer (handles --version, --help prints server help)" + # Validate tarball contents section "Validating tarball contents" tar_arches=(linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386)