Move v5 maintenance flow onto release/5.1

This commit is contained in:
rcourtman 2026-04-14 18:34:41 +01:00
parent deee730af2
commit 324f3be1c8
31 changed files with 548 additions and 256 deletions

View file

@ -4,9 +4,11 @@ on:
push:
branches:
- main
- release/5.1
pull_request:
branches:
- main
- release/5.1
workflow_dispatch:
permissions:

View file

@ -78,6 +78,16 @@ jobs:
fi
echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)"
- name: Enforce maintenance branch for 5.1.x releases
run: |
REQUESTED_VERSION="${{ steps.extract.outputs.version }}"
CURRENT_REF="${GITHUB_REF_NAME}"
if [[ "$REQUESTED_VERSION" =~ ^5\.1\.[0-9]+([-.].*)?$ ]] && [ "$CURRENT_REF" != "release/5.1" ]; then
echo "::error::5.1.x releases must be cut from release/5.1 (current ref: $CURRENT_REF)"
exit 1
fi
echo "[OK] Release ref ${CURRENT_REF} is valid for ${REQUESTED_VERSION}"
# Frontend checks run in parallel with backend tests
frontend_checks:
needs: prepare
@ -497,7 +507,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
gh workflow run publish-docker.yml -f tag="${{ needs.prepare.outputs.tag }}"
gh workflow run publish-docker.yml \
--ref "${GITHUB_REF_NAME}" \
-f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Docker publish workflow dispatched"
- name: Trigger demo server update
@ -506,7 +518,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
gh workflow run update-demo-server.yml -f tag="${{ needs.prepare.outputs.tag }}"
gh workflow run update-demo-server.yml \
--ref "${GITHUB_REF_NAME}" \
-f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Demo server update dispatched"
- name: Summary

View file

@ -2,7 +2,7 @@ name: Helm CI
on:
push:
branches: [main]
branches: [main, release/5.1]
paths:
- "deploy/helm/**"
- ".github/workflows/helm-ci.yml"

View file

@ -4,6 +4,7 @@ on:
pull_request:
branches:
- main
- release/5.1
paths:
- 'frontend-modern/**'
- 'internal/**'
@ -13,6 +14,7 @@ on:
push:
branches:
- main
- release/5.1
- master
paths:
- 'frontend-modern/**'

View file

@ -15,6 +15,7 @@ on:
push:
branches:
- main
- release/5.1
- master
paths:
- 'internal/updates/**'

View file

