mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
1530 lines
66 KiB
Go
1530 lines
66 KiB
Go
package installtests
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
func TestBuildReleaseUsesV6InstallScripts(t *testing.T) {
|
|
content, err := os.ReadFile(repoFile("scripts", "build-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read build-release.sh: %v", err)
|
|
}
|
|
|
|
script := string(content)
|
|
required := []string{
|
|
`SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`,
|
|
`PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"`,
|
|
`cd "${PULSE_REPO_ROOT}"`,
|
|
`source "${SCRIPT_DIR}/release_asset_common.sh"`,
|
|
`RENDERED_INSTALLERS_DIR="${BUILD_DIR}/rendered-installers"`,
|
|
`go run ./scripts/render_installers.go \`,
|
|
// 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/<tag>/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/"`,
|
|
`cp "$BUILD_DIR/pulse-agent-linux-armv7" "$RELEASE_DIR/"`,
|
|
`cp "$BUILD_DIR/pulse-agent-linux-armv6" "$RELEASE_DIR/"`,
|
|
`cp "$BUILD_DIR/pulse-agent-linux-386" "$RELEASE_DIR/"`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(script, needle) {
|
|
t.Fatalf("build-release.sh missing required release asset copy: %s", needle)
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
`agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${update_ldflags_args[@]}")"`,
|
|
`server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}" "${update_ldflags_args[@]}")"`,
|
|
`release_go_build_args=(-buildvcs=false -trimpath)`,
|
|
`"${release_go_build_args[@]}"`,
|
|
`RELEASE_PACKET_SBOM="pulse-v${VERSION}-release.sbom.spdx.json"`,
|
|
`pulse_release_prepare_signing_state "pulse-installer" "pulse-install"`,
|
|
`trap 'pulse_release_cleanup_signing_state' EXIT`,
|
|
`--installer-ssh-public-key "${PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY}"`,
|
|
`pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}"`,
|
|
`mapfile -t checksum_files < <(pulse_release_collect_checksum_files "${RELEASE_DIR}")`,
|
|
`pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"`,
|
|
}
|
|
for _, needle := range requiredScriptWiring {
|
|
if !strings.Contains(script, needle) {
|
|
t.Fatalf("build-release.sh missing canonical ldflags wiring: %s", needle)
|
|
}
|
|
}
|
|
if builds, cleanBuilds := strings.Count(script, `env $build_env go build \`), strings.Count(script, `"${release_go_build_args[@]}"`); builds != cleanBuilds {
|
|
t.Fatalf("build-release.sh must disable automatic VCS stamping on every release go build: builds=%d clean_builds=%d", builds, cleanBuilds)
|
|
}
|
|
|
|
helperBytes, err := os.ReadFile(repoFile("scripts", "release_asset_common.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read release_asset_common.sh: %v", err)
|
|
}
|
|
helper := string(helperBytes)
|
|
helperRequired := []string{
|
|
`: "${PULSE_SCRIPTS_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"`,
|
|
`: "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}"`,
|
|
`go -C "${PULSE_REPO_ROOT}" run ./scripts/release_update_key.go "$@"`,
|
|
`pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
|
|
`pulse_release_go_run_update_key fingerprint --public-key "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}"`,
|
|
`pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
|
|
`pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
|
|
`pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${absolute_file}"`,
|
|
`PULSE_UPDATE_SIGNING_PUBLIC_KEY`,
|
|
`PULSE_UPDATE_SIGNING_PUBLIC_KEY_FINGERPRINT`,
|
|
`Verified update signing public key fingerprint: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}`,
|
|
`ssh-keygen -q -Y sign`,
|
|
`"${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}"`,
|
|
`if compgen -G "pulse-*.sbom.spdx.json" > /dev/null; then`,
|
|
`find . -maxdepth 1 -type f \( -name '*.sig' -o -name '*.sshsig' \) -delete`,
|
|
}
|
|
for _, needle := range helperRequired {
|
|
if !strings.Contains(helper, needle) {
|
|
t.Fatalf("release_asset_common.sh missing canonical release asset wiring: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func shieldsBadgeMessage(value string) string {
|
|
return strings.ReplaceAll(value, "-", "--")
|
|
}
|
|
|
|
func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
|
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read create-release.yml: %v", err)
|
|
}
|
|
validationContent, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read validate-release-assets.yml: %v", err)
|
|
}
|
|
|
|
workflow := string(content)
|
|
validationWorkflow := string(validationContent)
|
|
required := []string{
|
|
`historical_asset_backfill_only:`,
|
|
`description: 'Repair an already-published release packet in place without rebuilding binaries'`,
|
|
`SYFT_VERSION="1.42.4"`,
|
|
`SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`,
|
|
`SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`,
|
|
`install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft`,
|
|
`release_upload_with_retry "${TAG}" release/*.sbom.spdx.json --clobber`,
|
|
`release/pulse-agent-linux-amd64`,
|
|
`release/pulse-agent-linux-arm64`,
|
|
`release/pulse-agent-linux-armv7`,
|
|
`release/pulse-agent-linux-armv6`,
|
|
`release/pulse-agent-linux-386`,
|
|
`release/pulse-agent-freebsd-amd64`,
|
|
`release/pulse-agent-freebsd-arm64`,
|
|
`release/pulse-agent-windows-amd64.exe`,
|
|
`release/pulse-agent-windows-arm64.exe`,
|
|
`release/pulse-agent-windows-386.exe`,
|
|
`release_upload_with_retry "${TAG}" release/install.sh --clobber`,
|
|
`if [ -f release/install.ps1 ]; then`,
|
|
`release_upload_with_retry "${TAG}" release/install.ps1 --clobber`,
|
|
`release_upload_with_retry "${TAG}" release/*.sig --clobber`,
|
|
`release_upload_with_retry "${TAG}" release/*.sshsig --clobber`,
|
|
`gh release upload "$@"`,
|
|
`gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s`,
|
|
`gh release upload failed after ${max_attempts} attempts`,
|
|
`uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # 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}"`,
|
|
`historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}`,
|
|
`if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}`,
|
|
`if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`,
|
|
`permissions:`,
|
|
`issues: write`,
|
|
`statuses: write`,
|
|
`ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')`,
|
|
`ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty')`,
|
|
`Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}.`,
|
|
`Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}.`,
|
|
`./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"`,
|
|
`./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"`,
|
|
// End-to-end install.sh smoke must run downstream of
|
|
// validate_release_assets on every release that is not a
|
|
// historical asset backfill. Without this wiring the smoke
|
|
// workflow exists but never actually protects a release —
|
|
// exactly the regression class that let rc.1 → rc.5 ship with
|
|
// broken install.sh.
|
|
`uses: ./.github/workflows/install-sh-smoke.yml`,
|
|
`install_sh_smoke:`,
|
|
`needs.validate_release_assets.result == 'success'`,
|
|
`needs.prepare.outputs.historical_asset_backfill_only != 'true'`,
|
|
`repository: ${{ github.repository }}`,
|
|
// Helm chart publish must be called explicitly from create-release
|
|
// because the draft→PATCH(draft=false) publish path does NOT fire
|
|
// the `release: published` webhook (GitHub-documented quirk). v6
|
|
// rc.1 → rc.5 published successfully but never produced a Helm
|
|
// chart on the GitHub Pages index, breaking
|
|
// `helm install pulse pulse/pulse --version 6.0.0-rc.X`.
|
|
`uses: ./.github/workflows/publish-helm-chart.yml`,
|
|
`publish_helm_chart:`,
|
|
`chart_version: ${{ needs.prepare.outputs.version }}`,
|
|
`app_version: ${{ needs.prepare.outputs.version }}`,
|
|
// promote-floating-tags chains off publish-docker via workflow_run,
|
|
// but when publish-docker fails (rc.3 → rc.5 all did) the chain
|
|
// silently doesn't fire and latest/major/minor docker tags stay
|
|
// stale. Defensive workflow_call backup, gated on
|
|
// validate_release_assets succeeding (which waits for the image to
|
|
// be pullable, so the tag points at a real manifest).
|
|
`uses: ./.github/workflows/promote-floating-tags.yml`,
|
|
`promote_floating_tags:`,
|
|
`tag: ${{ needs.prepare.outputs.tag }}`,
|
|
`prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}`,
|
|
// Draft-only mode (draft_only=true input) keeps the release as a
|
|
// draft and skips the publish step. The three workflow_call'd
|
|
// downstreams must skip in that mode too — install-sh-smoke can't
|
|
// reach the /releases/download/<tag>/ URL of a draft (404), and
|
|
// helm publish + tag promotion would advance externally-visible
|
|
// state to a release that hasn't been promoted out of draft yet.
|
|
`needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true'`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(workflow, needle) {
|
|
t.Fatalf("create-release.yml missing required installer upload step: %s", needle)
|
|
}
|
|
}
|
|
|
|
publishedReleaseGuard := `needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true'`
|
|
for _, job := range []string{"install_sh_smoke", "publish_helm_chart", "promote_floating_tags"} {
|
|
block := workflowJobBlock(t, workflow, job)
|
|
if !strings.Contains(block, publishedReleaseGuard) {
|
|
t.Fatalf("create-release.yml job %s must skip historical backfill and draft-only runs before invoking downstream workflow_call", job)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
validationRequired := []string{
|
|
`statuses: write`,
|
|
`curl --fail-with-body --silent --show-error -X POST`,
|
|
`"context": "Release Asset Validation"`,
|
|
`--arg tag "${{ steps.context.outputs.tag }}"`,
|
|
`--arg target_commitish "${{ steps.context.outputs.target_commitish }}"`,
|
|
`{body: $body, tag_name: $tag, target_commitish: $target_commitish}`,
|
|
`{draft: true, tag_name: $tag, target_commitish: $target_commitish}`,
|
|
`Validation release body update detached release tag`,
|
|
`Validation release body update changed target_commitish`,
|
|
}
|
|
for _, needle := range validationRequired {
|
|
if !strings.Contains(validationWorkflow, needle) {
|
|
t.Fatalf("validate-release-assets.yml missing required status publication contract: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentStablePatchReleasePacketTracksInstallMetadata(t *testing.T) {
|
|
version := currentReleaseVersion(t)
|
|
if isPrereleaseVersion(version) {
|
|
t.Skip("current release is a prerelease")
|
|
}
|
|
previous, ok := previousStablePatchVersion(version)
|
|
if !ok {
|
|
t.Skip("current release is not a stable patch release")
|
|
}
|
|
releaseBranch := requiredReleaseBranchForVersion(t, version)
|
|
|
|
releaseNotesPath := repoFile("docs", "releases", "RELEASE_NOTES_v"+version+".md")
|
|
changelogPath := repoFile("docs", "releases", "V6_CHANGELOG_v"+version+".md")
|
|
|
|
assertFileContainsAllNormalized(t, releaseNotesPath,
|
|
"`v"+version+"` is a stable patch release",
|
|
"`v"+previous+"`",
|
|
"Patrol Gemini model readiness",
|
|
"remembered-login submit persistence",
|
|
"Proxmox SMART temperature fallback",
|
|
"direct SATA/SAT disks",
|
|
"paid-runtime activation continuity",
|
|
"license-period status fixes",
|
|
"rollback target for this patch release is `v"+previous+"`",
|
|
)
|
|
assertFileContainsAll(t, changelogPath,
|
|
"Version: `v"+version+"`",
|
|
"Rollback target: `v"+previous+"`",
|
|
"Promotion path: stable patch hotfix from `"+releaseBranch+"`",
|
|
"Gemini tool-call capability",
|
|
"Remembered-login state now persists",
|
|
"Proxmox SMART temperature collection",
|
|
"explicit SAT probe",
|
|
"Paid runtime activation now writes and reuses a durable installation fingerprint",
|
|
"License grants now include the billing `current_period_end`",
|
|
"Server installer execution now rejects unsafe piped invocation",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docs", "RELEASE_NOTES.md"),
|
|
"docs/releases/RELEASE_NOTES_v"+version+".md",
|
|
"docs/releases/V6_CHANGELOG_v"+version+".md",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docs", "UPGRADE_v6.md"),
|
|
"docs/releases/RELEASE_NOTES_v"+version+".md",
|
|
"docs/releases/V6_CHANGELOG_v"+version+".md",
|
|
)
|
|
assertFileContainsAll(t, repoFile("deploy", "helm", "pulse", "Chart.yaml"),
|
|
"version: "+version,
|
|
`appVersion: "`+version+`"`,
|
|
"raw.githubusercontent.com/rcourtman/Pulse/v"+version+"/docs/images/pulse-logo.svg",
|
|
"blob/v"+version+"/docs/KUBERNETES.md",
|
|
)
|
|
assertFileContainsAll(t, repoFile("deploy", "helm", "pulse", "README.md"),
|
|
"Version-"+version+"-informational",
|
|
"AppVersion-"+version+"-informational",
|
|
"Autogenerated from chart metadata using [helm-docs v1.14.2]",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docker-compose.yml"),
|
|
"image: ${PULSE_IMAGE:-rcourtman/pulse:"+version+"}",
|
|
)
|
|
assertFileContainsAll(t, repoFile("scripts", "install-docker.sh"),
|
|
`CANONICAL_DEFAULT_PULSE_VERSION="`+version+`"`,
|
|
)
|
|
assertFileContainsAllNormalized(t, repoFile("docs", "release-control", "v6", "internal", "subsystems", "deployment-installability.md"),
|
|
"The active stable `v"+version+"` cut sets the repo-root `VERSION`, repo-root `docker-compose.yml` image default, `scripts/install-docker.sh` fallback, and Helm chart release metadata to the same `"+version+"` release version.",
|
|
"This patch release uses the stable hotfix path with `rollback_version=v"+previous+"`, `hotfix_exception=true`, a release-owner reason, and no fabricated same-version RC tag.",
|
|
"For the active stable `v"+version+"` cut, the repo-root compose default and `scripts/install-docker.sh` fallback must both pin `"+version+"`",
|
|
)
|
|
}
|
|
|
|
func TestCurrentSupportPrereleasePacketTracksInstallMetadata(t *testing.T) {
|
|
version := currentReleaseVersion(t)
|
|
if !isPrereleaseVersion(version) {
|
|
t.Skip("current release is stable")
|
|
}
|
|
previous, ok := previousStableForPrereleaseVersion(version)
|
|
if !ok {
|
|
t.Skip("current prerelease does not have a previous stable patch")
|
|
}
|
|
|
|
releaseNotesPath := repoFile("docs", "releases", "RELEASE_NOTES_v"+version+".md")
|
|
changelogPath := repoFile("docs", "releases", "V6_CHANGELOG_v"+version+".md")
|
|
|
|
assertFileContainsAllNormalized(t, releaseNotesPath,
|
|
"`v"+version+"` is a release candidate",
|
|
"stable `v"+previous+"`",
|
|
"Patrol Gemini model readiness",
|
|
"remembered-login submit persistence",
|
|
"Proxmox SMART temperature fallback",
|
|
"direct SATA/SAT disks",
|
|
"legacy agent update token recovery",
|
|
"temperature display severity",
|
|
"PBS backup polling",
|
|
"Proxmox token preservation",
|
|
"legacy OIDC SSO discovery",
|
|
"Pulse Mobile pairing",
|
|
"SSO browser-session display labels",
|
|
"route-aware Proxmox host URLs",
|
|
"MSP report scheduling",
|
|
"portal alert rollup",
|
|
"per-disk-type temperature thresholds",
|
|
"PBS backup discovery recovery",
|
|
"paid-runtime activation continuity fixes",
|
|
"license-period status corrections",
|
|
"installer hardening",
|
|
"rollback target for this release candidate is `v"+previous+"`",
|
|
)
|
|
assertFileContainsAll(t, changelogPath,
|
|
"Version: `v"+version+"`",
|
|
"Rollback target: `v"+previous+"`",
|
|
"Promotion path: release candidate from `main`",
|
|
"Gemini tool-call capability",
|
|
"Remembered-login state now persists",
|
|
"Proxmox SMART temperature collection",
|
|
"explicit SAT probe",
|
|
"Legacy agent update token recovery",
|
|
"Temperature displays now resolve configured alert thresholds",
|
|
"PBS backup polling now keeps a bounded read-state working set",
|
|
"Physical disk SMART and Proxmox records now merge",
|
|
"Proxmox install/update flows now preserve generated token state",
|
|
"Legacy OIDC SSO discovery now persists",
|
|
"Pulse Mobile pairing now omits unsafe non-HTTPS Pulse web handoff URLs",
|
|
"OIDC and SAML browser sessions now expose a separate display label",
|
|
"Proxmox host setup now prefers route-aware host URLs",
|
|
"MSP reports now support scheduled delivery",
|
|
"per-workspace alert rollup",
|
|
"Physical disk temperature thresholds are now configurable per disk type",
|
|
"PBS backup discovery now derives its memory bounds from real snapshot data",
|
|
"Paid runtime activation now writes and reuses a durable installation fingerprint",
|
|
"License grants now include the billing `current_period_end`",
|
|
"Server installer execution now rejects unsafe piped invocation",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docs", "RELEASE_NOTES.md"),
|
|
"docs/releases/RELEASE_NOTES_v"+version+".md",
|
|
"docs/releases/V6_CHANGELOG_v"+version+".md",
|
|
"current v6 support release candidate packet",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docs", "UPGRADE_v6.md"),
|
|
"docs/releases/RELEASE_NOTES_v"+version+".md",
|
|
"docs/releases/V6_CHANGELOG_v"+version+".md",
|
|
"current v6 support release candidate packet",
|
|
)
|
|
assertFileContainsAll(t, repoFile("deploy", "helm", "pulse", "Chart.yaml"),
|
|
"version: "+version,
|
|
`appVersion: "`+version+`"`,
|
|
"raw.githubusercontent.com/rcourtman/Pulse/v"+version+"/docs/images/pulse-logo.svg",
|
|
"blob/v"+version+"/docs/KUBERNETES.md",
|
|
)
|
|
assertFileContainsAll(t, repoFile("deploy", "helm", "pulse", "README.md"),
|
|
"Version-"+shieldsBadgeMessage(version)+"-informational",
|
|
"AppVersion-"+shieldsBadgeMessage(version)+"-informational",
|
|
"Autogenerated from chart metadata using [helm-docs v1.14.2]",
|
|
)
|
|
assertFileContainsAll(t, repoFile("docker-compose.yml"),
|
|
"image: ${PULSE_IMAGE:-rcourtman/pulse:"+version+"}",
|
|
)
|
|
assertFileContainsAll(t, repoFile("scripts", "install-docker.sh"),
|
|
`CANONICAL_DEFAULT_PULSE_VERSION="`+version+`"`,
|
|
)
|
|
assertFileContainsAllNormalized(t, repoFile("docs", "release-control", "v6", "internal", "subsystems", "deployment-installability.md"),
|
|
"The active support prerelease `v"+version+"` cut sets the repo-root `VERSION`, repo-root `docker-compose.yml` image default, `scripts/install-docker.sh` fallback, and Helm chart release metadata to the same `"+version+"` release version.",
|
|
"This support prerelease keeps `rollback_version=v"+previous+"`, publishes a versioned public GitHub prerelease plus versioned Docker and Helm artifacts, and does not move stable/latest install pointers or stable semver aliases.",
|
|
"For the active support prerelease `v"+version+"` cut, the repo-root compose default and `scripts/install-docker.sh` fallback must both pin `"+version+"` until the next governed stable cut moves them forward.",
|
|
)
|
|
}
|
|
|
|
func TestBackfillReleaseWorkflowRepairsPublishedAssetsWithoutRebuilds(t *testing.T) {
|
|
scriptBytes, err := os.ReadFile(repoFile("scripts", "backfill-release-assets.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read backfill-release-assets.sh: %v", err)
|
|
}
|
|
script := string(scriptBytes)
|
|
scriptRequired := []string{
|
|
`SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`,
|
|
`PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"`,
|
|
`cd "${PULSE_REPO_ROOT}"`,
|
|
`source "${SCRIPT_DIR}/release_asset_common.sh"`,
|
|
`gh release view "${TAG}" -R "${REPO}" --json isDraft,tagName`,
|
|
`Error: ${TAG} is still a draft release; use the normal release pipeline instead of historical backfill.`,
|
|
`gh release download "${TAG}" -R "${REPO}" --dir "${RELEASE_DIR}" --clobber`,
|
|
`pulse_release_prepare_signing_state "pulse-installer" "pulse-install"`,
|
|
`pulse_release_generate_packet_sbom "${PAYLOAD_DIR}" "${RELEASE_PACKET_SBOM}"`,
|
|
`pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"`,
|
|
`gh release upload "${TAG}" "${RELEASE_DIR}/checksums.txt" --clobber`,
|
|
`gh release upload "${TAG}" "${RELEASE_DIR}"/*.sha256 --clobber`,
|
|
`gh release upload "${TAG}" "${RELEASE_DIR}"/*.sig --clobber`,
|
|
`gh release upload "${TAG}" "${RELEASE_DIR}"/*.sshsig --clobber`,
|
|
`gh release upload "${TAG}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" --clobber`,
|
|
}
|
|
for _, needle := range scriptRequired {
|
|
if !strings.Contains(script, needle) {
|
|
t.Fatalf("backfill-release-assets.sh missing required historical backfill step: %s", needle)
|
|
}
|
|
}
|
|
|
|
workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "backfill-release-assets.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read backfill-release-assets.yml: %v", err)
|
|
}
|
|
workflow := string(workflowBytes)
|
|
workflowRequired := []string{
|
|
`name: Backfill Release Assets`,
|
|
`workflow_dispatch:`,
|
|
`contents: write`,
|
|
`runs-on: ubuntu-24.04`,
|
|
`uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3`,
|
|
`uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0`,
|
|
`SYFT_VERSION="1.42.4"`,
|
|
`SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`,
|
|
`SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`,
|
|
`./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"`,
|
|
`PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`,
|
|
`PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`,
|
|
`./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"`,
|
|
}
|
|
for _, needle := range workflowRequired {
|
|
if !strings.Contains(workflow, needle) {
|
|
t.Fatalf("backfill-release-assets.yml missing required release-repair step: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseValidationRequiresSignedSidecars(t *testing.T) {
|
|
localValidatorBytes, err := os.ReadFile(repoFile("scripts", "validate-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read validate-release.sh: %v", err)
|
|
}
|
|
localValidator := string(localValidatorBytes)
|
|
localRequired := []string{
|
|
`"pulse-v${PULSE_VERSION}-release.sbom.spdx.json"`,
|
|
`release_sbom="pulse-${PULSE_TAG}-release.sbom.spdx.json"`,
|
|
`error "checksums.txt is missing ${release_sbom}"`,
|
|
`success "Release SBOM is listed in checksums.txt"`,
|
|
`info "Validating SSH signature sidecars..."`,
|
|
`if [ ! -s "checksums.txt.sshsig" ]; then`,
|
|
`error "Missing or empty checksums.txt.sshsig"`,
|
|
`if [ ! -s "${filename}.sshsig" ]; then`,
|
|
`error "Missing or empty ${filename}.sshsig"`,
|
|
`success "SSH signature sidecars validated"`,
|
|
`validate_download_binary_headers() {`,
|
|
`http_header_value "X-Checksum-Sha256"`,
|
|
`http_header_value "X-Signature-Ed25519"`,
|
|
`http_header_value "X-Signature-SSHSIG"`,
|
|
`url="http://127.0.0.1:${HOST_PORT}/${script_name}"`,
|
|
`^# Pulse Unified Agent Installer`,
|
|
`--token-file`,
|
|
`TokenFile`,
|
|
`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.`,
|
|
// README key drift guard — across v6 rc.2 → rc.5 the README pinned a
|
|
// stale ed25519 key that did not verify install.sh.sshsig, so anyone
|
|
// following the secure-install path saw "Could not verify signature".
|
|
// validate-release.sh must extract the README's pinned key and actually
|
|
// run ssh-keygen -Y verify against the signed installer.
|
|
`Validating README pinned signature key matches install.sh.sshsig`,
|
|
`grep -oE "ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer" "$readme_path"`,
|
|
`ssh-keygen -Y verify \`,
|
|
`README's pinned signature key does not verify install.sh.sshsig`,
|
|
}
|
|
|
|
readmeBytes, err := os.ReadFile(repoFile("README.md"))
|
|
if err != nil {
|
|
t.Fatalf("read README.md: %v", err)
|
|
}
|
|
readme := string(readmeBytes)
|
|
// Lock in the actual signing key documented to customers. This is the public
|
|
// counterpart of PULSE_UPDATE_SIGNING_KEY and matches what install.sh and
|
|
// scripts/pulse-auto-update.sh have embedded. A future edit cannot silently
|
|
// regress to the stale Ds21c5 key without tripping this assertion.
|
|
const correctReadmeKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"
|
|
if !strings.Contains(readme, correctReadmeKey) {
|
|
t.Fatalf("README.md must pin the correct pulse-installer ed25519 key for install.sh signature verification")
|
|
}
|
|
const staleReadmeKey = "Ds21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV"
|
|
if strings.Contains(readme, staleReadmeKey) {
|
|
t.Fatalf("README.md still references the stale pulse-installer key Ds21c5...; rc.2 → rc.5 shipped this drift")
|
|
}
|
|
|
|
installDocsBytes, err := os.ReadFile(repoFile("docs", "INSTALL.md"))
|
|
if err != nil {
|
|
t.Fatalf("read docs/INSTALL.md: %v", err)
|
|
}
|
|
installDocs := string(installDocsBytes)
|
|
if !strings.Contains(installDocs, correctReadmeKey) {
|
|
t.Fatalf("docs/INSTALL.md must pin the correct pulse-installer ed25519 key")
|
|
}
|
|
if strings.Contains(installDocs, staleReadmeKey) {
|
|
t.Fatalf("docs/INSTALL.md still references the stale pulse-installer key Ds21c5...")
|
|
}
|
|
for _, needle := range localRequired {
|
|
if !strings.Contains(localValidator, needle) {
|
|
t.Fatalf("validate-release.sh missing signed sidecar validation: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(localValidator, `url="http://127.0.0.1:${HOST_PORT}/download/${script_name}"`) {
|
|
t.Fatal("validate-release.sh must smoke-test /install.sh and /install.ps1, not non-existent /download/install.* routes")
|
|
}
|
|
|
|
publishedValidatorBytes, err := os.ReadFile(repoFile("scripts", "validate-published-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read validate-published-release.sh: %v", err)
|
|
}
|
|
publishedValidator := string(publishedValidatorBytes)
|
|
publishedRequired := []string{
|
|
`RELEASE_SBOM="pulse-${TAG}-release.sbom.spdx.json"`,
|
|
`echo "Failed to download ${RELEASE_SBOM} for ${TAG}" >&2`,
|
|
`echo "${RELEASE_SBOM} is empty for ${TAG}" >&2`,
|
|
`CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig"`,
|
|
`"${BASE_URL}/checksums.txt.sshsig"`,
|
|
`echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2`,
|
|
`sshsig_path="${TMP_DIR}/${filename}.sshsig"`,
|
|
`"${artifact_url}.sshsig"`,
|
|
`echo "Failed to download ${filename}.sshsig" >&2`,
|
|
`Published release assets for ${TAG} match checksums.txt, *.sha256 files, and required *.sshsig sidecars.`,
|
|
}
|
|
for _, needle := range publishedRequired {
|
|
if !strings.Contains(publishedValidator, needle) {
|
|
t.Fatalf("validate-published-release.sh missing signed sidecar validation: %s", needle)
|
|
}
|
|
}
|
|
|
|
contractBytes, err := os.ReadFile(repoFile("docs", "release-control", "v6", "internal", "subsystems", "deployment-installability.md"))
|
|
if err != nil {
|
|
t.Fatalf("read deployment-installability contract: %v", err)
|
|
}
|
|
contract := string(contractBytes)
|
|
contractRequired := []string{
|
|
"`scripts/validate-release.sh`",
|
|
"`scripts/validate-published-release.sh`",
|
|
"`scripts/backfill-release-assets.sh`",
|
|
"`.github/workflows/backfill-release-assets.yml`",
|
|
"`scripts/validate-release.sh`, and",
|
|
"`scripts/release_asset_common.sh`",
|
|
"must derive the embedded update trust root",
|
|
"standalone SPDX JSON SBOM",
|
|
"already-published packet",
|
|
"derived integrity assets",
|
|
"and fail validation if",
|
|
"published artifact or",
|
|
"`checksums.txt` is missing its `.sshsig` sidecar",
|
|
"release-packet SBOM is absent",
|
|
"download endpoints must return checksum and signature headers",
|
|
"must disable Go's automatic VCS stamping",
|
|
"`-buildvcs=false`",
|
|
}
|
|
for _, needle := range contractRequired {
|
|
if !strings.Contains(contract, needle) {
|
|
t.Fatalf("deployment-installability contract missing signed sidecar validation requirement: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
|
|
if err != nil {
|
|
t.Fatalf("read Dockerfile: %v", err)
|
|
}
|
|
dockerfile := string(dockerfileBytes)
|
|
dockerRequired := []string{
|
|
`FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder`,
|
|
`FROM --platform=linux/amd64 golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS backend-builder`,
|
|
`FROM backend-builder AS release-assets-builder`,
|
|
`FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS agent_runtime`,
|
|
`FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-base`,
|
|
`FROM pulse-runtime-base AS hosted_runtime`,
|
|
`FROM pulse-runtime-base AS runtime`,
|
|
`COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh`,
|
|
`COPY scripts/release_update_key.go ./scripts/release_update_key.go`,
|
|
`COPY scripts/render_installers.go ./scripts/render_installers.go`,
|
|
`ARG PULSE_LICENSE_PUBLIC_KEY_SHA256`,
|
|
`--mount=type=secret,id=pulse_license_public_key,required=false`,
|
|
`--mount=type=secret,id=pulse_update_signing_key,required=false`,
|
|
`ARG PULSE_UPDATE_SIGNING_PUBLIC_KEY`,
|
|
`LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"`,
|
|
`EXPECTED_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256#SHA256:}"`,
|
|
`mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256.`,
|
|
`UPDATE_PUBLIC_KEYS="$(go run ./scripts/release_update_key.go public-key --private-key "${UPDATE_SIGNING_KEY}")"`,
|
|
`mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY.`,
|
|
`./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"`,
|
|
`./scripts/release_ldflags.sh agent --version "${VERSION}"`,
|
|
`-buildvcs=false`,
|
|
`go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers`,
|
|
`--allow-empty-installer-ssh-public-key`,
|
|
`ssh-keygen -q -Y sign -f "${OPENSSH_SIGNING_KEY}" -n pulse-install`,
|
|
`COPY --from=release-assets-builder /app/rendered-installers/install.sh /opt/pulse/scripts/install.sh`,
|
|
`COPY --from=release-assets-builder /app/pulse-agent-* /opt/pulse/bin/`,
|
|
}
|
|
for _, needle := range dockerRequired {
|
|
if !strings.Contains(dockerfile, needle) {
|
|
t.Fatalf("Dockerfile missing canonical release ldflags usage: %s", needle)
|
|
}
|
|
}
|
|
hostedStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS hosted_runtime`)
|
|
runtimeStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS runtime`)
|
|
if hostedStart == -1 || runtimeStart == -1 || hostedStart > runtimeStart {
|
|
t.Fatal("Dockerfile must define hosted_runtime from pulse-runtime-base before the full runtime stage")
|
|
}
|
|
hostedStage := dockerfile[hostedStart:runtimeStart]
|
|
if strings.Contains(hostedStage, "rendered-installers") || strings.Contains(hostedStage, "/opt/pulse/bin") {
|
|
t.Fatalf("hosted_runtime target must not depend on installer rendering or embedded agent artifacts:\n%s", hostedStage)
|
|
}
|
|
if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:20-alpine AS frontend-builder`) ||
|
|
strings.Contains(dockerfile, `FROM --platform=linux/amd64 golang:1.25.11-alpine AS backend-builder`) ||
|
|
strings.Contains(dockerfile, `FROM alpine:3.20 AS agent_runtime`) ||
|
|
strings.Contains(dockerfile, `FROM alpine:3.20 AS pulse-runtime-base`) {
|
|
t.Fatal("Dockerfile base images must be pinned by immutable @sha256 digests")
|
|
}
|
|
if builds, cleanBuilds := strings.Count(dockerfile, " go build \\"), strings.Count(dockerfile, "-buildvcs=false"); builds != cleanBuilds {
|
|
t.Fatalf("Dockerfile release go builds must all disable automatic VCS stamping: builds=%d clean_builds=%d", builds, cleanBuilds)
|
|
}
|
|
|
|
workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "deploy-demo-server.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read deploy-demo-server workflow: %v", err)
|
|
}
|
|
workflow := string(workflowBytes)
|
|
workflowRequired := []string{
|
|
`./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"`,
|
|
`-buildvcs=false`,
|
|
`demo-stable`,
|
|
`workflow_dispatch:`,
|
|
`target:`,
|
|
}
|
|
for _, needle := range workflowRequired {
|
|
if !strings.Contains(workflow, needle) {
|
|
t.Fatalf("deploy-demo-server workflow missing canonical release ldflags usage: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(workflow, `preview-v6`) || strings.Contains(workflow, `demo-preview-v6`) {
|
|
t.Fatal("deploy-demo-server workflow must not keep a separate v6 preview demo target after GA")
|
|
}
|
|
}
|
|
|
|
func TestAgentRuntimeImagePersistsAgentIdentityByDefault(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
|
|
if err != nil {
|
|
t.Fatalf("read Dockerfile: %v", err)
|
|
}
|
|
dockerfile := string(dockerfileBytes)
|
|
|
|
required := []string{
|
|
`mkdir -p /var/lib/pulse-agent`,
|
|
`PULSE_DISABLE_AUTO_UPDATE=true`,
|
|
`PULSE_ENABLE_HOST=false`,
|
|
`PULSE_ENABLE_DOCKER=true`,
|
|
`PULSE_AGENT_ID_FILE=/var/lib/pulse-agent/agent-id`,
|
|
`PULSE_STATE_DIR=/var/lib/pulse-agent`,
|
|
`VOLUME ["/var/lib/pulse-agent"]`,
|
|
`ENTRYPOINT ["/usr/local/bin/pulse-agent"]`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(dockerfile, needle) {
|
|
t.Fatalf("Dockerfile agent_runtime missing persistent identity contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(dockerfile, `ENTRYPOINT ["/usr/local/bin/pulse-agent", "--enable-docker", "--enable-host=false"]`) {
|
|
t.Fatal("agent_runtime must not hard-code module flags in ENTRYPOINT; env defaults keep user args overridable")
|
|
}
|
|
}
|
|
|
|
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: |`,
|
|
`id: license_key_cache`,
|
|
`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 }}`,
|
|
`PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`,
|
|
`Validate installer signing key pins`,
|
|
`go run ./scripts/release_update_key.go public-key-ssh`,
|
|
`install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh`,
|
|
`does not trust the configured release signing key.`,
|
|
`DOCKER_BUILDKIT: 1`,
|
|
`--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY`,
|
|
`--secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY`,
|
|
`--build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}"`,
|
|
`--build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"`,
|
|
`id-token: write`,
|
|
`attestations: write`,
|
|
`uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # 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: |`,
|
|
`id: license_key_cache`,
|
|
`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 }}`,
|
|
`subject-name: docker.io/rcourtman/pulse`,
|
|
`subject-name: ghcr.io/${{ github.repository_owner }}/pulse`,
|
|
`subject-name: docker.io/rcourtman/pulse-control-plane`,
|
|
`subject-name: ghcr.io/${{ github.repository_owner }}/pulse-control-plane`,
|
|
`rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}`,
|
|
`ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.tag }}`,
|
|
// pulse-agent ships as release-asset binaries, not as a Docker
|
|
// image (see commit dropping the agent image publish steps).
|
|
// The agent attestation subject-names intentionally do not
|
|
// appear in publish-docker.yml.
|
|
`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 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)
|
|
}
|
|
|
|
chartBytes, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "Chart.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("read Helm Chart.yaml: %v", err)
|
|
}
|
|
chart := string(chartBytes)
|
|
chartRequired := []string{
|
|
"version: " + version,
|
|
`appVersion: "` + version + `"`,
|
|
"https://raw.githubusercontent.com/rcourtman/Pulse/v" + version + "/docs/images/pulse-logo.svg",
|
|
"https://github.com/rcourtman/Pulse/blob/v" + version + "/docs/KUBERNETES.md",
|
|
}
|
|
for _, needle := range chartRequired {
|
|
if !strings.Contains(chart, needle) {
|
|
t.Fatalf("Helm Chart.yaml must pin the governed release version, missing %s:\n%s", needle, chart)
|
|
}
|
|
}
|
|
if previous, ok := previousStablePatchVersion(version); ok && strings.Contains(chart, "v"+previous) {
|
|
t.Fatalf("Helm Chart.yaml must not retain the previous stable patch tag v%s:\n%s", previous, chart)
|
|
}
|
|
if previous, ok := previousPrereleaseVersion(version); ok && strings.Contains(chart, "v"+previous) {
|
|
t.Fatalf("Helm Chart.yaml must not retain the previous prerelease tag v%s:\n%s", previous, chart)
|
|
}
|
|
|
|
chartReadmeBytes, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "README.md"))
|
|
if err != nil {
|
|
t.Fatalf("read Helm README.md: %v", err)
|
|
}
|
|
chartReadme := string(chartReadmeBytes)
|
|
badgeVersion := shieldsBadgeMessage(version)
|
|
chartReadmeRequired := []string{
|
|
"",
|
|
"",
|
|
}
|
|
for _, needle := range chartReadmeRequired {
|
|
if !strings.Contains(chartReadme, needle) {
|
|
t.Fatalf("Helm README.md must reflect the governed release version, missing %s:\n%s", needle, chartReadme)
|
|
}
|
|
}
|
|
if previous, ok := previousStablePatchVersion(version); ok && strings.Contains(chartReadme, previous) {
|
|
t.Fatalf("Helm README.md must not retain the previous stable patch version %s:\n%s", previous, chartReadme)
|
|
}
|
|
if previous, ok := previousPrereleaseVersion(version); ok && strings.Contains(chartReadme, previous) {
|
|
t.Fatalf("Helm README.md must not retain the previous prerelease version %s:\n%s", previous, chartReadme)
|
|
}
|
|
|
|
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 --`,
|
|
`name: Ensure chart release and pages index`,
|
|
`gh release create "${CHART_RELEASE}" "${CHART_PATH}"`,
|
|
`helm repo index "${index_work}"`,
|
|
`git -C "${workdir}/gh-pages" push origin HEAD:gh-pages`,
|
|
`grep -q "version: ${VERSION}"`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(helmPages, needle) {
|
|
t.Fatalf("helm-pages.yml missing checksum-verified helm-docs install step: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHelmChartDoesNotPublishRetiredExplorePrepassMonitoring(t *testing.T) {
|
|
chartDir := repoFile("deploy", "helm", "pulse")
|
|
err := filepath.WalkDir(chartDir, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
switch filepath.Ext(path) {
|
|
case ".yaml", ".json", ".md":
|
|
default:
|
|
return nil
|
|
}
|
|
content, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
text := string(content)
|
|
for _, forbidden := range []string{
|
|
"prometheusRule",
|
|
"pulse_ai_explore",
|
|
"Explore pre-pass",
|
|
"explore_runs_total",
|
|
} {
|
|
if strings.Contains(text, forbidden) {
|
|
t.Fatalf("helm chart file %s must not publish retired Assistant explore-prepass monitoring %q", path, forbidden)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk helm chart: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeployDemoWorkflowFailsClosedForStableAndVerifiesFrontendParity(t *testing.T) {
|
|
workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "deploy-demo-server.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read deploy-demo-server workflow: %v", err)
|
|
}
|
|
|
|
workflow := string(workflowBytes)
|
|
required := []string{
|
|
`DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }}`,
|
|
`DEMO_LOCAL_BASE_URL: ${{ vars.DEMO_LOCAL_BASE_URL }}`,
|
|
`[ -n "$DEMO_EXPECTED_HOSTNAME" ] || { echo "::error::DEMO_EXPECTED_HOSTNAME is required in the selected demo environment."; exit 1; }`,
|
|
`[ -n "$DEMO_LOCAL_BASE_URL" ] || { echo "::error::DEMO_LOCAL_BASE_URL is required in the selected demo environment."; exit 1; }`,
|
|
`ENVIRONMENT_NAME="demo-stable"`,
|
|
`options:`,
|
|
` - stable`,
|
|
`Capture expected frontend entry asset`,
|
|
`Verify target host identity`,
|
|
`MAX_SSH_SETUP_ATTEMPTS=18`,
|
|
`getent hosts "$DEMO_SERVER_HOST"`,
|
|
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
|
|
`Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`,
|
|
`SERVICE_NAME="pulse"`,
|
|
`Unsupported demo target: ${TARGET}`,
|
|
`Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`,
|
|
`Verify frontend parity`,
|
|
`Verify public browser smoke`,
|
|
`./scripts/run_demo_public_browser_smoke.sh`,
|
|
`extract_entry_asset()`,
|
|
`<script\b[^>]*\bsrc=\"(/assets/index-[^\"]*\.js)\"`,
|
|
`Remote service is serving $REMOTE_ASSET but the build expected $EXPECTED_ASSET.`,
|
|
`Public demo is serving $PUBLIC_ASSET but the build expected $EXPECTED_ASSET.`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(workflow, needle) {
|
|
t.Fatalf("deploy-demo-server workflow missing stable isolation or frontend parity proof: %s", needle)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{`pulse-v6-preview`, `preview-v6`, `demo-preview-v6`} {
|
|
if strings.Contains(workflow, forbidden) {
|
|
t.Fatalf("deploy-demo-server workflow must not retain retired v6 preview target %s", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
|
|
workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read update-demo-server workflow: %v", err)
|
|
}
|
|
|
|
workflow := string(workflowBytes)
|
|
required := []string{
|
|
`- name: Tailscale`,
|
|
`uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2`,
|
|
`oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}`,
|
|
`oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}`,
|
|
`tags: tag:infra`,
|
|
`uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0`,
|
|
`go run ./scripts/release_update_key.go public-key-ssh`,
|
|
`sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`,
|
|
`Verify target host identity`,
|
|
`MAX_SSH_SETUP_ATTEMPTS=18`,
|
|
`getent hosts "$DEMO_SERVER_HOST"`,
|
|
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
|
|
`Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`,
|
|
`Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`,
|
|
`Prepare demo host storage`,
|
|
`KEEP_BACKUPS=2`,
|
|
`Removing demo backup to restore install headroom: %s`,
|
|
`Pruning demo volatile runtime stores to restore install headroom.`,
|
|
`sudo find "$CONFIG_DIR" -xdev -type f`,
|
|
`-name "metrics.db"`,
|
|
`Removing demo volatile store: %s`,
|
|
`Demo host does not have enough free space to back up $CONFIG_DIR before install.`,
|
|
`Restore demo runtime configuration`,
|
|
`resolve_config_dir`,
|
|
`set_env_value DEMO_MODE true`,
|
|
`set_env_value PULSE_MOCK_MODE true`,
|
|
`ensure_demo_fixture_entitlement`,
|
|
`"demo_fixtures"`,
|
|
`del(.integrity)`,
|
|
`Demo fixture entitlement ensured in governed demo billing state.`,
|
|
`/api/license/runtime-capabilities`,
|
|
`Mock mode enabled`,
|
|
`Demo server mock mode did not enable after entitlement sync`,
|
|
`Verify public browser smoke`,
|
|
`./scripts/run_demo_public_browser_smoke.sh`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(workflow, needle) {
|
|
t.Fatalf("update-demo-server workflow missing governed network path: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDemoPublicBrowserSmokeWaitsForVisibleLoginUI(t *testing.T) {
|
|
scriptBytes, err := os.ReadFile(repoFile("scripts", "demo_public_browser_smoke.cjs"))
|
|
if err != nil {
|
|
t.Fatalf("read demo public browser smoke script: %v", err)
|
|
}
|
|
|
|
script := string(scriptBytes)
|
|
required := []string{
|
|
`waitUntil: 'domcontentloaded'`,
|
|
`getByLabel('Username').waitFor({ state: 'visible', timeout: 120000 })`,
|
|
`getByLabel('Password').waitFor({ state: 'visible', timeout: 120000 })`,
|
|
`getByRole('button', { name: 'Sign in to Pulse' }).waitFor({ state: 'visible', timeout: 120000 })`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(script, needle) {
|
|
t.Fatalf("demo public browser smoke missing visible-login readiness proof: %s", needle)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(script, `waitUntil: 'networkidle'`) {
|
|
t.Fatal("demo public browser smoke still depends on networkidle instead of visible login readiness")
|
|
}
|
|
}
|
|
|
|
func TestDockerfileStagesShippedDocsForEmbeddedFrontendBuild(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
|
|
if err != nil {
|
|
t.Fatalf("read Dockerfile: %v", err)
|
|
}
|
|
|
|
dockerfile := string(dockerfileBytes)
|
|
required := []string{
|
|
`COPY docs/ /app/docs/`,
|
|
`COPY SECURITY.md TERMS.md /app/`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(dockerfile, needle) {
|
|
t.Fatalf("Dockerfile missing shipped-doc build input: %s", needle)
|
|
}
|
|
}
|
|
|
|
dockerignoreBytes, err := os.ReadFile(repoFile(".dockerignore"))
|
|
if err != nil {
|
|
t.Fatalf("read .dockerignore: %v", err)
|
|
}
|
|
|
|
dockerignore := string(dockerignoreBytes)
|
|
requiredAllowlist := []string{
|
|
`!docs/`,
|
|
`!docs/**`,
|
|
`!SECURITY.md`,
|
|
`!TERMS.md`,
|
|
}
|
|
for _, needle := range requiredAllowlist {
|
|
if !strings.Contains(dockerignore, needle) {
|
|
t.Fatalf(".dockerignore missing shipped-doc allowlist entry: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseUpdateKeyFingerprintUsesCanonicalRawPublicKeyHash(t *testing.T) {
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate signing key: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("go", "run", "./scripts/release_update_key.go", "fingerprint", "--private-key", base64.StdEncoding.EncodeToString(privateKey))
|
|
cmd.Dir = repoFile()
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("release_update_key.go fingerprint failed: %v\n%s", err, output)
|
|
}
|
|
|
|
sum := sha256.Sum256(publicKey)
|
|
expected := "SHA256:" + base64.StdEncoding.EncodeToString(sum[:])
|
|
if got := strings.TrimSpace(string(output)); got != expected {
|
|
t.Fatalf("fingerprint mismatch: got %q want %q", got, expected)
|
|
}
|
|
}
|
|
|
|
func TestReleaseUpdateKeyPublicKeySSHAcceptsPublicKey(t *testing.T) {
|
|
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate signing key: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("go", "run", "./scripts/release_update_key.go", "public-key-ssh", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--comment", "pulse-installer")
|
|
cmd.Dir = repoFile()
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("release_update_key.go public-key-ssh failed: %v\n%s", err, output)
|
|
}
|
|
|
|
sshPublicKey, err := ssh.NewPublicKey(publicKey)
|
|
if err != nil {
|
|
t.Fatalf("derive SSH public key: %v", err)
|
|
}
|
|
expected := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))) + " pulse-installer"
|
|
if got := strings.TrimSpace(string(output)); got != expected {
|
|
t.Fatalf("SSH public key mismatch: got %q want %q", got, expected)
|
|
}
|
|
}
|
|
|
|
func TestReleaseAssetCommonRunsUpdateKeyThroughModulePath(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not installed")
|
|
}
|
|
if _, err := exec.LookPath("go"); err != nil {
|
|
t.Skip("go not installed")
|
|
}
|
|
|
|
cmd := exec.Command("bash", "-lc", "source ./scripts/release_asset_common.sh; pulse_release_go_run_update_key")
|
|
cmd.Dir = repoFile()
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatalf("expected release_update_key.go usage failure, got success:\n%s", output)
|
|
}
|
|
text := string(output)
|
|
if !strings.Contains(text, "release_update_key.go public-key") {
|
|
t.Fatalf("expected release_update_key.go usage output, got:\n%s", output)
|
|
}
|
|
if strings.Contains(text, "use of internal package") {
|
|
t.Fatalf("release helper invoked update key outside module import boundary:\n%s", output)
|
|
}
|
|
}
|
|
|
|
func TestReleaseAssetCommonRejectsUnexpectedUpdateSigningPublicKey(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not installed")
|
|
}
|
|
if _, err := exec.LookPath("go"); err != nil {
|
|
t.Skip("go not installed")
|
|
}
|
|
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate signing key: %v", err)
|
|
}
|
|
unexpectedPublicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate unexpected public key: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("bash", "-lc", "source ./scripts/release_asset_common.sh; pulse_release_prepare_signing_state pulse-installer pulse-install")
|
|
cmd.Dir = repoFile()
|
|
cmd.Env = append(os.Environ(),
|
|
"PULSE_UPDATE_SIGNING_KEY="+base64.StdEncoding.EncodeToString(privateKey),
|
|
"PULSE_UPDATE_SIGNING_PUBLIC_KEY="+base64.StdEncoding.EncodeToString(unexpectedPublicKey),
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatalf("expected release_asset_common.sh to reject a mismatched signing public key:\n%s", output)
|
|
}
|
|
if !strings.Contains(string(output), "does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY") {
|
|
t.Fatalf("expected mismatched signing public key error, got:\n%s", output)
|
|
}
|
|
}
|
|
|
|
// TestBuildReleasePackagesPulseMcpForAllPlatforms pins the
|
|
// distribution path for pulse-mcp: each Pulse release must build
|
|
// the MCP adapter for the same multi-OS matrix as the unified
|
|
// agent and emit per-platform tarballs/zips, bare binaries (for
|
|
// /releases/latest/download/ redirect compatibility), and the
|
|
// install-mcp.sh script into RELEASE_DIR. Drift in any of those
|
|
// strings means an integrator following the published install
|
|
// path hits a 404 on the release endpoint instead of a working
|
|
// binary.
|
|
func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) {
|
|
content, err := os.ReadFile(repoFile("scripts", "build-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read build-release.sh: %v", err)
|
|
}
|
|
script := string(content)
|
|
|
|
required := []string{
|
|
// Build loop wires through ./cmd/pulse-mcp.
|
|
`-o "$output_path" \
|
|
./cmd/pulse-mcp`,
|
|
// Per-platform packaging follows the pulse-agent shape
|
|
// exactly so the upload step's glob does not need
|
|
// special cases.
|
|
`tar -czf "$RELEASE_DIR/pulse-mcp-v${VERSION}-linux-amd64.tar.gz" -C "$BUILD_DIR" pulse-mcp-linux-amd64`,
|
|
`tar -czf "$RELEASE_DIR/pulse-mcp-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_DIR" pulse-mcp-darwin-arm64`,
|
|
`zip -j "$RELEASE_DIR/pulse-mcp-v${VERSION}-windows-amd64.zip" "$BUILD_DIR/pulse-mcp-windows-amd64.exe"`,
|
|
// Bare-binary copies for the /releases/latest/download/
|
|
// redirect that install-mcp.sh fetches by default.
|
|
`cp "$BUILD_DIR/pulse-mcp-linux-amd64" "$RELEASE_DIR/"`,
|
|
`cp "$BUILD_DIR/pulse-mcp-darwin-amd64" "$RELEASE_DIR/"`,
|
|
`cp "$BUILD_DIR/pulse-mcp-darwin-arm64" "$RELEASE_DIR/"`,
|
|
`cp "$BUILD_DIR/pulse-mcp-windows-amd64.exe" "$RELEASE_DIR/"`,
|
|
// The installer scripts themselves must reach
|
|
// RELEASE_DIR so the GitHub Releases asset upload can
|
|
// publish them as the canonical curl-pipe-bash entry
|
|
// point.
|
|
`cp scripts/install-mcp.sh "$RELEASE_DIR/install-mcp.sh"`,
|
|
`[ -f scripts/install-mcp.ps1 ] && cp scripts/install-mcp.ps1 "$RELEASE_DIR/install-mcp.ps1"`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(script, needle) {
|
|
t.Fatalf("build-release.sh missing pulse-mcp distribution wiring: %s", needle)
|
|
}
|
|
}
|
|
|
|
// install-mcp.sh and install-mcp.ps1 must both exist as
|
|
// shipped scripts; the build pipeline references them, so
|
|
// missing-file drift breaks release builds rather than
|
|
// quietly ships an installer that 404s.
|
|
if _, err := os.Stat(repoFile("scripts", "install-mcp.sh")); err != nil {
|
|
t.Fatalf("scripts/install-mcp.sh missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(repoFile("scripts", "install-mcp.ps1")); err != nil {
|
|
t.Fatalf("scripts/install-mcp.ps1 missing: %v", err)
|
|
}
|
|
|
|
// install-mcp.sh's install-dir resolution and SHA256
|
|
// verification are load-bearing: dropping either silently
|
|
// turns the installer into "curl | bash with no integrity
|
|
// check," which is the failure mode the hook is here to
|
|
// prevent. Pin the touchstones.
|
|
mcpScript, err := os.ReadFile(repoFile("scripts", "install-mcp.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read install-mcp.sh: %v", err)
|
|
}
|
|
for _, needle := range []string{
|
|
`detect_platform()`,
|
|
`choose_install_dir()`,
|
|
`PULSE_MCP_NO_VERIFY`,
|
|
`checksums.txt`,
|
|
`sha256 mismatch`,
|
|
} {
|
|
if !strings.Contains(string(mcpScript), needle) {
|
|
t.Fatalf("install-mcp.sh missing required helper or guard: %s", needle)
|
|
}
|
|
}
|
|
|
|
mcpPowerShell, err := os.ReadFile(repoFile("scripts", "install-mcp.ps1"))
|
|
if err != nil {
|
|
t.Fatalf("read install-mcp.ps1: %v", err)
|
|
}
|
|
for _, needle := range []string{
|
|
`function Resolve-Architecture`,
|
|
`PULSE_MCP_NO_VERIFY`,
|
|
`checksums.txt`,
|
|
`Get-FileHash -Path $tmp -Algorithm SHA256`,
|
|
`sha256 mismatch`,
|
|
} {
|
|
if !strings.Contains(string(mcpPowerShell), needle) {
|
|
t.Fatalf("install-mcp.ps1 missing required helper or guard: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The release-pipeline downstream workflows and private Pro publication path
|
|
// share the same root cause: v6 rc.1 -> rc.6 silently broke because GitHub's
|
|
// `release: published` webhook doesn't fire when create-release.yml's draft ->
|
|
// PATCH(draft=false) promotion path is used, `workflow_run` chains don't fire
|
|
// when their upstream fails, and the private Pro path was left as a manual
|
|
// checklist step. The fix is explicit post-release orchestration after
|
|
// validate_release_assets succeeds. The tests below pin the trigger
|
|
// declarations, resolver logic, and private Pro dispatch contract so the
|
|
// regression class can't return.
|
|
|
|
func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
|
assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"),
|
|
// Inputs and triggers.
|
|
`name: install.sh Smoke (Published Release)`,
|
|
`workflow_call:`,
|
|
`workflow_dispatch:`,
|
|
// Pull straight from the published release URL (not local release/).
|
|
`releases/download/${TAG}`,
|
|
`install.sh.sshsig`,
|
|
`pulse-${TAG}-linux-amd64.tar.gz`,
|
|
// README key extraction + ssh-keygen verify against the asset.
|
|
`grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md`,
|
|
`ssh-keygen -Y verify \`,
|
|
`-I pulse-installer \`,
|
|
`-n pulse-install \`,
|
|
`-s install.sh.sshsig < install.sh`,
|
|
// Server-installer identity assertions, mirroring validate-release.sh.
|
|
`grep -qE '^# Pulse Installer Script' install.sh`,
|
|
`grep -q 'Pulse Unified Agent Installer' install.sh`,
|
|
`grep -qE '^[[:space:]]*--version\)' install.sh`,
|
|
// End-to-end install in a privileged systemd container.
|
|
`jrei/systemd-debian:12`,
|
|
`bash install.sh --archive /smoke/${tarball} --disable-auto-updates`,
|
|
`systemctl is-active pulse`,
|
|
// curl --retry handles its own poll loop instead of a bash for-loop.
|
|
`--retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors http://127.0.0.1:7655/api/health`,
|
|
// Authoritative version check via /api/version (not /api/health).
|
|
`curl -fsS http://127.0.0.1:7655/api/version`,
|
|
`Installed version mismatch. Expected`,
|
|
)
|
|
}
|
|
|
|
func TestPromoteFloatingTagsReachableViaWorkflowCall(t *testing.T) {
|
|
assertFileContainsAll(t, repoFile(".github", "workflows", "promote-floating-tags.yml"),
|
|
`workflow_call:`,
|
|
`tag:`,
|
|
`description: "Release tag (e.g., v6.0.0). Required for workflow_call."`,
|
|
`prerelease:`,
|
|
`type: boolean`,
|
|
// Job condition must accept workflow_call alongside workflow_dispatch.
|
|
`github.event_name == 'workflow_call'`,
|
|
// Tag resolver must prefer inputs over the workflow_run derivation.
|
|
`if [ -n "${INPUT_TAG}" ]; then`,
|
|
`TAG="${INPUT_TAG}"`,
|
|
)
|
|
}
|
|
|
|
func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) {
|
|
workflowPath := repoFile(".github", "workflows", "publish-helm-chart.yml")
|
|
assertFileContainsAll(t, workflowPath,
|
|
`workflow_call:`,
|
|
`chart_version:`,
|
|
`description: "Chart version (e.g., 6.0.0-rc.5). Required for workflow_call."`,
|
|
`required: true`,
|
|
`type: string`,
|
|
`app_version:`,
|
|
// Chart-version resolver prefers inputs over release-event tag.
|
|
`if [ -n "${INPUT_CHART_VERSION}" ]; then`,
|
|
`RELEASE_TAG="${RELEASE_TAG_NAME}"`,
|
|
`name: Verify public GHCR chart read`,
|
|
`helm registry logout ghcr.io || true`,
|
|
`helm show chart`,
|
|
`oci://ghcr.io/${{ github.repository_owner }}/pulse-chart/pulse`,
|
|
`--version "${{ steps.versions.outputs.chart_version }}"`,
|
|
)
|
|
|
|
content, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("read publish-helm-chart.yml: %v", err)
|
|
}
|
|
workflow := string(content)
|
|
for _, forbidden := range []string{
|
|
`versions/latest/restore`,
|
|
`-f visibility=public`,
|
|
`Package visibility configuration attempted`,
|
|
} {
|
|
if strings.Contains(workflow, forbidden) {
|
|
t.Fatalf("publish-helm-chart.yml must verify chart readability instead of masking GHCR visibility API failures; found %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
|
|
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read create-release.yml: %v", err)
|
|
}
|
|
workflow := string(content)
|
|
job := workflowJobBlock(t, workflow, "publish_private_pro_runtime")
|
|
|
|
for _, needle := range []string{
|
|
`needs.validate_release_assets.result == 'success'`,
|
|
`github.event.inputs.draft_only != 'true'`,
|
|
`startsWith(needs.prepare.outputs.version, '6.')`,
|
|
`GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`,
|
|
`r2_prefix="${TAG}-pro-$(date -u '+%Y%m%d')-${GITHUB_RUN_ID}"`,
|
|
`gh workflow run build-pro-release.yml`,
|
|
`--repo rcourtman/pulse-enterprise`,
|
|
`-f pulse_ref="${TAG}"`,
|
|
`-f version="${VERSION}"`,
|
|
`-f upload_actions_artifact=false`,
|
|
`-f upload_to_r2=true`,
|
|
`-f publish_docker_image=true`,
|
|
`-f docker_image=license.pulserelay.pro/pulse-pro`,
|
|
`-f r2_prefix="${r2_prefix}"`,
|
|
`-f allow_stable_ga_publish="${allow_ga_publish}"`,
|
|
`wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build"`,
|
|
`gh workflow run promote-paid-runtime-release.yml`,
|
|
`--repo rcourtman/pulse-pro`,
|
|
`-f r2_prefix="${r2_prefix}"`,
|
|
`-f allow_ga_prefix="${allow_ga_publish}"`,
|
|
`wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion"`,
|
|
`echo "::error::${label} failed with conclusion=${conclusion}: ${url}"`,
|
|
} {
|
|
if !strings.Contains(job, needle) {
|
|
t.Fatalf("publish_private_pro_runtime missing required contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(job, "continue-on-error: true") {
|
|
t.Fatal("publish_private_pro_runtime must fail the release pipeline when private Pro publication or promotion fails")
|
|
}
|
|
}
|
|
|
|
func TestHelmAgentRuntimePointsAtRealImage(t *testing.T) {
|
|
// The helm chart's agent.enabled=true workload used to default to
|
|
// ghcr.io/rcourtman/pulse-agent — an image that was never published.
|
|
// The chart now points at the main rcourtman/pulse image and uses an
|
|
// arch-resolved /usr/local/bin/pulse-agent symlink baked into the
|
|
// runtime stage. This test pins:
|
|
// 1. values.yaml uses the main image
|
|
// 2. values.yaml has the command override
|
|
// 3. the agent template renders the command
|
|
// 4. the Dockerfile creates the symlink for every supported arch
|
|
// 5. validate-release.sh asserts the symlink exists in the published image
|
|
// Reverting any one of these unwires the chart back to ImagePullBackOff.
|
|
|
|
valuesBytes, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "values.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("read values.yaml: %v", err)
|
|
}
|
|
values := string(valuesBytes)
|
|
if !strings.Contains(values, "repository: rcourtman/pulse\n") {
|
|
t.Fatal("agent.image.repository must default to rcourtman/pulse (single-image agent + server)")
|
|
}
|
|
// Match the actual config value, not casual mentions in surrounding
|
|
// comments that explain why the default changed.
|
|
if strings.Contains(values, "repository: ghcr.io/rcourtman/pulse-agent") {
|
|
t.Fatal("agent.image.repository must not reference the never-published ghcr.io/rcourtman/pulse-agent image")
|
|
}
|
|
if !strings.Contains(values, "- /usr/local/bin/pulse-agent") {
|
|
t.Fatal("agent.command must default to /usr/local/bin/pulse-agent so the main image's server ENTRYPOINT is overridden")
|
|
}
|
|
|
|
agentTemplate, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "templates", "agent.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("read agent.yaml: %v", err)
|
|
}
|
|
tmpl := string(agentTemplate)
|
|
if !strings.Contains(tmpl, "{{- if .Values.agent.command }}") {
|
|
t.Fatal("agent.yaml template must conditionally render command from .Values.agent.command")
|
|
}
|
|
if !strings.Contains(tmpl, "command:\n {{- toYaml .Values.agent.command | nindent 12 }}") {
|
|
t.Fatal("agent.yaml template must render command via toYaml so list values pass through correctly")
|
|
}
|
|
|
|
assertFileContainsAll(t, repoFile("Dockerfile"),
|
|
`ln -s /opt/pulse/bin/pulse-agent-linux-arm64 /usr/local/bin/pulse-agent`,
|
|
`ln -s /opt/pulse/bin/pulse-agent-linux-armv7 /usr/local/bin/pulse-agent`,
|
|
`ln -s /opt/pulse/bin/pulse-agent-linux-amd64 /usr/local/bin/pulse-agent`,
|
|
)
|
|
|
|
assertFileContainsAll(t, repoFile("scripts", "validate-release.sh"),
|
|
`Validating /usr/local/bin/pulse-agent arch-resolved symlink`,
|
|
`[ -L /usr/local/bin/pulse-agent ]`,
|
|
`/usr/local/bin/pulse-agent target is not executable`,
|
|
)
|
|
}
|
|
|
|
func repoFile(parts ...string) string {
|
|
root := filepath.Join("..", "..")
|
|
segments := append([]string{root}, parts...)
|
|
return filepath.Join(segments...)
|
|
}
|
|
|
|
// assertFileContainsAll reads the file at path and fails the test if any of
|
|
// the required substrings is missing. The standard pinning-test shape in
|
|
// this package.
|
|
func assertFileContainsAll(t *testing.T, path string, required ...string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
s := string(content)
|
|
for _, needle := range required {
|
|
if !strings.Contains(s, needle) {
|
|
t.Fatalf("%s missing required substring: %s", path, needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertFileContainsAllNormalized(t *testing.T, path string, required ...string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
s := normalizedInstallTestWhitespace(string(content))
|
|
for _, needle := range required {
|
|
if !strings.Contains(s, normalizedInstallTestWhitespace(needle)) {
|
|
t.Fatalf("%s missing required normalized substring: %s", path, needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizedInstallTestWhitespace(text string) string {
|
|
return strings.Join(strings.Fields(text), " ")
|
|
}
|
|
|
|
func workflowJobBlock(t *testing.T, workflow, job string) string {
|
|
t.Helper()
|
|
|
|
startMarker := "\n " + job + ":\n"
|
|
start := strings.Index(workflow, startMarker)
|
|
if start == -1 {
|
|
t.Fatalf("workflow missing job %s", job)
|
|
}
|
|
start += 1
|
|
rest := workflow[start+len(" "+job+":\n"):]
|
|
end := len(rest)
|
|
for _, line := range strings.Split(rest, "\n") {
|
|
if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") {
|
|
candidate := strings.Index(rest, "\n"+line)
|
|
if candidate >= 0 {
|
|
end = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return workflow[start : start+len(" "+job+":\n")+end]
|
|
}
|