mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
2241 lines
95 KiB
Go
2241 lines
95 KiB
Go
package installtests
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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:
|
|
// 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 TestHelmChartShipsOpenShiftProfile(t *testing.T) {
|
|
read := func(parts ...string) string {
|
|
t.Helper()
|
|
content, err := os.ReadFile(repoFile(parts...))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", filepath.Join(parts...), err)
|
|
}
|
|
return string(content)
|
|
}
|
|
|
|
values := read("deploy", "helm", "pulse", "values.yaml")
|
|
agent := read("deploy", "helm", "pulse", "templates", "agent.yaml")
|
|
deployment := read("deploy", "helm", "pulse", "templates", "deployment.yaml")
|
|
rbac := read("deploy", "helm", "pulse", "templates", "agent-rbac.yaml")
|
|
helmCI := read(".github", "workflows", "helm-ci.yml")
|
|
docs := read("docs", "KUBERNETES.md")
|
|
|
|
for _, required := range []string{
|
|
"openShift:",
|
|
"kubernetesAgent:",
|
|
"clusterID:",
|
|
"rbac:",
|
|
} {
|
|
if !strings.Contains(values, required) {
|
|
t.Fatalf("values.yaml missing OpenShift chart value %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
`$openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled`,
|
|
`- --enable-kubernetes`,
|
|
`- --enable-host=false`,
|
|
`"name" "PULSE_AGENT_ID"`,
|
|
`$dockerSocketEnabled := and .Values.agent.dockerSocket.enabled (not $openShiftAgent)`,
|
|
`"runAsNonRoot" true`,
|
|
`omit $openShiftSecurityContext "runAsUser" "runAsGroup"`,
|
|
} {
|
|
if !strings.Contains(agent, required) {
|
|
t.Fatalf("agent template missing OpenShift contract %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
`.Values.openShift.enabled`,
|
|
`omit $openShiftContainerSecurityContext "runAsUser" "runAsGroup"`,
|
|
`"allowPrivilegeEscalation" false`,
|
|
} {
|
|
if !strings.Contains(deployment, required) {
|
|
t.Fatalf("server deployment missing OpenShift SCC contract %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
"kind: ClusterRole",
|
|
"kind: ClusterRoleBinding",
|
|
`apiGroups: ["metrics.k8s.io"]`,
|
|
`resources: ["nodes", "pods"]`,
|
|
`apiGroups: ["discovery.k8s.io"]`,
|
|
`resources: ["endpointslices"]`,
|
|
`apiGroups: ["rbac.authorization.k8s.io"]`,
|
|
} {
|
|
if !strings.Contains(rbac, required) {
|
|
t.Fatalf("agent RBAC template missing read-only collector rule %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(rbac, "- secrets") || strings.Contains(rbac, "- nodes/proxy") {
|
|
t.Fatal("OpenShift default role must not grant Secrets or direct kubelet proxy access")
|
|
}
|
|
for _, required := range []string{
|
|
"Render and verify the OpenShift profile",
|
|
"--show-only templates/agent-rbac.yaml",
|
|
`grep -Eq "runAs(User|Group):|fsGroup:"`,
|
|
`grep -q "/var/run/docker.sock"`,
|
|
} {
|
|
if !strings.Contains(helmCI, required) {
|
|
t.Fatalf("Helm CI missing OpenShift render assertion %q", required)
|
|
}
|
|
}
|
|
if !strings.Contains(docs, "--set openShift.enabled=true") ||
|
|
!strings.Contains(docs, "--set openShift.kubernetesAgent.enabled=true") ||
|
|
!strings.Contains(docs, "create secret generic pulse-server-env") ||
|
|
!strings.Contains(docs, "create secret generic pulse-agent-env") {
|
|
t.Fatal("Kubernetes guide must document the shipped OpenShift profile")
|
|
}
|
|
if strings.Contains(docs, "--set-string agent.secretEnv.data.PULSE_TOKEN") {
|
|
t.Fatal("OpenShift guide must not persist the agent token in Helm release values")
|
|
}
|
|
}
|
|
|
|
func shieldsBadgeMessage(value string) string {
|
|
return strings.ReplaceAll(value, "-", "--")
|
|
}
|
|
|
|
// TestAgentBuildCacheDoesNotResurrectPulseAgentPackage guards the repository's
|
|
// GHCR package list, which is a user-facing surface. A registry cache ref
|
|
// creates the package it points at, so pointing the agent_runtime build cache
|
|
// at ghcr.io/<owner>/pulse-agent recreated an empty package on every release.
|
|
// It then sat in the repo's Packages sidebar beside pulse, pulse-control-plane
|
|
// and pulse-chart/pulse, reading like a pullable agent image even though no
|
|
// workflow publishes it. Only images a release workflow actually pushes may
|
|
// own a package; the unified agent ships inside the main pulse image.
|
|
func TestAgentBuildCacheDoesNotResurrectPulseAgentPackage(t *testing.T) {
|
|
workflowDir := repoFile(".github", "workflows")
|
|
entries, err := os.ReadDir(workflowDir)
|
|
if err != nil {
|
|
t.Fatalf("read workflow dir: %v", err)
|
|
}
|
|
|
|
// Registry-qualified refs only: the release binaries are legitimately named
|
|
// pulse-agent-<os>-<arch> and must keep matching nothing here. Workflow
|
|
// expressions are collapsed first so an owner interpolated as
|
|
// ${{ github.repository_owner }} cannot hide the ref behind its spaces.
|
|
workflowExpr := regexp.MustCompile(`\$\{\{[^}]*\}\}`)
|
|
packageRef := regexp.MustCompile(`(?:ghcr\.io|docker\.io)/[^\s"']*/pulse-agent\b|(?:^|\s)rcourtman/pulse-agent\b`)
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if entry.IsDir() || (!strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml")) {
|
|
continue
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(workflowDir, name))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
for i, line := range strings.Split(string(content), "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
|
continue
|
|
}
|
|
if match := packageRef.FindString(workflowExpr.ReplaceAllString(line, "EXPR")); match != "" {
|
|
t.Fatalf("%s:%d targets the unpublished pulse-agent package (%q); no workflow may reference it, buildcache refs included", name, i+1, strings.TrimSpace(match))
|
|
}
|
|
}
|
|
}
|
|
|
|
release, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read create-release.yml: %v", err)
|
|
}
|
|
for _, required := range []string{
|
|
`cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache`,
|
|
`cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache,mode=max`,
|
|
} {
|
|
if !strings.Contains(string(release), required) {
|
|
t.Fatalf("create-release.yml must cache the agent_runtime build under the published pulse package; missing %q", required)
|
|
}
|
|
}
|
|
|
|
values, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "values.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("read values.yaml: %v", err)
|
|
}
|
|
agentBlock := topLevelYAMLBlock(t, string(values), "agent")
|
|
if !strings.Contains(agentBlock, "repository: rcourtman/pulse\n") {
|
|
t.Fatal("chart agent.image.repository must default to the published rcourtman/pulse image")
|
|
}
|
|
}
|
|
|
|
// topLevelYAMLBlock returns the lines of a top-level mapping key, from the key
|
|
// itself up to the next unindented key.
|
|
func topLevelYAMLBlock(t *testing.T, doc string, key string) string {
|
|
t.Helper()
|
|
lines := strings.Split(doc, "\n")
|
|
start := -1
|
|
for i, line := range lines {
|
|
if line == key+":" {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
t.Fatalf("values.yaml missing top-level %q key", key)
|
|
}
|
|
for i := start + 1; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
return strings.Join(lines[start:i], "\n")
|
|
}
|
|
return strings.Join(lines[start:], "\n")
|
|
}
|
|
|
|
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`,
|
|
`--rawfile body "$NOTES_FILE"`,
|
|
`--input "$RELEASE_PAYLOAD"`,
|
|
`--expected-body-file "$NOTES_FILE"`,
|
|
`historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}`,
|
|
`if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}`,
|
|
`candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}`,
|
|
`if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`,
|
|
`permissions:`,
|
|
`issues: write`,
|
|
`statuses: write`,
|
|
`ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE")`,
|
|
`ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE")`,
|
|
`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 }}`,
|
|
`asset_source: staged`,
|
|
`release_id: ${{ needs.create_release.outputs.release_id }}`,
|
|
// 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 }}`,
|
|
`uses: ./.github/workflows/helm-pages.yml`,
|
|
`publish_helm_pages:`,
|
|
// Mutable image aliases have one explicit owner at the activation
|
|
// barrier; no implicit workflow_run may race that call.
|
|
`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 stops after staged validation and skips the
|
|
// customer activation sequence.
|
|
`needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true'`,
|
|
`activate_release:`,
|
|
`needs.promote_private_pro_runtime.result == 'success'`,
|
|
`Publish the fully staged release`,
|
|
`'{draft: false, make_latest: $make_latest}'`,
|
|
`returning ${TAG} to draft quarantine`,
|
|
}
|
|
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", "publish_helm_pages"} {
|
|
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)
|
|
}
|
|
}
|
|
readinessJob := workflowJobBlock(t, workflow, "release_readiness")
|
|
if !strings.Contains(readinessJob, publishedReleaseGuard) {
|
|
t.Fatal("release_readiness must skip historical backfill and draft-only runs")
|
|
}
|
|
floatingJob := workflowJobBlock(t, workflow, "promote_floating_tags")
|
|
if !strings.Contains(floatingJob, `needs.release_readiness.result == 'success'`) {
|
|
t.Fatal("floating-tag promotion must run only after the immutable readiness barrier")
|
|
}
|
|
|
|
if !strings.Contains(workflow, `draft: true`) {
|
|
t.Fatal("create-release.yml must validate the release while it remains staged as a draft")
|
|
}
|
|
createJob := workflowJobBlock(t, workflow, "create_release")
|
|
if strings.Contains(createJob, `draft=false`) || strings.Contains(createJob, `Publish release`) {
|
|
t.Fatal("create_release must stage assets without crossing the customer publication boundary")
|
|
}
|
|
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`,
|
|
`Validate release body integrity`,
|
|
`--validate-body-file "$RELEASE_BODY_FILE"`,
|
|
`--expected-body-file "$CLEAN_BODY_FILE"`,
|
|
`Quarantine malformed release body`,
|
|
`The release was quarantined as a draft without deleting its assets.`,
|
|
}
|
|
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+"`",
|
|
"Proxmox monitoring is more authoritative",
|
|
"TrueNAS monitoring uses the supported JSON-RPC transport",
|
|
"QNAP and Unraid installs now fail early",
|
|
"not Authenticode-signed",
|
|
"Unknown Publisher",
|
|
"`no-mobile-impact`",
|
|
"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+"`",
|
|
"Proxmox and PBS monitoring",
|
|
"TrueNAS uses its supported JSON-RPC transport",
|
|
"Unified Agent installs on constrained QNAP and Unraid roots",
|
|
"`v6.1.2`-only",
|
|
"Unknown Publisher",
|
|
"Mobile decision: `no-mobile-impact`",
|
|
)
|
|
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 TestCurrentStableMinorReleasePacketTracksInstallMetadata(t *testing.T) {
|
|
version := currentReleaseVersion(t)
|
|
if isPrereleaseVersion(version) {
|
|
t.Skip("current release is a prerelease")
|
|
}
|
|
parts, valid := parseStableVersion(version)
|
|
if !valid || parts[1] == 0 || parts[2] != 0 {
|
|
t.Skip("current release is not a stable minor release")
|
|
}
|
|
previous, ok := previousStableForPrereleaseVersion(version + "-rc.1")
|
|
if !ok {
|
|
t.Fatal("stable minor release has no earlier stable rollback packet")
|
|
}
|
|
|
|
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 minor release",
|
|
"stable `v"+previous+"`",
|
|
"## Highlights",
|
|
"Operational Trust",
|
|
"Actions provides a dedicated inbox",
|
|
"existing Pulse Mobile candidate",
|
|
"not Authenticode-signed",
|
|
"The rollback target is `v"+previous+"`",
|
|
)
|
|
assertFileContainsAllNormalized(t, changelogPath,
|
|
"Version: `v"+version+"`",
|
|
"Previous stable: `v"+previous+"`",
|
|
"Rollback target: `v"+previous+"`",
|
|
"Promotion path: owner-approved exact-SHA stable cutoff from `main`",
|
|
"Mobile decision: `existing-mobile-build-compatible`",
|
|
)
|
|
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.",
|
|
"`rollback_version=v"+previous+"`",
|
|
"The exact stable `main` SHA must pass the no-publication dry run before the same SHA is dispatched through the single-build publish workflow.",
|
|
"The stable server cut is classified `existing-mobile-build-compatible`.",
|
|
"explicit version-bound decision",
|
|
"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",
|
|
"## Highlights",
|
|
"stable `v"+previous+"`",
|
|
"supersedes `v6.2.0-rc.8`",
|
|
"Alert delivery preserves configured ntfy metadata and acknowledgement state",
|
|
"Certificate-expiry monitoring, QNAP RAID bitmap parsing, real vCenter tags",
|
|
"Agent install and update paths reject older served binaries",
|
|
"Provider-hosted restore writes and automatic agent identity matching",
|
|
"Phone and narrow-screen layouts retain workload identity",
|
|
"Tenant resource stores close cleanly during offboarding and shutdown",
|
|
"Targeted regressions cover the alert, monitoring, agent-install",
|
|
"iOS build 12 is distributed through the TestFlight public beta link",
|
|
"Android versionCode 9 remains available through Play open testing",
|
|
"No public mobile-store rollout is part of this RC",
|
|
"not yet Authenticode-signed",
|
|
"No unsigned-Windows exception applies to any `v6.2.0` release",
|
|
"rollback target is stable `v"+previous+"`",
|
|
)
|
|
assertFileContainsAllNormalized(t, changelogPath,
|
|
"Version: `v"+version+"`",
|
|
"Previous candidate: `v6.2.0-rc.8`",
|
|
"Previous stable: `v"+previous+"`",
|
|
"Rollback target: `v"+previous+"`",
|
|
"Promotion path: exact-SHA single-build release candidate from `main`",
|
|
"This changelog describes the changes since `v6.2.0-rc.8`",
|
|
"Certificate-validity monitoring and alerting",
|
|
"Shared generation-bound resource views across API requests",
|
|
"Canonical agent auto-registration identity matching",
|
|
"Preserved configured ntfy metadata, provider incidents",
|
|
"Refused to serve agent binaries older than the server",
|
|
"Closed the outstanding CodeQL findings",
|
|
"Windows signing decision: Authenticode through SignPath is the mandatory signing backend",
|
|
"Mobile decision: `existing-mobile-build-compatible`",
|
|
"iOS build 12 is distributed through the TestFlight public beta link",
|
|
"Android versionCode 9 remains on Play open testing",
|
|
"No public store rollout is part of this candidate",
|
|
)
|
|
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.",
|
|
"The `v"+version+"` server cut is classified `existing-mobile-build-compatible`.",
|
|
"The changes since RC8 do not alter mobile relay payloads, pairing, approvals, authentication, or onboarding contracts; no additional companion upload or public store rollout is part of RC9.",
|
|
)
|
|
}
|
|
|
|
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")
|
|
}
|
|
// Format drift guard — ssh-keygen -Y verify -f expects an allowed_signers
|
|
// file whose FIRST field is the principal. The docs shipped the key in
|
|
// authorized_keys order (principal last, parsed as a comment), so the
|
|
// documented verification failed against a perfectly good signature.
|
|
// Reported by a customer against v6.0.5 on 2026-07-13.
|
|
const allowedSignersLine = `pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer`
|
|
if !strings.Contains(readme, allowedSignersLine) {
|
|
t.Fatalf("README.md verification snippet must publish the key as an allowed_signers line (principal first), not authorized_keys order")
|
|
}
|
|
|
|
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...")
|
|
}
|
|
if !strings.Contains(installDocs, allowedSignersLine) {
|
|
t.Fatalf("docs/INSTALL.md verification snippet must publish the key as an allowed_signers line (principal first), not authorized_keys order")
|
|
}
|
|
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.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 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.26.5-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 TestDockerRuntimeShipsPinnedAppriseCLI(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
|
|
if err != nil {
|
|
t.Fatalf("read Dockerfile: %v", err)
|
|
}
|
|
dockerfile := string(dockerfileBytes)
|
|
required := []string{
|
|
`ARG APPRISE_VERSION=1.12.0`,
|
|
`AS apprise-builder`,
|
|
`python3 -m venv /opt/apprise`,
|
|
`/opt/apprise/bin/pip install --no-cache-dir "apprise==${APPRISE_VERSION}"`,
|
|
`COPY --from=apprise-builder /opt/apprise /opt/apprise`,
|
|
`ln -s /opt/apprise/bin/apprise /usr/local/bin/apprise`,
|
|
`apprise --version | grep -F "Apprise v${APPRISE_VERSION}"`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(dockerfile, needle) {
|
|
t.Fatalf("Dockerfile missing pinned Apprise runtime contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Count(dockerfile, `apprise --version | grep -F "Apprise v${APPRISE_VERSION}"`) < 2 {
|
|
t.Fatal("Dockerfile must verify the pinned Apprise CLI in both its build and runtime stages")
|
|
}
|
|
}
|
|
|
|
func TestAgentRuntimeImageDefaultsToUnifiedHostAndDockerMonitoring(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=true`,
|
|
`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 unified host and Docker contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(dockerfile, `PULSE_ENABLE_HOST=false`) {
|
|
t.Fatal("agent_runtime must not silently force every deployment into workload-only mode")
|
|
}
|
|
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 TestReleaseCandidateRequiresPlatformNativeAgentSigning(t *testing.T) {
|
|
candidateWorkflowPath := repoFile(".github", "workflows", "build-release-candidate.yml")
|
|
assertFileContainsAll(t, candidateWorkflowPath,
|
|
`require_macos_signing:`,
|
|
`require_windows_signing:`,
|
|
`sign-macos-agent:`,
|
|
`codesign --force --timestamp --options runtime`,
|
|
`xcrun notarytool submit`,
|
|
`--output-format json > notarization-result.json`,
|
|
`result.get('status') != 'Accepted'`,
|
|
`codesign --verify --deep --strict --verbose=2`,
|
|
`sign-windows-agent:`,
|
|
`windows_signing_backend:`,
|
|
`signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2`,
|
|
`github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}`,
|
|
`SIGNPATH_API_TOKEN`,
|
|
`SIGNPATH_ORGANIZATION_ID`,
|
|
`SIGNPATH_PROJECT_SLUG`,
|
|
`SIGNPATH_SIGNING_POLICY_SLUG`,
|
|
`SIGNPATH_ARTIFACT_CONFIGURATION_SLUG`,
|
|
`SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT`,
|
|
`signtool sign`,
|
|
`signtool verify /pa /v`,
|
|
`windows-signing-evidence.json`,
|
|
`signerThumbprint`,
|
|
`PULSE_AGENT_NATIVE_BINARIES_DIR:`,
|
|
)
|
|
candidateWorkflow, err := os.ReadFile(candidateWorkflowPath)
|
|
if err != nil {
|
|
t.Fatalf("read build-release-candidate.yml: %v", err)
|
|
}
|
|
if strings.Contains(string(candidateWorkflow), `spctl --assess --type execute`) {
|
|
t.Fatal("bare command-line Mach-O binaries must not use Gatekeeper app assessment after notarization")
|
|
}
|
|
assertFileContainsAll(t, repoFile(".github", "workflows", "create-release.yml"),
|
|
`require_macos_signing: true`,
|
|
`require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}`,
|
|
`unsigned_windows_exception:`,
|
|
`unsigned_windows_reason:`,
|
|
`windows_signing_backend: signpath`,
|
|
)
|
|
assertFileContainsAll(t, repoFile(".github", "workflows", "release-dry-run.yml"),
|
|
`Definitive Dry-Run Verdict`,
|
|
`require_windows_signing: ${{ !contains(inputs.version, '-') && !((inputs.version == '6.1.0' || inputs.version == '6.1.1' || inputs.version == '6.1.2') && inputs.unsigned_windows_exception) }}`,
|
|
`require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success`,
|
|
`require_result "stable demo no-mutation verification" "$DEMO_RESULT" success`,
|
|
)
|
|
assertFileContainsAll(t, repoFile("scripts", "release_control", "resolve_release_promotion.py"),
|
|
`version not in {"6.1.0", "6.1.1", "6.1.2"}`,
|
|
`unsigned_windows_reason is required`,
|
|
`not Authenticode-signed`,
|
|
`require_windows_signing = not is_prerelease and not unsigned_windows_exception`,
|
|
)
|
|
assertFileContainsAll(t, repoFile("scripts", "build-release.sh"),
|
|
`PULSE_AGENT_NATIVE_BINARIES_DIR`,
|
|
`native_targets=()`,
|
|
`PULSE_REQUIRE_MACOS_SIGNING:-false`,
|
|
`native_targets+=(darwin-amd64 darwin-arm64)`,
|
|
`PULSE_REQUIRE_WINDOWS_SIGNING:-false`,
|
|
`native_targets+=(windows-amd64 windows-arm64 windows-386)`,
|
|
`Applied required platform-native signed Unified Agent binaries.`,
|
|
`required native signing is enabled but PULSE_AGENT_NATIVE_BINARIES_DIR is empty.`,
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
candidateWorkflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read build-release-candidate.yml: %v", err)
|
|
}
|
|
createRelease := string(createReleaseBytes) + "\n" + string(candidateWorkflowBytes)
|
|
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{
|
|
`workflow_call:`,
|
|
`chart_version:`,
|
|
`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}"`,
|
|
`helm show chart pulse-public/pulse --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)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"workflow_run:",
|
|
`git checkout -B "$REQUIRED_BRANCH"`,
|
|
`git push origin HEAD:"$REQUIRED_BRANCH"`,
|
|
} {
|
|
if strings.Contains(helmPages, forbidden) {
|
|
t.Fatalf("helm-pages.yml must be an awaited exact-tag staging job; found forbidden %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
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`,
|
|
`uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4`,
|
|
`ping: ${{ secrets.DEMO_SERVER_HOST }}`,
|
|
`bash .github/scripts/check-demo-reachability.sh`,
|
|
`bash .github/scripts/setup-demo-ssh.sh`,
|
|
`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@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4`,
|
|
`oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}`,
|
|
`oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}`,
|
|
`tags: tag:infra`,
|
|
`version: '1.94.2'`,
|
|
`ping: ${{ secrets.DEMO_SERVER_HOST }}`,
|
|
`bash .github/scripts/check-demo-reachability.sh`,
|
|
`workflow_call:`,
|
|
`verify_only:`,
|
|
`release_id:`,
|
|
`repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100`,
|
|
`Accept: application/octet-stream`,
|
|
`--archive "/tmp/${tarball}" --disable-auto-updates`,
|
|
`Refuse mutation during verification-only checks`,
|
|
`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`,
|
|
`bash .github/scripts/setup-demo-ssh.sh`,
|
|
`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 TestDemoSshSetupHelperHandlesIpLiteralTargets(t *testing.T) {
|
|
helperBytes, err := os.ReadFile(repoFile(".github", "scripts", "setup-demo-ssh.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read demo ssh setup helper: %v", err)
|
|
}
|
|
helper := string(helperBytes)
|
|
required := []string{
|
|
`is_ip_literal()`,
|
|
`ipaddress.ip_address(sys.argv[1])`,
|
|
`host_needs_dns=false`,
|
|
`Demo SSH host is an IP literal; skipping DNS resolution wait.`,
|
|
`[ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST"`,
|
|
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
|
|
`MAX_SSH_SETUP_ATTEMPTS="${DEMO_SSH_SETUP_ATTEMPTS:-3}"`,
|
|
`Demo network preflight passed, but ssh-keyscan did not return host keys.`,
|
|
}
|
|
for _, needle := range required {
|
|
if !strings.Contains(helper, needle) {
|
|
t.Fatalf("demo ssh setup helper missing guarded IP/hostname behavior: %s", needle)
|
|
}
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
fakeBin := filepath.Join(tmpDir, "bin")
|
|
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
|
|
t.Fatalf("create fake bin: %v", err)
|
|
}
|
|
getentMarker := filepath.Join(tmpDir, "getent-called")
|
|
if err := os.WriteFile(filepath.Join(fakeBin, "getent"), []byte("#!/bin/sh\n: > \"$GETENT_MARKER\"\nexit 1\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake getent: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(fakeBin, "ssh-keyscan"), []byte("#!/bin/sh\nprintf '100.109.163.95 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDemo\\n'\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake ssh-keyscan: %v", err)
|
|
}
|
|
|
|
homeDir := filepath.Join(tmpDir, "home")
|
|
cmd := exec.Command("bash", repoFile(".github", "scripts", "setup-demo-ssh.sh"))
|
|
cmd.Env = append(os.Environ(),
|
|
"DEMO_SERVER_HOST=100.109.163.95",
|
|
"DEMO_SERVER_SSH_KEY=fake-private-key",
|
|
"GETENT_MARKER="+getentMarker,
|
|
"HOME="+homeDir,
|
|
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("demo ssh setup helper failed for IP literal: %v\n%s", err, output)
|
|
}
|
|
if _, err := os.Stat(getentMarker); !os.IsNotExist(err) {
|
|
t.Fatalf("demo ssh setup helper must not require getent hosts for IP literals; stat err=%v", err)
|
|
}
|
|
knownHosts, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "known_hosts"))
|
|
if err != nil {
|
|
t.Fatalf("read generated known_hosts: %v", err)
|
|
}
|
|
if !strings.Contains(string(knownHosts), "ssh-ed25519") {
|
|
t.Fatalf("known_hosts missing captured key: %s", knownHosts)
|
|
}
|
|
if !strings.Contains(string(output), "Demo SSH host is an IP literal; skipping DNS resolution wait.") {
|
|
t.Fatalf("helper output did not report IP literal path: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestDemoReachabilityHelperSeparatesTailnetAndSshTransportProof(t *testing.T) {
|
|
helperBytes, err := os.ReadFile(repoFile(".github", "scripts", "check-demo-reachability.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read demo reachability helper: %v", err)
|
|
}
|
|
helper := string(helperBytes)
|
|
for _, needle := range []string{
|
|
`tailscale status --json`,
|
|
`tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"`,
|
|
`nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"`,
|
|
`Runner Tailscale DNS:`,
|
|
`Runner Tailscale tags:`,
|
|
`Demo peer is not present in the runner peer map yet.`,
|
|
`Verify sshd and the host firewall on tailscale0.`,
|
|
} {
|
|
if !strings.Contains(helper, needle) {
|
|
t.Fatalf("demo reachability helper missing diagnostic contract: %s", needle)
|
|
}
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
fakeBin := filepath.Join(tmpDir, "bin")
|
|
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
|
|
t.Fatalf("create fake bin: %v", err)
|
|
}
|
|
tailscaleScript := `#!/bin/sh
|
|
if [ "$1" = "status" ]; then
|
|
printf '%s\n' '{"BackendState":"Running","Self":{"TailscaleIPs":["100.100.100.1"]},"Peer":{"demo":{"TailscaleIPs":["100.109.163.95"],"Online":true,"Active":true,"Relay":"lhr"}}}'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "ping" ]; then
|
|
echo 'pong from demo'
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(filepath.Join(fakeBin, "tailscale"), []byte(tailscaleScript), 0o755); err != nil {
|
|
t.Fatalf("write fake tailscale: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(fakeBin, "nc"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake nc: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("bash", repoFile(".github", "scripts", "check-demo-reachability.sh"))
|
|
cmd.Env = append(os.Environ(),
|
|
"DEMO_SERVER_HOST=100.109.163.95",
|
|
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("demo reachability helper failed: %v\n%s", err, output)
|
|
}
|
|
for _, needle := range []string{"Tailscale backend: Running", "Demo peer state: online=True active=True relay=lhr", "Demo SSH transport is reachable over Tailscale."} {
|
|
if !strings.Contains(string(output), needle) {
|
|
t.Fatalf("demo reachability output missing %q: %s", needle, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestDockerfileStampsTelemetryDeploymentMethod(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile"))
|
|
if err != nil {
|
|
t.Fatalf("read Dockerfile: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(dockerfileBytes), `ENV PULSE_DEPLOYMENT_METHOD=container_other`) {
|
|
t.Fatal("Dockerfile must stamp the closed fallback deployment method for container images")
|
|
}
|
|
}
|
|
|
|
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 one customer boundary. Exact-version artifacts are staged behind a
|
|
// draft, verified, and only then activated; GitHub publication is the final
|
|
// notification rather than the trigger for a long tail of publication work.
|
|
// The tests below pin that barrier so the staggered-release regression class
|
|
// cannot return.
|
|
|
|
func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
|
assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"),
|
|
// Inputs and triggers.
|
|
`name: install.sh Smoke (Release Assets)`,
|
|
`workflow_call:`,
|
|
`workflow_dispatch:`,
|
|
`asset_source:`,
|
|
`release_id:`,
|
|
// Staged cuts use authenticated draft assets; manual verification can
|
|
// still pull from the public release URL.
|
|
`repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100`,
|
|
`repos/${REPO}/releases/assets/${asset_id}`,
|
|
`Accept: application/octet-stream`,
|
|
`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) {
|
|
workflowPath := repoFile(".github", "workflows", "promote-floating-tags.yml")
|
|
assertFileContainsAll(t, workflowPath,
|
|
`workflow_call:`,
|
|
`tag:`,
|
|
`description: "Release tag (e.g., v6.0.0). Required for workflow_call."`,
|
|
`prerelease:`,
|
|
`type: boolean`,
|
|
`TAG="${INPUT_TAG}"`,
|
|
`for image in pulse pulse-control-plane; do`,
|
|
`"rcourtman/${image}:rc"`,
|
|
`"ghcr.io/${OWNER}/${image}:latest"`,
|
|
)
|
|
content, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("read promote-floating-tags.yml: %v", err)
|
|
}
|
|
if strings.Contains(string(content), "workflow_run:") {
|
|
t.Fatal("floating aliases must have one explicit activation owner, not an implicit workflow_run trigger")
|
|
}
|
|
publishBytes, err := os.ReadFile(repoFile(".github", "workflows", "publish-docker.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read publish-docker.yml: %v", err)
|
|
}
|
|
publishWorkflow := string(publishBytes)
|
|
for _, mutableTag := range []string{
|
|
`rcourtman/pulse:latest`,
|
|
`ghcr.io/{0}/pulse:latest`,
|
|
`rcourtman/pulse-control-plane:latest`,
|
|
`ghcr.io/{0}/pulse-control-plane:latest`,
|
|
} {
|
|
if strings.Contains(publishWorkflow, mutableTag) {
|
|
t.Fatalf("publish-docker.yml must stage exact-version images without moving mutable alias %q", mutableTag)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|
createBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read create-release.yml: %v", err)
|
|
}
|
|
candidateBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read build-release-candidate.yml: %v", err)
|
|
}
|
|
validationBytes, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read validate-release-assets.yml: %v", err)
|
|
}
|
|
|
|
createWorkflow := string(createBytes)
|
|
candidateWorkflow := string(candidateBytes)
|
|
validationWorkflow := string(validationBytes)
|
|
createJob := workflowJobBlock(t, createWorkflow, "create_release")
|
|
backendJob := workflowJobBlock(t, createWorkflow, "backend_tests")
|
|
integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests")
|
|
validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets")
|
|
privateStageJob := workflowJobBlock(t, createWorkflow, "stage_private_pro_runtime")
|
|
readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness")
|
|
privatePromotionJob := workflowJobBlock(t, createWorkflow, "promote_private_pro_runtime")
|
|
activationJob := workflowJobBlock(t, createWorkflow, "activate_release")
|
|
|
|
for _, needle := range []string{
|
|
`./scripts/build-release.sh "${{ inputs.version }}"`,
|
|
`scripts/validate-release.sh "${{ inputs.version }}" --skip-docker`,
|
|
`scripts/release_candidate_manifest.py create`,
|
|
`compression-level: 0`,
|
|
`retention-days: 1`,
|
|
} {
|
|
if !strings.Contains(candidateWorkflow, needle) {
|
|
t.Fatalf("build-release-candidate.yml missing single-build contract: %s", needle)
|
|
}
|
|
}
|
|
|
|
for _, needle := range []string{
|
|
`Download immutable release candidate`,
|
|
`scripts/release_candidate_manifest.py verify-local`,
|
|
`needs.build_release_candidate.outputs.artifact_name`,
|
|
} {
|
|
if !strings.Contains(createJob, needle) {
|
|
t.Fatalf("create_release missing candidate promotion contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(createJob, "scripts/build-release.sh") {
|
|
t.Fatal("create_release must promote the verified candidate instead of rebuilding release assets")
|
|
}
|
|
|
|
if !strings.Contains(backendJob, "- frontend_checks") || !strings.Contains(integrationJob, "- frontend_checks") {
|
|
t.Fatal("backend and integration jobs must consume the shared verified frontend bundle")
|
|
}
|
|
if strings.Contains(integrationJob, "- backend_tests") {
|
|
t.Fatal("integration tests must run in parallel with backend tests")
|
|
}
|
|
if !strings.Contains(integrationJob, `tests/66-organization-sharing-approval-ui.spec.ts`) {
|
|
t.Fatal("integration release gate missing current organization-sharing coverage")
|
|
}
|
|
if strings.Contains(integrationJob, `tests/03-multi-tenant.spec.ts`) {
|
|
t.Fatal("integration release gate must not target the quarantined multi-tenant spec")
|
|
}
|
|
if strings.Contains(validationJob, "- publish_docker") {
|
|
t.Fatal("release asset digest validation must run in parallel with Docker publication")
|
|
}
|
|
if !strings.Contains(privateStageJob, "- create_release") || strings.Contains(privateStageJob, "- validate_release_assets") {
|
|
t.Fatal("private Pro staging must start after draft creation without waiting for asset validation")
|
|
}
|
|
for _, dependency := range []string{
|
|
"- create_release",
|
|
"- publish_docker",
|
|
"- validate_release_assets",
|
|
"- install_sh_smoke",
|
|
"- publish_helm_chart",
|
|
"- publish_helm_pages",
|
|
"- stage_private_pro_runtime",
|
|
} {
|
|
if !strings.Contains(readinessJob, dependency) {
|
|
t.Fatalf("immutable release readiness missing dependency: %s", dependency)
|
|
}
|
|
}
|
|
for _, dependency := range []string{"- release_readiness", "- stage_private_pro_runtime"} {
|
|
if !strings.Contains(privatePromotionJob, dependency) {
|
|
t.Fatalf("private Pro live promotion missing staging dependency: %s", dependency)
|
|
}
|
|
}
|
|
for _, dependency := range []string{
|
|
"- release_readiness",
|
|
"- update_stable_demo",
|
|
"- promote_floating_tags",
|
|
"- promote_private_pro_runtime",
|
|
} {
|
|
if !strings.Contains(activationJob, dependency) {
|
|
t.Fatalf("release activation missing readiness dependency: %s", dependency)
|
|
}
|
|
}
|
|
if !strings.Contains(activationJob, `'{draft: false, make_latest: $make_latest}'`) {
|
|
t.Fatal("release activation must be the job that crosses the draft publication boundary")
|
|
}
|
|
for _, needle := range []string{
|
|
`inputs.candidate_manifest_artifact != ''`,
|
|
`scripts/release_candidate_manifest.py verify-release`,
|
|
`inputs.candidate_manifest_artifact == ''`,
|
|
} {
|
|
if !strings.Contains(validationWorkflow, needle) {
|
|
t.Fatalf("validate-release-assets.yml missing fast digest contract: %s", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(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)
|
|
frontendJob := workflowJobBlock(t, workflow, "frontend_checks")
|
|
windowsJob := workflowJobBlock(t, workflow, "windows_install_command_smoke")
|
|
smokeJob := workflowJobBlock(t, workflow, "release_smoke")
|
|
createJob := workflowJobBlock(t, workflow, "create_release")
|
|
verdictJob := workflowJobBlock(t, workflow, "release_verdict")
|
|
|
|
for _, needle := range []string{
|
|
`npm --prefix frontend-modern run type-check`,
|
|
`npm --prefix frontend-modern test`,
|
|
} {
|
|
if !strings.Contains(frontendJob, needle) {
|
|
t.Fatalf("frontend release gate missing %s", needle)
|
|
}
|
|
}
|
|
for _, needle := range []string{
|
|
`runs-on: windows-2025`,
|
|
`agentInstallCommand.windows.test.ts`,
|
|
} {
|
|
if !strings.Contains(windowsJob, needle) {
|
|
t.Fatalf("Windows install-command release gate missing %s", needle)
|
|
}
|
|
}
|
|
for _, needle := range []string{
|
|
`tests/95-release-smoke.spec.ts`,
|
|
`release-smoke-failures-${{ github.sha }}`,
|
|
`tests/integration/test-results/`,
|
|
} {
|
|
if !strings.Contains(smokeJob, needle) {
|
|
t.Fatalf("release render smoke missing %s", needle)
|
|
}
|
|
}
|
|
if !strings.Contains(createJob, `needs.windows_install_command_smoke.result == 'success'`) {
|
|
t.Fatal("release assembly must fail closed on the Windows install-command smoke")
|
|
}
|
|
if !strings.Contains(verdictJob, `require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success`) {
|
|
t.Fatal("definitive release verdict must report the Windows install-command smoke")
|
|
}
|
|
}
|
|
|
|
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)
|
|
stageJob := workflowJobBlock(t, workflow, "stage_private_pro_runtime")
|
|
promotionJob := workflowJobBlock(t, workflow, "promote_private_pro_runtime")
|
|
|
|
for _, needle := range []string{
|
|
`needs.create_release.result == 'success'`,
|
|
`github.event.inputs.draft_only != 'true'`,
|
|
`startsWith(needs.prepare.outputs.version, '6.')`,
|
|
`GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`,
|
|
`--json createdAt`,
|
|
`r2_prefix="${TAG}-pro-${run_created_date}-${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 reuse_existing_packet=true`,
|
|
`-f allow_stable_ga_publish="${allow_ga_publish}"`,
|
|
`wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build"`,
|
|
`echo "r2_prefix=${r2_prefix}" >> "$GITHUB_OUTPUT"`,
|
|
} {
|
|
if !strings.Contains(stageJob, needle) {
|
|
t.Fatalf("stage_private_pro_runtime missing required contract: %s", needle)
|
|
}
|
|
}
|
|
for _, needle := range []string{
|
|
`needs.stage_private_pro_runtime.result == 'success'`,
|
|
`R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}`,
|
|
`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(promotionJob, needle) {
|
|
t.Fatalf("promote_private_pro_runtime missing required contract: %s", needle)
|
|
}
|
|
}
|
|
if strings.Contains(stageJob, "continue-on-error: true") || strings.Contains(promotionJob, "continue-on-error: true") {
|
|
t.Fatal("private Pro staging and promotion must fail the release pipeline on error")
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|