@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to deploy (e.g., v4.29.0)'
description: 'Release tag to deploy (e.g., v5.1.28)'
required: true
type: string
@ -37,12 +37,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
TARGET="${{ steps.target.outputs.tag }}"
LATEST=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')
LATEST=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --jq 'map(select(.draft == false and .prerelease == false and (.tag_name | test("^v5\\.1\\.[0-9]+$"))))[0].tag_name')
echo "Target tag: $TARGET"
echo "Latest published tag: $LATEST"
echo "Latest published v5.1 tag: $LATEST"
if [ "$TARGET" != "$LATEST" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Release is not the latest; skipping demo update."
echo "Release is not the latest v5.1 stable release; skipping demo update."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
@ -55,7 +55,7 @@ jobs:
else
echo "Release: ${{ github.event.release.tag_name }}"
echo "Prerelease: ${{ github.event.release.prerelease }}"
echo "Updating demo server to latest stable release..."
echo "Updating demo server to latest stable v5.1 release..."
fi
- name: Wait for release assets
@ -123,7 +123,7 @@ jobs:
TAG="${{ steps.target.outputs.tag }}"
# Use set -o pipefail to ensure curl errors aren't masked by bash
ssh -i ~/.ssh/id_ed25519 ${{ secrets.DEMO_SERVER_USER }}@${{ secrets.DEMO_SERVER_HOST }} \
"set -o pipefail && curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash -s -- --version $TAG"
"set -o pipefail && curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | sudo bash -s -- --version $TAG"
- name: Verify update
if: steps.gate.outputs.skip != 'true' && steps.current.outputs.skip_current != 'true'

View file

@ -83,7 +83,7 @@ If you use the legacy `docker-compose` binary, replace `docker compose` with `do
### ProxmoxVE LXC (Manual)
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | bash
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.
@ -91,7 +91,7 @@ This script installs/updates the **Pulse server**. Agent updates use the `/insta
### Systemd Service (Manual)
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | bash
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.

View file

@ -6,7 +6,7 @@
If you run Proxmox VE, use the official LXC installer (recommended):
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | bash
```
Note: this installs the Pulse **server**. Agent installs use the command from **Settings → Agents → Installation commands** (served from `/install.sh` on your Pulse server).

View file

@ -10,7 +10,7 @@ If you run Proxmox VE, the easiest and most “Pulse-native” deployment is the
Run this on your Proxmox host:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | bash
```
> **Note**: The GitHub `install.sh` is the **server** installer. The agent installer is served from your Pulse server at `/install.sh` (see **Settings → Agents → Installation commands**).
@ -72,7 +72,7 @@ See [KUBERNETES.md](KUBERNETES.md) for ingress and persistence configuration.
For Linux servers (VM or bare metal), use the official installer:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | sudo bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | sudo bash
```
> **Note**: This installs the Pulse server. Use the `/install.sh` endpoint from your Pulse UI for installing `pulse-agent` on monitored hosts.

View file

@ -80,14 +80,14 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge
```
If you also want to remove the `pulse-monitor@pam` API user/tokens for a full clean slate before re-adding the node, include `--remove-proxmox-access`:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --remove-proxmox-access
```

View file

@ -19,7 +19,7 @@ Preferred path:
If you prefer CLI, use the official installer for the target version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | \
sudo bash -s -- --version vX.Y.Z
```
@ -61,7 +61,7 @@ If you reset auth (for example by deleting `.env`), Pulse may require a bootstra
The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring is now handled by the unified agent. If you had the sensor proxy installed on your Proxmox hosts, remove it **on each host** after upgrading:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge
```

View file

@ -42,7 +42,7 @@ If guest disk values only populate for the first part of a large VM list, tune t
### Diagnostic Script
Run this on your Proxmox host to debug specific VMs:
```bash
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/scripts/test-vm-disk.sh | bash
```
## 📝 Notes

View file

@ -11,7 +11,7 @@ Manage Pulse auto-updates on host-mode installations.
| `pulse-update.service` | Runs the update script. |
| `pulse-auto-update.sh` | Fetches release & restarts Pulse (`/opt/pulse/scripts/pulse-auto-update.sh`). |
**Release channel note:** the systemd timer script tracks GitHub `releases/latest` (stable). RC channel settings only affect the in-app update checker.
**Release channel note:** the systemd timer script tracks the latest stable `5.1.x` release. RC channel settings only affect the in-app update checker.
## 🚀 Enable/Disable
@ -46,7 +46,7 @@ If an update fails:
2. The timer script keeps a temporary backup under `/tmp/pulse-backup-<timestamp>` during the update; failures auto-restore from that backup and then clean it up.
3. If you need to pin a specific version, re-run the installer with a version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | \
sudo bash -s -- --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.

View file

@ -9,6 +9,7 @@ import Download from 'lucide-solid/icons/download';
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
import {
V6_RC_ANNOUNCEMENT,
V5_MAINTENANCE_BRANCH,
isV5ReleaseLine,
} from '@/constants/releaseAnnouncements';
@ -498,11 +499,11 @@ sudo tar -xzf pulse-v${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
git pull origin main
{`git pull origin ${V5_MAINTENANCE_BRANCH}`}
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('git pull origin main')}
onClick={() => navigator.clipboard.writeText(`git pull origin ${V5_MAINTENANCE_BRANCH}`)}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-60 hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>

View file

@ -1,5 +1,7 @@
import type { SecurityStatus } from '@/types/config';
export const V5_MAINTENANCE_BRANCH = 'release/5.1';
export const V6_RC_ANNOUNCEMENT = {
id: 'v6-rc-testing-v6.0.0-rc.1',
tag: 'v6.0.0-rc.1',

View file

@ -25,7 +25,7 @@ ENABLE_AUTO_UPDATES=false
FORCE_VERSION=""
FORCE_CHANNEL=""
ARCHIVE_OVERRIDE="${PULSE_ARCHIVE_PATH:-}"
SOURCE_BRANCH="main"
SOURCE_BRANCH="release/5.1"
CURRENT_INSTALL_CTID=""
CONTAINER_CREATED_FOR_CLEANUP=false
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
@ -45,8 +45,11 @@ DEBIAN_TEMPLATE=""
get_latest_release_from_redirect() {
# Follow the GitHub "latest" redirect and extract the tag in a way that
# tolerates intermediate redirects that omit /tag/ (issue #698).
local target_url="${1:-https://github.com/$GITHUB_REPO/releases/latest}"
local target_url="${1:-}"
local effective_url=""
if [[ -z "$target_url" ]]; then
return 1
fi
local curl_cmd=(curl -fsSL --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "$target_url")
if command -v timeout >/dev/null 2>&1; then
@ -77,6 +80,62 @@ get_latest_release_from_redirect() {
return 0
}
maintenance_raw_url() {
local path="$1"
printf 'https://raw.githubusercontent.com/%s/%s/%s\n' "$GITHUB_REPO" "$SOURCE_BRANCH" "$path"
}
get_latest_maintenance_stable_version() {
local latest_version=""
local releases_json=""
local feed_xml=""
local api_url="https://api.github.com/repos/$GITHUB_REPO/releases"
local feed_url="https://github.com/$GITHUB_REPO/releases.atom"
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 10 curl -fsSL --connect-timeout 5 --max-time 10 "$api_url" 2>/dev/null || true)
else
releases_json=$(curl -fsSL --connect-timeout 5 --max-time 10 "$api_url" 2>/dev/null || true)
fi
if [[ -n "$releases_json" ]]; then
latest_version=$(printf '%s' "$releases_json" | grep -oE '"tag_name":[[:space:]]*"v5\.1\.[0-9]+"' | head -1 | sed -E 's/.*"([^"]+)"/\1/' || true)
fi
if [[ -z "$latest_version" ]]; then
if command -v timeout >/dev/null 2>&1; then
feed_xml=$(timeout 10 curl -fsSL --connect-timeout 5 --max-time 10 "$feed_url" 2>/dev/null || true)
else
feed_xml=$(curl -fsSL --connect-timeout 5 --max-time 10 "$feed_url" 2>/dev/null || true)
fi
if [[ -n "$feed_xml" ]]; then
latest_version=$(printf '%s' "$feed_xml" | grep -oE '<title>Pulse v5\.1\.[0-9]+</title>' | head -1 | sed -E 's#<title>Pulse (v[^<]+)</title>#\1#' || true)
fi
fi
printf '%s\n' "$latest_version"
}
resolve_bootstrap_install_script_url() {
local requested_version="${1:-}"
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
maintenance_raw_url "install.sh"
return
fi
if [[ -n "$requested_version" ]]; then
if [[ "$requested_version" != v* ]]; then
requested_version="v${requested_version}"
fi
printf 'https://github.com/%s/releases/download/%s/install.sh\n' "$GITHUB_REPO" "$requested_version"
return
fi
maintenance_raw_url "install.sh"
}
detect_lxc_ctid() {
local ctid=""
@ -1403,7 +1462,8 @@ create_lxc_container() {
local script_source="/tmp/pulse_install_$$.sh"
if [[ "$0" == "bash" ]] || [[ ! -f "$0" ]]; then
# We're being piped, download the script with retry logic
local download_url="https://github.com/rcourtman/Pulse/releases/latest/download/install.sh"
local download_url
download_url=$(resolve_bootstrap_install_script_url "$FORCE_VERSION")
local download_success=false
local download_error=""
local max_retries=3
@ -3120,9 +3180,9 @@ fi
echo "Updating Pulse..."
if [[ ${#extra_args[@]} -gt 0 ]]; then
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- "${extra_args[@]}"
curl -fsSL "$(maintenance_raw_url "install.sh")" | bash -s -- "${extra_args[@]}"
else
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
curl -fsSL "$(maintenance_raw_url "install.sh")" | bash
fi
echo ""
@ -3143,7 +3203,8 @@ EOF
}
download_auto_update_script() {
local url="https://github.com/$GITHUB_REPO/releases/latest/download/pulse-auto-update.sh"
local url
url=$(maintenance_raw_url "scripts/pulse-auto-update.sh")
local dest="/usr/local/bin/pulse-auto-update.sh"
local attempts=0
local max_attempts=3
@ -3407,9 +3468,9 @@ print_completion() {
echo " journalctl -u $SERVICE_NAME -f - View logs"
echo
echo -e "${YELLOW}Management:${NC}"
echo " Update: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash"
echo " Reset: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- --reset"
echo " Uninstall: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- --uninstall"
echo " Update: curl -sSL $(maintenance_raw_url "install.sh") | bash"
echo " Reset: curl -sSL $(maintenance_raw_url "install.sh") | bash -s -- --reset"
echo " Uninstall: curl -sSL $(maintenance_raw_url "install.sh") | bash -s -- --uninstall"
# Show auto-update status if timer exists
if systemctl list-unit-files --no-legend | grep -q "^pulse-update.timer"; then
@ -3569,23 +3630,9 @@ main() {
fi
# Get both stable and RC versions
# Try GitHub API first, but have a fallback - with timeout protection
# Stable is explicitly pinned to the v5.1 maintenance line.
local STABLE_VERSION=""
if command -v timeout >/dev/null 2>&1; then
STABLE_VERSION=$(timeout 10 curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases/latest 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true)
else
STABLE_VERSION=$(curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases/latest 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
# If rate limited or failed, try direct GitHub latest URL
if [[ -z "$STABLE_VERSION" ]] || [[ "$STABLE_VERSION" == *"rate limit"* ]]; then
# Use the GitHub latest release redirect to get version
local redirect_version=""
redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true)
if [[ -n "$redirect_version" ]]; then
STABLE_VERSION="$redirect_version"
fi
fi
STABLE_VERSION=$(get_latest_maintenance_stable_version)
# For RC, we need the API, so if it fails just use empty
local RC_VERSION=""
@ -4088,7 +4135,7 @@ parse_args() {
SOURCE_BRANCH="$2"
shift 2
else
SOURCE_BRANCH="main"
SOURCE_BRANCH="release/5.1"
shift
fi
;;
@ -4100,7 +4147,7 @@ parse_args() {
echo " --stable Install latest stable version (default)"
echo " --version VERSION Install specific version (e.g., v4.4.0-rc.1)"
echo " --archive PATH Install from a local Pulse release tarball"
echo " --source [BRANCH] Build and install from source (default: main)"
echo " --source [BRANCH] Build and install from source (default: release/5.1)"
echo " --enable-auto-updates Enable automatic stable updates (via systemd timer)"
echo ""
echo "Management options:"

View file

@ -3452,9 +3452,9 @@ resource_id format: {instance}:{node}:{vmid} (colons, not dashes)
Example: "delly:minipc:201" for VMID 201 on node minipc in instance delly
## Installing/Updating Pulse
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/install.sh | bash
After: systemctl enable pulse && systemctl start pulse
Latest version: https://api.github.com/repos/rcourtman/Pulse/releases/latest`
Stable v5 line: https://github.com/rcourtman/Pulse/releases?q=v5.1`
// Add custom context from AI settings (user's infrastructure description)
s.mu.RLock()

View file

@ -146,7 +146,8 @@ func TestHandleDownloadHostAgent_ProxyFromGitHub(t *testing.T) {
binDir := setupTempPulseBin(t)
router := &Router{
projectRoot: t.TempDir(),
installScriptClient: newTestInstallScriptClient(t, "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-freebsd-amd64", http.StatusOK, "freebsd-binary", nil),
serverVersion: "5.1.28",
installScriptClient: newTestInstallScriptClient(t, "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/pulse-host-agent-freebsd-amd64", http.StatusOK, "freebsd-binary", nil),
}
for _, path := range []string{
@ -183,7 +184,8 @@ func TestHandleDownloadHostAgentChecksum_ProxyFromGitHub(t *testing.T) {
binDir := setupTempPulseBin(t)
router := &Router{
projectRoot: t.TempDir(),
installScriptClient: newTestInstallScriptClient(t, "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-freebsd-amd64", http.StatusOK, "freebsd-binary", nil),
serverVersion: "5.1.28",
installScriptClient: newTestInstallScriptClient(t, "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/pulse-host-agent-freebsd-amd64", http.StatusOK, "freebsd-binary", nil),
}
for _, path := range []string{

View file

@ -3,6 +3,7 @@ package api
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
@ -10,9 +11,44 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rs/zerolog/log"
)
const v5MaintenanceScriptRef = "release/5.1"
func releaseAssetTagFromServerVersion(serverVersion string) string {
version := strings.TrimSpace(serverVersion)
if version == "" || strings.EqualFold(version, "dev") {
return ""
}
parsed, err := updates.ParseVersion(version)
if err != nil {
return ""
}
if parsed.Major == 0 && parsed.Minor == 0 && parsed.Patch == 0 {
return ""
}
tag := fmt.Sprintf("v%d.%d.%d", parsed.Major, parsed.Minor, parsed.Patch)
if parsed.Prerelease != "" {
tag += "-" + parsed.Prerelease
}
return tag
}
func releaseAssetGitHubURL(assetName, serverVersion string) string {
tag := releaseAssetTagFromServerVersion(serverVersion)
if tag == "" {
return ""
}
return "https://github.com/rcourtman/Pulse/releases/download/" + tag + "/" + assetName
}
// handleDownloadUnifiedInstallScript serves the universal install.sh script
func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
@ -106,6 +142,14 @@ func normalizeUnifiedAgentArch(arch string) string {
}
}
func installScriptGitHubURL(scriptName, serverVersion string) string {
if releaseURL := releaseAssetGitHubURL(scriptName, serverVersion); releaseURL != "" {
return releaseURL
}
return "https://raw.githubusercontent.com/rcourtman/Pulse/" + v5MaintenanceScriptRef + "/scripts/" + scriptName
}
// handleDownloadUnifiedAgent serves the pulse-agent binary
func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
@ -199,7 +243,12 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
if strings.HasPrefix(normalized, "windows-") {
binaryName += ".exe"
}
githubURL := "https://github.com/rcourtman/Pulse/releases/latest/download/" + binaryName
githubURL := releaseAssetGitHubURL(binaryName, r.serverVersion)
if githubURL == "" {
log.Error().Str("serverVersion", r.serverVersion).Msg("Cannot proxy agent binary without a released server version")
http.Error(w, "Agent binary unavailable for non-release server builds", http.StatusServiceUnavailable)
return
}
log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases")
@ -256,7 +305,12 @@ func (r *Router) proxyHostAgentBinaryFromGitHub(w http.ResponseWriter, req *http
if platform == "windows" {
binaryName += ".exe"
}
githubURL := "https://github.com/rcourtman/Pulse/releases/latest/download/" + binaryName
githubURL := releaseAssetGitHubURL(binaryName, r.serverVersion)
if githubURL == "" {
log.Error().Str("serverVersion", r.serverVersion).Msg("Cannot proxy host agent binary without a released server version")
http.Error(w, "Host agent binary unavailable for non-release server builds", http.StatusServiceUnavailable)
return
}
log.Info().
Str("platform", platform).
@ -319,8 +373,7 @@ func (r *Router) proxyHostAgentBinaryFromGitHub(w http.ResponseWriter, req *http
// proxyInstallScriptFromGitHub fetches an install script from GitHub releases
// This is used as a fallback when scripts aren't available locally (e.g., LXC updates)
func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.Request, scriptName string) {
// Use raw.githubusercontent.com to fetch from main branch
githubURL := "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/" + scriptName
githubURL := installScriptGitHubURL(scriptName, r.serverVersion)
client := r.installScriptClient
if client == nil {

View file

@ -66,7 +66,7 @@ func TestDownloadUnifiedInstallScriptPS_MethodNotAllowed(t *testing.T) {
func TestDownloadUnifiedInstallScript_ProxyFallback(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
expectedURL := "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install.sh"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClient(t, expectedURL, http.StatusOK, payload, nil)
@ -94,7 +94,7 @@ func TestDownloadUnifiedInstallScript_ProxyFallback(t *testing.T) {
func TestDownloadUnifiedInstallScriptPS_ProxyFallback(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
expectedURL := "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install.ps1"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/install.ps1"
payload := "Write-Host 'hi'"
router.installScriptClient = newTestInstallScriptClient(t, expectedURL, http.StatusOK, payload, nil)
@ -114,6 +114,26 @@ func TestDownloadUnifiedInstallScriptPS_ProxyFallback(t *testing.T) {
}
}
func TestDownloadUnifiedInstallScript_ProxyFallback_DevBuildUsesMaintenanceBranch(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = ""
expectedURL := "https://raw.githubusercontent.com/rcourtman/Pulse/release/5.1/scripts/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClient(t, expectedURL, http.StatusOK, payload, nil)
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if strings.TrimSpace(w.Body.String()) != payload {
t.Fatalf("unexpected response body")
}
}
func TestProxyInstallScriptFromGitHub_NonOK(t *testing.T) {
router := &Router{
installScriptClient: newTestInstallScriptClient(t, "", http.StatusNotFound, "", nil),

View file

@ -25,6 +25,7 @@ func setupUnifiedAgentRouter(t *testing.T) (*Router, string) {
router := &Router{
projectRoot: tempDir,
serverVersion: "5.1.28",
checksumCache: make(map[string]checksumCacheEntry),
}
@ -130,7 +131,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub(t *testing.T) {
// Ensure NO local files exist (temp dir is empty of binaries)
// Set up a mock HTTP client to simulate GitHub response
binaryContent := "fake binary content for proxy test"
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/pulse-agent-linux-amd64"
router.installScriptClient = newTestInstallScriptClient(t, expectedURL, http.StatusOK, binaryContent, nil)
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
@ -153,7 +154,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
binaryContent := "MZ fake windows binary"
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-windows-amd64.exe"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/pulse-agent-windows-amd64.exe"
router.installScriptClient = newTestInstallScriptClient(t, expectedURL, http.StatusOK, binaryContent, nil)
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=windows-amd64", nil)
@ -216,3 +217,23 @@ func TestNormalizeUnifiedAgentArch(t *testing.T) {
})
}
}
func TestReleaseAssetTagFromServerVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{name: "release", version: "5.1.28", want: "v5.1.28"},
{name: "prerelease", version: "5.1.29-rc.1", want: "v5.1.29-rc.1"},
{name: "build metadata stripped", version: "5.1.28+git.2.gabc123", want: "v5.1.28"},
{name: "zero dev version rejected", version: "0.0.0-dev", want: ""},
{name: "empty", version: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, releaseAssetTagFromServerVersion(tt.version))
})
}
}

View file

@ -19,17 +19,17 @@ import (
// InstallShAdapter wraps the install.sh script for systemd/LXC deployments
type InstallShAdapter struct {
history *UpdateHistory
installScriptURL string
logDir string
history *UpdateHistory
releaseAssetBaseURL string
logDir string
}
// NewInstallShAdapter creates a new install.sh adapter
func NewInstallShAdapter(history *UpdateHistory) *InstallShAdapter {
return &InstallShAdapter{
history: history,
installScriptURL: "https://github.com/rcourtman/Pulse/releases/latest/download/install.sh",
logDir: "/var/log/pulse",
history: history,
releaseAssetBaseURL: "https://github.com/rcourtman/Pulse/releases/download",
logDir: "/var/log/pulse",
}
}
@ -88,12 +88,21 @@ func (a *InstallShAdapter) Execute(ctx context.Context, request UpdateRequest, p
Message: "Downloading installation script...",
})
// Validate version string to prevent command injection
// Version must match semantic versioning format (with optional 'v' prefix)
versionPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$`)
if !versionPattern.MatchString(request.Version) {
return fmt.Errorf("invalid version format: %s", request.Version)
}
installScriptURL := a.installScriptURLForVersion(request.Version)
log.Info().
Str("url", a.installScriptURL).
Str("url", installScriptURL).
Str("version", request.Version).
Msg("Downloading install script")
installScript, err := a.downloadInstallScript(ctx)
installScript, err := a.downloadInstallScript(ctx, installScriptURL)
if err != nil {
return fmt.Errorf("failed to download install script: %w", err)
}
@ -105,13 +114,6 @@ func (a *InstallShAdapter) Execute(ctx context.Context, request UpdateRequest, p
Message: "Preparing update...",
})
// Validate version string to prevent command injection
// Version must match semantic versioning format (with optional 'v' prefix)
versionPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$`)
if !versionPattern.MatchString(request.Version) {
return fmt.Errorf("invalid version format: %s", request.Version)
}
// Build command: bash install.sh --version vX.Y.Z
args := []string{"-s", "--", "--version", request.Version}
if request.Force {
@ -211,6 +213,14 @@ func (a *InstallShAdapter) Execute(ctx context.Context, request UpdateRequest, p
return nil
}
func (a *InstallShAdapter) installScriptURLForVersion(version string) string {
tag := strings.TrimSpace(version)
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
return fmt.Sprintf("%s/%s/install.sh", a.releaseAssetBaseURL, tag)
}
// Rollback rolls back to a previous version
func (a *InstallShAdapter) Rollback(ctx context.Context, eventID string) error {
// Get the event from history
@ -535,7 +545,7 @@ func (a *InstallShAdapter) waitForHealth(ctx context.Context, timeout time.Durat
}
// downloadInstallScript downloads the install.sh script
func (a *InstallShAdapter) downloadInstallScript(ctx context.Context) (string, error) {
func (a *InstallShAdapter) downloadInstallScript(ctx context.Context, installScriptURL string) (string, error) {
tmpDir, err := os.MkdirTemp("", "pulse-installsh-*")
if err != nil {
return "", err
@ -544,12 +554,12 @@ func (a *InstallShAdapter) downloadInstallScript(ctx context.Context) (string, e
scriptPath := filepath.Join(tmpDir, "install.sh")
cmd := exec.CommandContext(ctx, "curl", "-fsSL", "-o", scriptPath, a.installScriptURL)
cmd := exec.CommandContext(ctx, "curl", "-fsSL", "-o", scriptPath, installScriptURL)
if err := cmd.Run(); err != nil {
return "", err
}
checksumURL := a.installScriptURL + ".sha256"
checksumURL := installScriptURL + ".sha256"
checksumPath := scriptPath + ".sha256"
cmd = exec.CommandContext(ctx, "curl", "-fsSL", "-o", checksumPath, checksumURL)
if err := cmd.Run(); err != nil {

View file

@ -71,8 +71,8 @@ func TestInstallShAdapterExecuteSuccess(t *testing.T) {
t.Setenv("PATH", strings.Join([]string{curlDir, bashDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
adapter := &InstallShAdapter{
installScriptURL: "http://example/install.sh",
logDir: t.TempDir(),
releaseAssetBaseURL: "http://example/releases/download",
logDir: t.TempDir(),
}
var updates []UpdateProgress
@ -105,8 +105,8 @@ func TestInstallShAdapterExecuteInvalidVersion(t *testing.T) {
t.Setenv("PATH", curlDir+string(os.PathListSeparator)+os.Getenv("PATH"))
adapter := &InstallShAdapter{
installScriptURL: "http://example/install.sh",
logDir: t.TempDir(),
releaseAssetBaseURL: "http://example/releases/download",
logDir: t.TempDir(),
}
err := adapter.Execute(context.Background(), UpdateRequest{Version: "bad version"}, func(UpdateProgress) {})

View file

@ -96,7 +96,7 @@ func TestInstallShAdapterDownloadInstallScript(t *testing.T) {
t.Setenv("PULSE_TEST_SHA", checksumPath)
adapter := NewInstallShAdapter(nil)
got, err := adapter.downloadInstallScript(context.Background())
got, err := adapter.downloadInstallScript(context.Background(), adapter.installScriptURLForVersion("v1.2.3"))
if err != nil {
t.Fatalf("downloadInstallScript error: %v", err)
}
@ -107,7 +107,7 @@ func TestInstallShAdapterDownloadInstallScript(t *testing.T) {
if err := os.WriteFile(checksumPath, []byte("deadbeef"), 0600); err != nil {
t.Fatalf("write checksum: %v", err)
}
if _, err := adapter.downloadInstallScript(context.Background()); err == nil {
if _, err := adapter.downloadInstallScript(context.Background(), adapter.installScriptURLForVersion("v1.2.3")); err == nil {
t.Fatalf("expected checksum error")
}
}

View file

@ -69,8 +69,8 @@ func TestInstallShAdapter_DownloadInstallScript(t *testing.T) {
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
adapter := &InstallShAdapter{installScriptURL: "http://example/install.sh"}
out, err := adapter.downloadInstallScript(context.Background())
adapter := &InstallShAdapter{releaseAssetBaseURL: "http://example/releases/download"}
out, err := adapter.downloadInstallScript(context.Background(), adapter.installScriptURLForVersion("v1.2.3"))
if err != nil {
t.Fatalf("downloadInstallScript error: %v", err)
}

View file

@ -478,17 +478,17 @@ func TestNewInstallShAdapter(t *testing.T) {
t.Fatal("NewInstallShAdapter returned nil")
}
if adapter.installScriptURL == "" {
t.Error("installScriptURL should not be empty")
if adapter.releaseAssetBaseURL == "" {
t.Error("releaseAssetBaseURL should not be empty")
}
if adapter.logDir == "" {
t.Error("logDir should not be empty")
}
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/install.sh"
if adapter.installScriptURL != expectedURL {
t.Errorf("installScriptURL = %q, want %q", adapter.installScriptURL, expectedURL)
expectedURL := "https://github.com/rcourtman/Pulse/releases/download"
if adapter.releaseAssetBaseURL != expectedURL {
t.Errorf("releaseAssetBaseURL = %q, want %q", adapter.releaseAssetBaseURL, expectedURL)
}
expectedLogDir := "/var/log/pulse"
@ -496,3 +496,29 @@ func TestNewInstallShAdapter(t *testing.T) {
t.Errorf("logDir = %q, want %q", adapter.logDir, expectedLogDir)
}
}
func TestInstallShAdapter_InstallScriptURLForVersion(t *testing.T) {
adapter := NewInstallShAdapter(nil)
tests := []struct {
version string
want string
}{
{
version: "5.1.28",
want: "https://github.com/rcourtman/Pulse/releases/download/v5.1.28/install.sh",
},
{
version: "v5.1.29-rc.1",
want: "https://github.com/rcourtman/Pulse/releases/download/v5.1.29-rc.1/install.sh",
},
}
for _, tc := range tests {
t.Run(tc.version, func(t *testing.T) {
if got := adapter.installScriptURLForVersion(tc.version); got != tc.want {
t.Fatalf("installScriptURLForVersion(%q) = %q, want %q", tc.version, got, tc.want)
}
})
}
}

View file

@ -588,6 +588,80 @@ func (m *Manager) GetCachedUpdateInfo() *UpdateInfo {
return m.checkCache[channel]
}
func releaseMatchesCurrentLine(releaseVer, currentVer *Version) bool {
if releaseVer == nil || currentVer == nil {
return false
}
if currentVer.Major != 5 || currentVer.Minor != 1 {
return true
}
return releaseVer.Major == currentVer.Major && releaseVer.Minor == currentVer.Minor
}
func selectLatestReleaseForChannel(releases []ReleaseInfo, channel string, currentVer *Version) (*ReleaseInfo, error) {
var newestRC *ReleaseInfo
var newestRCVer *Version
var newestStable *ReleaseInfo
var newestStableVer *Version
for i := range releases {
if releases[i].Draft {
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
continue
}
releaseVer, err := ParseVersion(releases[i].TagName)
if err != nil {
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
continue
}
if !releaseMatchesCurrentLine(releaseVer, currentVer) {
log.Debug().
Str("tag", releases[i].TagName).
Int("currentMajor", currentVer.Major).
Int("currentMinor", currentVer.Minor).
Msg("Skipping release outside current release line")
continue
}
if releases[i].Prerelease {
if channel != "rc" {
continue
}
if newestRCVer == nil || releaseVer.IsNewerThan(newestRCVer) {
newestRC = &releases[i]
newestRCVer = releaseVer
}
continue
}
if newestStableVer == nil || releaseVer.IsNewerThan(newestStableVer) {
newestStable = &releases[i]
newestStableVer = releaseVer
}
}
if channel == "rc" {
if newestStable != nil && newestRC != nil {
if newestStableVer.IsNewerThan(newestRCVer) {
return newestStable, nil
}
return newestRC, nil
}
if newestStable != nil {
return newestStable, nil
}
if newestRC != nil {
return newestRC, nil
}
} else if newestStable != nil {
return newestStable, nil
}
return nil, fmt.Errorf("no releases found for channel %s in %d.%d.x line", channel, currentVer.Major, currentVer.Minor)
}
// getLatestReleaseForChannel fetches the latest release from GitHub for a specific channel
func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string, currentVer *Version) (*ReleaseInfo, error) {
if channel == "" {
@ -633,7 +707,7 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
Msg("GitHub API rate limit encountered, trying RSS fallback")
// Try RSS/Atom feed as fallback - doesn't count against rate limits
if feedRelease, err := m.getLatestReleaseFromFeed(ctx, channel); err == nil {
if feedRelease, err := m.getLatestReleaseFromFeed(ctx, channel, currentVer); err == nil {
log.Info().Str("version", feedRelease.TagName).Msg("Got release info from RSS feed fallback")
return feedRelease, nil
}
@ -660,123 +734,34 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
return nil, fmt.Errorf("failed to decode releases: %w", err)
}
// Find latest release based on channel
// RC channel: return newest release (RC or stable), even if not newer than current
// Stable channel: return newest stable release, even if not newer than current
// The caller will determine if it's actually an update by comparing versions
if channel == "rc" {
// For RC channel: find newest release (RC or stable)
// RC users should see both RCs and stable releases
var newestRC *ReleaseInfo
var newestStable *ReleaseInfo
for i := range releases {
// Skip draft releases
if releases[i].Draft {
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
continue
}
releaseVer, err := ParseVersion(releases[i].TagName)
if err != nil {
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
continue
}
if releases[i].Prerelease {
// Track newest RC
if newestRC == nil {
newestRC = &releases[i]
} else {
newestRCVer, _ := ParseVersion(newestRC.TagName)
if releaseVer.IsNewerThan(newestRCVer) {
newestRC = &releases[i]
}
}
} else {
// Track newest stable
if newestStable == nil {
newestStable = &releases[i]
} else {
newestStableVer, _ := ParseVersion(newestStable.TagName)
if releaseVer.IsNewerThan(newestStableVer) {
newestStable = &releases[i]
}
}
}
}
// Return the highest version among candidates
// Stable versions are considered higher than RCs (4.22.0 > 4.22.0-rc.3)
if newestStable != nil && newestRC != nil {
stableVer, _ := ParseVersion(newestStable.TagName)
rcVer, _ := ParseVersion(newestRC.TagName)
if stableVer.IsNewerThan(rcVer) {
isUpdate := stableVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
} else {
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
}
return newestStable, nil
}
isUpdate := rcVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
} else {
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
}
return newestRC, nil
} else if newestStable != nil {
isUpdate := newestStable.TagName != currentVer.String()
if isUpdate {
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
} else {
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
}
return newestStable, nil
} else if newestRC != nil {
isUpdate := newestRC.TagName != currentVer.String()
if isUpdate {
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
} else {
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
}
return newestRC, nil
}
} else {
// For stable channel: find latest non-prerelease
for i := range releases {
// Skip draft releases
if releases[i].Draft {
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
continue
}
if releases[i].Prerelease {
continue
}
releaseVer, err := ParseVersion(releases[i].TagName)
if err != nil {
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
continue
}
// Found the latest stable release
isUpdate := releaseVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", releases[i].TagName).Msg("Found stable update")
} else {
log.Info().Str("version", releases[i].TagName).Msg("On latest stable version")
}
return &releases[i], nil
}
release, err := selectLatestReleaseForChannel(releases, channel, currentVer)
if err != nil {
log.Warn().
Str("channel", channel).
Int("currentMajor", currentVer.Major).
Int("currentMinor", currentVer.Minor).
Msg("No releases found for channel in current release line")
return nil, err
}
// No releases found at all for this channel
log.Warn().Str("channel", channel).Msg("No releases found for channel")
return nil, fmt.Errorf("no releases found for channel %s", channel)
releaseVer, err := ParseVersion(release.TagName)
if err != nil {
return nil, fmt.Errorf("failed to parse selected release version: %w", err)
}
if releaseVer.IsNewerThan(currentVer) {
if release.Prerelease {
log.Info().Str("version", release.TagName).Msg("Found prerelease update")
} else {
log.Info().Str("version", release.TagName).Msg("Found stable update")
}
} else if release.Prerelease {
log.Info().Str("version", release.TagName).Msg("On latest prerelease version")
} else {
log.Info().Str("version", release.TagName).Msg("On latest stable version")
}
return release, nil
}
func (m *Manager) resolveChannel(requested string, currentInfo *VersionInfo) string {
@ -795,7 +780,7 @@ func (m *Manager) resolveChannel(requested string, currentInfo *VersionInfo) str
// getLatestReleaseFromFeed fetches the latest release from GitHub's Atom feed
// This is used as a fallback when the API is rate-limited, as the Atom feed
// doesn't count against API rate limits.
func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) (*ReleaseInfo, error) {
func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string, currentVer *Version) (*ReleaseInfo, error) {
feedURL := "https://github.com/rcourtman/Pulse/releases.atom"
req, err := http.NewRequestWithContext(ctx, "GET", feedURL, nil)
@ -834,7 +819,7 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string)
return nil, fmt.Errorf("no version tags found in feed")
}
// Filter based on channel
var releases []ReleaseInfo
for _, match := range matches {
if len(match) < 2 {
continue
@ -849,28 +834,27 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string)
isPrerelease := ver.IsPrerelease()
// For stable channel, skip prereleases
if channel == "stable" && isPrerelease {
continue
}
// Found a valid release for this channel
log.Debug().
Str("tag", tagName).
Bool("prerelease", isPrerelease).
Str("channel", channel).
Msg("Found release from feed")
return &ReleaseInfo{
releases = append(releases, ReleaseInfo{
TagName: tagName,
Name: "Pulse " + tagName,
Prerelease: isPrerelease,
// Note: Feed doesn't include full release notes or asset info
// This is just for version checking - actual download still uses known URL patterns
}, nil
})
}
return nil, fmt.Errorf("no suitable release found for channel %s", channel)
release, err := selectLatestReleaseForChannel(releases, channel, currentVer)
if err != nil {
return nil, err
}
log.Debug().
Str("tag", release.TagName).
Bool("prerelease", release.Prerelease).
Str("channel", channel).
Msg("Found release from feed")
return release, nil
}
func (m *Manager) createHistoryEntry(ctx context.Context, entry UpdateHistoryEntry) string {

View file

@ -56,8 +56,8 @@ func TestCheckForUpdatesWithChannel_AvailableUsesCache(t *testing.T) {
releaseTime := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
releases := []ReleaseInfo{
{
TagName: "v99.0.0",
Name: "v99.0.0",
TagName: "v5.1.99",
Name: "v5.1.99",
Body: "Release notes",
Prerelease: false,
PublishedAt: releaseTime,
@ -66,8 +66,8 @@ func TestCheckForUpdatesWithChannel_AvailableUsesCache(t *testing.T) {
BrowserDownloadURL string `json:"browser_download_url"`
}{
{
Name: "pulse-v99.0.0-linux-amd64.tar.gz",
BrowserDownloadURL: "https://example.com/pulse-v99.0.0-linux-amd64.tar.gz",
Name: "pulse-v5.1.99-linux-amd64.tar.gz",
BrowserDownloadURL: "https://example.com/pulse-v5.1.99-linux-amd64.tar.gz",
},
},
},
@ -87,8 +87,8 @@ func TestCheckForUpdatesWithChannel_AvailableUsesCache(t *testing.T) {
if !info.Available {
t.Fatalf("expected update to be available")
}
if info.LatestVersion != "99.0.0" {
t.Fatalf("LatestVersion = %q, want 99.0.0", info.LatestVersion)
if info.LatestVersion != "5.1.99" {
t.Fatalf("LatestVersion = %q, want 5.1.99", info.LatestVersion)
}
if info.DownloadURL == "" {
t.Fatalf("DownloadURL not set")
@ -110,8 +110,8 @@ func TestCheckForUpdatesWithChannel_NoReleases(t *testing.T) {
var hits int32
releases := []ReleaseInfo{
{
TagName: "v99.0.0-rc.1",
Name: "v99.0.0-rc.1",
TagName: "v5.1.99-rc.1",
Name: "v5.1.99-rc.1",
Prerelease: true,
PublishedAt: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC),
},
@ -140,8 +140,8 @@ func TestCheckForUpdates_Wrapper(t *testing.T) {
var hits int32
releases := []ReleaseInfo{
{
TagName: "v99.1.0",
Name: "v99.1.0",
TagName: "v5.1.100",
Name: "v5.1.100",
Body: "Release notes",
Prerelease: false,
PublishedAt: time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC),
@ -150,8 +150,8 @@ func TestCheckForUpdates_Wrapper(t *testing.T) {
BrowserDownloadURL string `json:"browser_download_url"`
}{
{
Name: "pulse-v99.1.0-linux-amd64.tar.gz",
BrowserDownloadURL: "https://example.com/pulse-v99.1.0-linux-amd64.tar.gz",
Name: "pulse-v5.1.100-linux-amd64.tar.gz",
BrowserDownloadURL: "https://example.com/pulse-v5.1.100-linux-amd64.tar.gz",
},
},
},
@ -171,7 +171,7 @@ func TestCheckForUpdates_Wrapper(t *testing.T) {
if !info.Available {
t.Fatalf("expected update to be available")
}
if info.LatestVersion != "99.1.0" {
t.Fatalf("LatestVersion = %q, want 99.1.0", info.LatestVersion)
if info.LatestVersion != "5.1.100" {
t.Fatalf("LatestVersion = %q, want 5.1.100", info.LatestVersion)
}
}

View file

@ -122,8 +122,16 @@ func TestGetLatestReleaseFromFeedMocked(t *testing.T) {
t.Cleanup(func() { http.DefaultTransport = origTransport })
manager := NewManager(&config.Config{})
stableCurrent, err := ParseVersion("4.36.1")
if err != nil {
t.Fatalf("parse stable current version: %v", err)
}
rcCurrent, err := ParseVersion("5.0.0-rc.0")
if err != nil {
t.Fatalf("parse rc current version: %v", err)
}
release, err := manager.getLatestReleaseFromFeed(context.Background(), "stable")
release, err := manager.getLatestReleaseFromFeed(context.Background(), "stable", stableCurrent)
if err != nil {
t.Fatalf("stable feed error: %v", err)
}
@ -131,7 +139,7 @@ func TestGetLatestReleaseFromFeedMocked(t *testing.T) {
t.Fatalf("unexpected stable tag: %s", release.TagName)
}
release, err = manager.getLatestReleaseFromFeed(context.Background(), "rc")
release, err = manager.getLatestReleaseFromFeed(context.Background(), "rc", rcCurrent)
if err != nil {
t.Fatalf("rc feed error: %v", err)
}
@ -140,7 +148,7 @@ func TestGetLatestReleaseFromFeedMocked(t *testing.T) {
}
feed = `<?xml version="1.0" encoding="UTF-8"?><feed></feed>`
if _, err := manager.getLatestReleaseFromFeed(context.Background(), "stable"); err == nil {
if _, err := manager.getLatestReleaseFromFeed(context.Background(), "stable", stableCurrent); err == nil {
t.Fatal("expected error for empty feed")
}
}

View file

@ -110,6 +110,19 @@ func TestRCUpdateNotifications(t *testing.T) {
expectUpdate: true,
description: "RC user should see stable release even if RC number is same (4.22.0 > 4.22.0-rc.1)",
},
{
name: "RC user ignores releases outside current line",
currentVersion: "5.1.28-rc.1",
releases: []ReleaseInfo{
{TagName: "v6.0.0-rc.1", Prerelease: true},
{TagName: "v6.0.0", Prerelease: false},
{TagName: "v5.1.28", Prerelease: false},
{TagName: "v5.1.28-rc.2", Prerelease: true},
},
expectedVersion: "v5.1.28",
expectUpdate: true,
description: "RC users on the maintenance line should not drift to the next major release",
},
}
for _, tt := range tests {
@ -207,6 +220,18 @@ func TestStableUpdateNotifications(t *testing.T) {
expectUpdate: true, // Returns latest version even if already on it (Available will be false)
description: "Returns latest stable version even when already on it",
},
{
name: "Stable user ignores newer stable outside current line",
currentVersion: "5.1.27",
releases: []ReleaseInfo{
{TagName: "v6.0.0", Prerelease: false},
{TagName: "v5.1.28", Prerelease: false},
{TagName: "v5.1.27", Prerelease: false},
},
expectedVersion: "v5.1.28",
expectUpdate: true,
description: "Stable users on 5.1 should stay on the 5.1 maintenance line",
},
}
for _, tt := range tests {
@ -249,6 +274,69 @@ func TestStableUpdateNotifications(t *testing.T) {
}
}
func TestSelectLatestReleaseForChannel_LineAware(t *testing.T) {
currentVer, err := ParseVersion("5.1.28")
if err != nil {
t.Fatalf("failed to parse current version: %v", err)
}
tests := []struct {
name string
channel string
releases []ReleaseInfo
expectedTag string
expectError bool
}{
{
name: "stable selects newest stable in line",
channel: "stable",
releases: []ReleaseInfo{
{TagName: "v6.0.0", Prerelease: false},
{TagName: "v5.1.29", Prerelease: false},
{TagName: "v5.1.30-rc.1", Prerelease: true},
},
expectedTag: "v5.1.29",
},
{
name: "rc selects highest in line",
channel: "rc",
releases: []ReleaseInfo{
{TagName: "v6.0.0-rc.1", Prerelease: true},
{TagName: "v5.1.30-rc.1", Prerelease: true},
{TagName: "v5.1.29", Prerelease: false},
},
expectedTag: "v5.1.30-rc.1",
},
{
name: "stable errors when no matching line exists",
channel: "stable",
releases: []ReleaseInfo{
{TagName: "v6.0.0", Prerelease: false},
{TagName: "v6.0.1", Prerelease: false},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
release, err := selectLatestReleaseForChannel(tt.releases, tt.channel, currentVer)
if tt.expectError {
if err == nil {
t.Fatalf("expected error, got release %s", release.TagName)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if release.TagName != tt.expectedTag {
t.Fatalf("selected release %s, want %s", release.TagName, tt.expectedTag)
}
})
}
}
func buildDummyTarball(t *testing.T) []byte {
t.Helper()

View file

@ -8,6 +8,7 @@ set -euo pipefail
# Configuration
GITHUB_REPO="rcourtman/Pulse"
DEFAULT_SOURCE_BRANCH="release/5.1"
INSTALL_DIR="/opt/pulse"
CONFIG_DIR="/etc/pulse"
LOG_TAG="pulse-auto-update"
@ -61,19 +62,27 @@ get_current_version() {
# Get latest stable release from GitHub
get_latest_stable_version() {
local latest_version=""
local releases_json=""
local feed_xml=""
local api_url="https://api.github.com/repos/$GITHUB_REPO/releases"
local feed_url="https://github.com/$GITHUB_REPO/releases.atom"
# Get latest stable release (not pre-releases)
latest_version=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/' || true)
# Check if we got rate limited or failed
if [[ -z "$latest_version" ]] || [[ "$latest_version" == *"rate limit"* ]]; then
# Try direct GitHub latest URL as fallback
latest_version=$(curl -sI "https://github.com/$GITHUB_REPO/releases/latest" | \
grep -i '^location:' | \
sed -E 's|.*tag/([^[:space:]]+).*|\1|' | \
tr -d '\r' || true)
releases_json=$(curl -fsSL --connect-timeout 5 --max-time 10 "$api_url" 2>/dev/null || true)
if [[ -n "$releases_json" ]]; then
latest_version=$(printf '%s' "$releases_json" | \
grep -oE '"tag_name":[[:space:]]*"v5\.1\.[0-9]+"' | \
head -1 | \
sed -E 's/.*"([^"]+)"/\1/' || true)
fi
if [[ -z "$latest_version" ]]; then
feed_xml=$(curl -fsSL --connect-timeout 5 --max-time 10 "$feed_url" 2>/dev/null || true)
if [[ -n "$feed_xml" ]]; then
latest_version=$(printf '%s' "$feed_xml" | \
grep -oE '<title>Pulse v5\.1\.[0-9]+</title>' | \
head -1 | \
sed -E 's#<title>Pulse (v[^<]+)</title>#\1#' || true)
fi
fi
echo "${latest_version:-}"
@ -201,15 +210,17 @@ perform_update() {
# Run install script with specific version
local marker_file="$INSTALL_DIR/BUILD_FROM_SOURCE"
local -a installer_args=(--version "$new_version")
local install_script_url="https://raw.githubusercontent.com/$GITHUB_REPO/$DEFAULT_SOURCE_BRANCH/install.sh"
if [[ -f "$marker_file" ]]; then
local branch
branch=$(tr -d '\r\n' <"$marker_file" 2>/dev/null || true)
if [[ -n "$branch" ]]; then
installer_args=(--source "$branch" "${installer_args[@]}")
install_script_url="https://raw.githubusercontent.com/$GITHUB_REPO/$branch/install.sh"
fi
fi
if curl -sSL "https://github.com/$GITHUB_REPO/releases/latest/download/install.sh" | \
if curl -sSL "$install_script_url" | \
bash -s -- "${installer_args[@]}" 2>&1 | \
while IFS= read -r line; do
log info "installer: $line"