Updating to v6.4.5-rc.5 and then v6.4.5 left the progress modal on
"Downloading update... 10%" even though the update finished in the
background. The status stream could go silent behind a proxy (Tailscale
Serve, nginx buffering) and the modal only advanced on stream events.
Backend: the update SSE endpoint now sends the current status on connect,
writes events through one ordered writer with an explicit flush, sends a
15s heartbeat and sets X-Accel-Buffering: no.
Frontend: a silence watchdog falls back to status polling, the restart
phase is entered only on real restart evidence (restarting status, the
server going away, or a version change) rather than a single failed poll,
and the page never auto-reloads on unconfirmed completion without a
pre-update version baseline to compare against.
Main is failing Backend tests (rest-1) on
TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome. The exact
server archive rule (ab0aa1ef0) offers an update only when the release
carries the archive for the host architecture, and the #2285 test
releases carried no assets, so its update-offered cases could never
report available. The two check tests also hard-coded a linux-amd64
archive, which failed on arm64 hosts.
Build every fixture archive from the same runtime asset helper the
updater uses. Test only.
## What changed and why
Keep queued notifications from being cancelled while saved destinations
load, and make stable-channel update discovery work with GitHub's large,
prerelease-heavy release list (#2282). Update download selection now
requires the exact server archive for the runtime architecture; ARMv6
and ARMv7 are distinguished by the build variant rather than silently
choosing ARMv7. The batch also retains the reviewed disk-monitoring
round-trip correction.
## Reviewed source
`b1e75fb9e4693c5706b2242760d6277e5291e7f9..18f2dc1f19416bd83ab72e7f82f2287063b0bedc`
(notification test isolation and startup gate, integration of update
metadata, exact-archive selection, ARM variant correction).
## Validation
Provenance and diff checks passed for the complete range. Prior
committed-source offline proofs passed for the update package through
`ab0aa1ef02698f7727b134a6f6b5ed520930cc61` and for the notification
startup race scenario. The new ARM mapping has a focused regression test
in this head; exact-head protected CI remains required before landing.
These source checks do not establish installed update or alert-delivery
results, nor release readiness.
[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
Combine the protected schema-v18 sender with the exact-archive updater. Require installable archives in positive fixtures, classify missing archives and unavailable Pro checks without claiming an offer, and avoid caching an unactivated Pro result. Record the contract and exact combined-source browser receipt.
Change-source: pulse-maintainer
Combine the reviewed main-line sender with the in-flight archive correction so the interaction can be repaired and proved at exact source.
Change-source: pulse-maintainer
A failed update check never reaches the update history, because nothing
was applied, so telemetry could not tell an install whose check is broken
from one that was offered an update and ignored it. The 6.4.3-rc.1 installs
looked healthy while their check failed on every call (#2282).
The update manager now records a closed outcome for every check on the
install's effective channel (up_to_date, available, no_release,
rate_limited, network_error, metadata_error, skipped, error), tagging
lookup failures without changing their error text, and ignores previews of
another channel from Settings. Telemetry schema v18 exports update_channel,
update_check_outcome and update_available. No version, URL or error text
leaves the install. The Settings preview type, privacy disclosure, adoption
report and subsystem contracts are updated to match; the Pulse Pro receiver
change lands separately and the parity check passes against it.
ARMv6 and ARMv7 server binaries both report GOARCH=arm. Read the release binary GOARM build setting and fail closed when it does not name a published server archive, rather than offering an ARMv7 update to ARMv6 installations. Cover the architecture mapping and document the installability boundary.
Change-source: pulse-maintainer
Release metadata contains agent, MCP and multiple architecture archives. An incomplete release must not offer an unrelated tarball or advertise an update that cannot be applied. Retain server archive names only, require the exact tag and runtime architecture, and record this installability boundary with regression coverage.
Change-source: pulse-maintainer
GitHub's release list is now ~9.6 MB because every 6.4.x release embeds
349 assets (~600 KB of JSON each). The update check buffered the whole
response behind a 1 MiB bound, so the primary path always failed and fell
through to the Atom feed. That feed holds ten entries and each release now
spends three of them, so stable-channel checks on main failed outright
("no suitable release found for channel stable") and 6.4.5 stable would
have shipped relying on it.
Decode the release list and the single-release notes response as a token
stream, keeping only tag, name, body, draft, prerelease, publish time and
the Pulse linux server archives. Memory now tracks what the updater keeps,
so the byte bound rises to 64 MiB while staying typed; the Atom feed stays
as the last resort for that over-limit condition only.
Verified against live GitHub: stable resolves v6.4.1 and rc resolves
v6.4.5-rc.3 through the primary path with no feed fallback.
Reconcile parallel release-version test fixtures while preserving the
current provider settings changes. Refresh browser verification against
the combined source before landing the diagnostic update safeguards.
Refs #1913
Release-update tests must exercise published builds even when the checkout is shallow and has no version tags. Use the existing build-version fixture instead of inferring release eligibility from the test runner checkout. Pin the source-marker case to a release version as well so it proves the marker disables updates.
Refs #1913
PR #2034 source-build exclusion exposes five tests that implicitly assume a published checkout. Give ordinary-release fixtures an explicit restored version so CI source identity cannot suppress their release discovery. Keep this correction separate from the pending diagnostic implementation and its frontend verification.
Change-source: pulse-maintainer
Diagnostic versions must keep their exact identity without advertising a published server or agent update target. Reuse the canonical source-build classification and discard cached release offers when runtime identity changes.
Show source-build status and manual image replacement guidance. Verify release and preview controls remain available.
Refs #1913
Manual checks previously bypassed only the browser cache, so a newly
published preview could remain hidden behind a fresh-looking server result.
Carry explicit freshness to the provider, retain prior evidence on failure,
and omit unknown dates instead of rendering year one.
Split the remaining non-database dependency proposal into a coherent main candidate. Retain testify's required YAML update and prove history ID round trips, OAuth refresh rotation and cancellation alongside existing concurrency consumers.
Contract-Neutral: Dependency and compatibility-test maintenance only; no authentication policy, API, deployment, platform support or privacy contract changes.
Change-source: pulse-maintainer
GitHub's Atom feed can retain entries for deleted releases. Probe the deterministic runtime archive without following redirects before exposing a feed candidate, and continue to the next published version when the candidate is stale. Keep custom update servers from falling through to the public feed on a 403.
Contract-Neutral: Update discovery validation only; no wire, persisted-data, or subsystem contract change
Refs: #1881
Change-source: pulse-maintainer
GitHub's release list now exceeds the update check's 1 MiB safety limit,
causing valid installations to report that updates are unavailable. Keep the
bound and use the existing bounded Atom feed only for the typed over-limit
condition so malformed metadata remains a hard error.
Accept both release-name and bare-tag feed entries, and do not substitute the
public GitHub feed for a configured custom update server.
Contract-Neutral: Update discovery error handling only; no wire, persisted-data, or subsystem contract change
Refs: #1881
Change-source: pulse-maintainer
normalizeVersionString assigns 0.0.0-<sanitized> to any build string it
cannot parse as a release version — a branch name, an ad-hoc test or
qualification label, an empty VERSION file — but usageDataVersionChannel
only called a build "dev" when it carried git build metadata or its
prerelease was exactly "dev" or "dev.*". Everything else fell through to
"prerelease" with version_is_development clear, so the flag did not mean
what its name says: on 2026-09-03 the receiver held 317 installs of
0.0.0-test-version and 18 of 0.0.0-dev-pro, all flagged as ordinary
prereleases, while only 0.0.0-dev was marked development. A receiver-side
read that filtered on that flag would have excluded none of them.
Pulse never publishes a 0.0.0 release, so the sentinel itself is the
signal and it now decides the channel ahead of the prerelease-text rules.
The existing test named "source branch falls back to prerelease dev
identity" asserted wantDevelopment false, encoding the defect; it is
corrected and joined by the test-version, dev-pro, qual, and empty-string
cases that reached the receiver.
A development build is also no longer reported as a published release at
the same time. The sentinel 0.0.0-rc.1 satisfies
IsPublishedReleaseAssetVersion on prerelease spelling alone, which would
have set both flags for a build that no install can be running. The guard
lives in DescribeUsageDataVersion rather than in
IsPublishedReleaseAssetVersion, which agent update logic in
internal/api/unified_agent.go also relies on and which this change should
not disturb.
Historical rows keep the values they were sent with; only builds
reporting after this lands carry the corrected identity.
The Unraid disk mergers, the Proxmox setup output classifiers, the ZFS SMART
annotator, the Docker runtime command builder, the approval decision actor
and the update retry classifier were all at zero coverage. Each decides
something a user feels directly, whether an Unraid disk keeps its name after
a merge, whether a setup run reports an already-registered token as a
failure, which pool a SMART entry is attributed to, and whether a failed
update request is retried or surfaced as an error.
Adds branch coverage for mergeUnraidDiskINI, mergeUnraidDisk,
defaultUnraidDiskName, isAlreadyExistsOutput, the client error Error and
Unwrap methods, annotateSMARTWithZFSPools, dockerRuntimeCommand,
approvalDecisionActor and isRetryableUpdateRequestError, including nil and
whitespace arms, field precedence between base and incoming values, in-place
slice mutation asserted on the caller's value, and wrapped errors that only
resolve through errors.Is and errors.As. Every named target moves from zero
to full statement coverage.
Test-only change.
PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT=test-only branch coverage, no contract surface touched
The license-server broker now serves stable and rc manifest slots
(pulse-pro side). The Pro self-updater passes channel=rc for rc-channel
installs so they track the RC slot; stable installs keep the unchanged
default URL, which older brokers also understand. The client-side guard
that refuses a prerelease pin on the stable channel stays as the backstop
for a drifted or single-manifest broker. Contract updated under
deployment-installability; fixture now pins the channel query behavior.
Production telemetry (7d, 2026-07-17) shows a persistent tail of live
installs on 6.0.0-rc.x and 6.0.3/6.0.4 while 6.0.5 has been stable since
2026-07-09. Root cause for the reachable stranding class: release
selection trusted GitHub's created_at ordering, and this repo interleaves
v5-line maintenance releases with v6 releases (v5.1.36 was created the
day before v6.0.5). Whenever a v5.1.x release is the most recently
created stable:
- stable channel (getLatestReleaseForChannel) returned the first
non-prerelease in list order -> "no update" for every v6 install
until the next v6 release ships
- the RSS rate-limit fallback returned the first feed entry matching the
channel, same failure, both channels
- the unattended updater trusted /releases/latest, which GitHub defines
as most-recently-created stable -> timer no-ops in the same window
All three now select the highest eligible version: the API path tracks
newest stable + newest prerelease in one pass (prerelease = GitHub flag
OR tag shape), the feed fallback picks the max matching the channel, and
pulse-auto-update.sh scans /releases pairing each tag with its own
draft/prerelease flags before the fail-closed shape filter, keeping
/releases/latest as fallback. helm-chart-* tags fail semver parsing and
are dropped everywhere.
Verified not broken (pinned by new tests): rc-channel installs are
offered the newer stable (6.0.0-rc.6 -> 6.0.5) and move onto the next rc
line when one opens (-> 6.1.0-rc.2); the auto-update prerelease filter
(fail-closed is_prerelease_tag, metadata-flag refusal) still refuses
prerelease targets on stable. Go tests fail on the pre-fix code
(returned 5.1.37 / "no update").
Contract: deployment-installability now pins version-max release
selection with proofs in manager_stranded_upgrade_test.go and
pulse_auto_update_test.go.
A Docker install of the Pro runtime had no sane update path: the binary
self-updater refuses to run in a container, the customer compose file pins
the previous image digest (so `docker compose pull` never updates), and the
in-app guidance showed community `docker pull rcourtman/pulse:<tag>` commands
that silently downgrade the container to the community build - the same
failure mode 93bccaff1 fixed for binary installs.
The update check on the Pro binary already fetches the license server
download broker manifest (GET /v1/downloads/pulse-pro), which carries the
digest-pinned image ref plus ready-to-run compose commands. Relay that block
on UpdateInfo.dockerUpdate for Docker deployments, behind the existing
stable/rc channel guard, failing closed unless both compose commands
reference the digest-pinned ref (never a mutable tag).
Settings -> Updates now renders those commands as copy blocks when an update
is available, and the idle Docker box, the update banner, and the docker
update plan suppress every community-image command whenever the compiled
runtime identity is pro.
Contracts: api-contracts (update transport dockerUpdate block),
deployment-installability (Pro Docker update path), frontend-primitives
(install guide Pro-runtime guidance), with transport and settings-shell
proofs extended accordingly.
The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.
The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.
Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.
Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
The only production consumer of the UpdaterRegistry is the plan endpoint
(GET /api/updates/plan); every real apply runs through the in-Go pipeline
in manager.go ApplyUpdate, with concurrency held by updateMu/updateInFlight.
The InstallShAdapter Execute/Rollback path (install.sh piping, rollback
binary download, health wait) and the UpdateQueue were never called outside
tests, so fixes made there silently did nothing. Adapters are now plan
providers only: the Updater interface keeps SupportsApply, PrepareUpdate,
and GetDeploymentType, pinned by the new registry proof in
internal/api/updates_test.go.
The orphaned fetchAndVerifyReleaseSignature helper goes with it; its
fail-closed sidecar tests now exercise the manager's
downloadAndVerifyReleaseSignature directly. The deployment-installability
sshsig invariant now names the manager pipeline as the only surface that
fetches release artifacts, and api-contracts, storage-recovery, and
agent-lifecycle pin the same plan-provider-only shape at their update
transport boundaries. Stale adapter references in build-release.sh,
validate-release.sh, and the installtests comments are updated to match.
Checksum and SSHSIG verification prove the downloaded artifact matches
what was published, not that it can run on this host or is the version
the user approved. The apply pipeline previously swapped the binary and
exited, relying on systemd to restart into an unproven executable; a
wrong-arch fallback asset or unstamped build would take Pulse down with
the backup left unused.
ApplyUpdate now locates the extracted binary and probes it with
--version before the backup and swap stages, failing the update with
zero changes applied when the probe fails or reports a version other
than the apply target. Same pattern the agent updater already uses.
Pinned in the deployment-installability contract with
internal/updates/selftest_test.go as the owned proof surface.
The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.
Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.
The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.
Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
TestUpdateDemoWorkflowUsesGovernedNetworkPath still asserted the retired
authkey: TS_AUTHKEY line; 0a9a29d63 moved update-demo-server.yml to the
OAuth client (TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET, tag:infra), so the
test now pins those lines instead.
The in-app updater and install.sh both fetch the public community build from
github.com/rcourtman/Pulse. On non-Docker Pro installs (systemd, proxmoxve,
source) the "Apply Update" button was live, so applying an update replaced the
separately compiled Pro binary with community and silently stripped Audit,
RBAC, Reporting, and SSO from a paying customer. Docker was already blocked;
nothing else was.
Add a dependency-free pkg/edition marker (defaults to community) that the Pro
binary flips via enterpriseruntime.Initialize, mirroring the existing
coreaudit.SetLogger / server.SetBusinessHooks registration seam. ApplyUpdate
now refuses when the edition is Pro, pointing at the Private Release Access
portal (https://pulserelay.pro/download.html) and the install.sh --archive
path. The gate keys off the compiled binary, not license state: a community
binary with an active license still self-updates as before.
The update banner hides the in-app apply affordance and shows portal
instructions for the Pro runtime, keyed off the existing runtime-identity
signal (runtime.build) rather than a new payload field, so nothing extra is
plumbed through the version API and the frontend reuses the canonical
"which binary am I" contract. The backend gate is the hard guarantee; the
banner is the UX layer.
Guard 2 of the Pro download/update experience spec.
Dead-code sweep. Functions flagged unreachable by golang.org/x/tools/cmd/deadcode
and confirmed unused across pulse, pulse-enterprise, pulse-pro and pulse-mobile by
adversarial cross-repo verification. Cross-module reachability was checked
explicitly (only pkg/ exported symbols are importable by other modules; internal/
packages and _test.go files are not). go build, go vet and test-compile all pass.
The unattended timer (scripts/pulse-auto-update.sh) and the public bootstrap
(scripts/install.sh, /install.sh) all verify the .sshsig sidecar against the
pinned pulse-installer ed25519 key before trusting a release artifact. The
in-app updater verified SHA256 only — same artifact, same root execution
context, lower trust bar. Closing the asymmetry: the in-app tarball download
in ApplyUpdate, adapter_installsh.go's install.sh download (piped into bash
as root), and the rollback binary download now fetch and verify the .sshsig
sidecar against the same pinned key, fail-closed.
The signing infrastructure (release_asset_common.sh, validate-release.sh,
backfill-release-assets.sh) already produces and validates these signatures
for every release; this teaches the Go updater to honor what the shell paths
have always required. ssh-keygen is shelled out to so the in-app updater
shares the exact trust path used by the unattended path, with a package-level
function variable for test injection so unit tests don't require ssh-keygen
on the build host.
Extends the deployment-installability contract's release-trust-fail-closed
invariant to cover the in-app updater paths.
SSE Broadcaster:
- Add per-client mutex to prevent concurrent writes to ResponseWriter
- Fix data race in cleanupLoop reading LastActive without synchronization
- Update LastActive in SendHeartbeat so clients aren't incorrectly pruned
after 5 minutes of idle heartbeat traffic
Alert Acknowledgements:
- Extract authenticated user from X-Authenticated-User header instead of
hardcoding 'admin' or trusting request body's User field
- Prevents audit log spoofing and ensures accurate user attribution
Security Status Endpoint:
- Remove ?token= query param validation from public /api/security/status
- Prevents endpoint from acting as a token validity oracle for attackers
- Authentication still works via session cookies and X-API-Token header