fix(deploy): pin docs links to release refs

This commit is contained in:
rcourtman 2026-03-28 21:32:11 +00:00
parent 893d003c0a
commit 0b98a0d3e0
9 changed files with 242 additions and 22 deletions

View file

@ -120,8 +120,10 @@ jobs:
- name: Update Chart.yaml version
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^version: .*/version: $VERSION/" deploy/helm/pulse/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$VERSION\"/" deploy/helm/pulse/Chart.yaml
python3 scripts/sync_chart_release_metadata.py \
--chart deploy/helm/pulse/Chart.yaml \
--version "$VERSION" \
--repo "${{ github.repository }}"
# Commit if Chart.yaml changed
if ! git diff --quiet deploy/helm/pulse/Chart.yaml; then

View file

@ -92,6 +92,13 @@ jobs:
echo "[OK] ${RELEASE_TAG} validated against release line ${REQUIRED_BRANCH}"
- name: Align chart metadata links
run: |
python3 scripts/sync_chart_release_metadata.py \
--chart deploy/helm/pulse/Chart.yaml \
--version "${{ steps.versions.outputs.chart_version }}" \
--repo "${{ github.repository }}"
- name: Helm lint (strict)
run: helm lint deploy/helm/pulse --strict

View file

@ -4,7 +4,7 @@ description: Helm chart for deploying the Pulse hub and optional Docker monitori
type: application
version: 5.1.7
appVersion: "5.1.7"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg
icon: https://raw.githubusercontent.com/rcourtman/Pulse/v5.1.7/docs/images/pulse-logo.svg
keywords:
- monitoring
- proxmox
@ -32,7 +32,7 @@ annotations:
description: Smoke tests with kind cluster deployment
artifacthub.io/links: |
- name: Documentation
url: https://github.com/rcourtman/Pulse/blob/main/docs/KUBERNETES.md
url: https://github.com/rcourtman/Pulse/blob/v5.1.7/docs/KUBERNETES.md
- name: Support
url: https://github.com/rcourtman/Pulse/discussions
artifacthub.io/maintainers: |

View file

@ -74,6 +74,7 @@ server-side update execution surfaces.
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `frontend-modern/package.json`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, artifact publication lineage enforcement, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists.
## Forbidden Paths

View file

@ -88,22 +88,8 @@ resolve_install_script_download_url() {
return 0
fi
local releases_json=""
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
else
releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
fi
local rc_release=""
if [[ -n "$releases_json" ]]; then
if command -v jq >/dev/null 2>&1; then
rc_release=$(echo "$releases_json" | jq -r '[.[] | select(.draft == false)][0].tag_name' 2>/dev/null || true)
else
rc_release=$(echo "$releases_json" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
fi
rc_release=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)
if [[ -z "$rc_release" || "$rc_release" == "null" ]]; then
return 1
fi
@ -521,8 +507,93 @@ repo_web_url() {
printf 'https://github.com/%s\n' "$GITHUB_REPO"
}
resolve_latest_release_tag_for_channel() {
local channel="${1:-stable}"
if [[ "$channel" != "rc" ]]; then
local stable_release=""
stable_release=$(get_latest_release_from_redirect 2>/dev/null || true)
if [[ -n "$stable_release" ]]; then
printf '%s\n' "$stable_release"
return 0
fi
local latest_json=""
if command -v timeout >/dev/null 2>&1; then
latest_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases/latest" 2>/dev/null || true)
else
latest_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases/latest" 2>/dev/null || true)
fi
local latest_release=""
if [[ -n "$latest_json" ]]; then
if command -v jq >/dev/null 2>&1; then
latest_release=$(echo "$latest_json" | jq -r '.tag_name' 2>/dev/null || true)
else
latest_release=$(echo "$latest_json" | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
fi
if [[ -n "$latest_release" && "$latest_release" != "null" ]]; then
printf '%s\n' "$latest_release"
return 0
fi
return 1
fi
local releases_json=""
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
else
releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
fi
local rc_release=""
if [[ -n "$releases_json" ]]; then
if command -v jq >/dev/null 2>&1; then
rc_release=$(echo "$releases_json" | jq -r '[.[] | select(.draft == false)][0].tag_name' 2>/dev/null || true)
else
rc_release=$(echo "$releases_json" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
fi
if [[ -z "$rc_release" || "$rc_release" == "null" ]]; then
return 1
fi
printf '%s\n' "$rc_release"
}
repo_release_docs_ref() {
if [[ -n "${FORCE_VERSION:-}" ]]; then
printf '%s\n' "$FORCE_VERSION"
return 0
fi
if [[ -n "${LATEST_RELEASE:-}" ]]; then
printf '%s\n' "$LATEST_RELEASE"
return 0
fi
local channel="${FORCE_CHANNEL:-${UPDATE_CHANNEL:-stable}}"
resolve_latest_release_tag_for_channel "$channel"
}
repo_docs_url_for_path() {
local docs_path="$1"
local ref=""
ref=$(repo_release_docs_ref 2>/dev/null || true)
if [[ -n "$ref" ]]; then
printf '%s/blob/%s/%s\n' "$(repo_web_url)" "$ref" "$docs_path"
return 0
fi
printf '%s/releases/latest\n' "$(repo_web_url)"
}
repo_docker_docs_url() {
printf '%s/blob/main/docs/DOCKER.md\n' "$(repo_web_url)"
repo_docs_url_for_path "docs/DOCKER.md"
}
repo_docker_image_ref() {

View file

@ -1507,7 +1507,11 @@ func TestPulseAutoUpdateResolveInstallScriptURLUsesConfiguredRepo(t *testing.T)
func TestRepoDockerDocsURLUsesConfiguredRepo(t *testing.T) {
script := `
GITHUB_REPO="example/pulse-fork"
LATEST_RELEASE="v9.9.9"
` + extractRootInstallShellFunction(t, "repo_web_url") + `
` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + `
` + extractRootInstallShellFunction(t, "repo_release_docs_ref") + `
` + extractRootInstallShellFunction(t, "repo_docs_url_for_path") + `
` + extractRootInstallShellFunction(t, "repo_docker_docs_url") + `
repo_docker_docs_url
`
@ -1516,11 +1520,34 @@ func TestRepoDockerDocsURLUsesConfiguredRepo(t *testing.T) {
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "https://github.com/example/pulse-fork/blob/main/docs/DOCKER.md" {
if got := strings.TrimSpace(string(out)); got != "https://github.com/example/pulse-fork/blob/v9.9.9/docs/DOCKER.md" {
t.Fatalf("repo_docker_docs_url = %q", got)
}
}
func TestRepoDockerDocsURLFallsBackToReleaseLandingPageWhenVersionUnknown(t *testing.T) {
script := `
GITHUB_REPO="example/pulse-fork"
get_latest_release_from_redirect() { return 1; }
curl() { return 1; }
timeout() { return 1; }
` + extractRootInstallShellFunction(t, "repo_web_url") + `
` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + `
` + extractRootInstallShellFunction(t, "repo_release_docs_ref") + `
` + extractRootInstallShellFunction(t, "repo_docs_url_for_path") + `
` + extractRootInstallShellFunction(t, "repo_docker_docs_url") + `
repo_docker_docs_url
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "https://github.com/example/pulse-fork/releases/latest" {
t.Fatalf("repo_docker_docs_url fallback = %q", got)
}
}
func TestRepoDockerImageRefUsesConfiguredImageRepo(t *testing.T) {
script := `
DOCKER_IMAGE_REPO="example/pulse-enterprise"
@ -1540,10 +1567,14 @@ func TestRepoDockerImageRefUsesConfiguredImageRepo(t *testing.T) {
func TestCheckDockerEnvironmentUsesConfiguredImageAndDocs(t *testing.T) {
script := `
GITHUB_REPO="example/pulse-fork"
LATEST_RELEASE="v9.9.9"
DOCKER_IMAGE_REPO="example/pulse-enterprise"
print_error() { printf 'ERR:%s\n' "$1"; }
grep() { return 1; }
` + extractRootInstallShellFunction(t, "repo_web_url") + `
` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + `
` + extractRootInstallShellFunction(t, "repo_release_docs_ref") + `
` + extractRootInstallShellFunction(t, "repo_docs_url_for_path") + `
` + extractRootInstallShellFunction(t, "repo_docker_docs_url") + `
` + extractRootInstallShellFunction(t, "repo_docker_image_ref") + `
` + extractRootInstallShellFunction(t, "check_docker_environment") + `
@ -1559,7 +1590,7 @@ func TestCheckDockerEnvironmentUsesConfiguredImageAndDocs(t *testing.T) {
if !strings.Contains(got, "docker run -d -p 7655:7655 example/pulse-enterprise:latest") {
t.Fatalf("docker guidance missing configured image repo:\n%s", got)
}
if !strings.Contains(got, "https://github.com/example/pulse-fork/blob/main/docs/DOCKER.md") {
if !strings.Contains(got, "https://github.com/example/pulse-fork/blob/v9.9.9/docs/DOCKER.md") {
t.Fatalf("docker guidance missing configured docs url:\n%s", got)
}
}

View file

@ -214,6 +214,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
demo = read(".github/workflows/update-demo-server.yml")
helm = read(".github/workflows/publish-helm-chart.yml")
helm_pages = read(".github/workflows/helm-pages.yml")
chart = read("deploy/helm/pulse/Chart.yaml")
chart_sync = read("scripts/sync_chart_release_metadata.py")
runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md")
self.assertIn("control_plane.py --branch-for-version", publish)
self.assertIn("control_plane.py --branch-for-version", promote)
@ -223,6 +225,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("does not descend from any matching prerelease tag", publish)
self.assertIn("does not descend from any matching prerelease tag", promote)
self.assertIn("Refusing cross-line Helm pages release", helm_pages)
self.assertIn("sync_chart_release_metadata.py", helm)
self.assertIn("sync_chart_release_metadata.py", helm_pages)
self.assertIn("--chart deploy/helm/pulse/Chart.yaml", helm)
self.assertIn("--chart deploy/helm/pulse/Chart.yaml", helm_pages)
self.assertNotIn("blob/main/docs/KUBERNETES.md", chart)
self.assertNotIn("raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg", chart)
self.assertIn("blob/{tag}/docs/KUBERNETES.md", chart_sync)
self.assertIn("raw.githubusercontent.com/{repo}/{tag}/docs/images/pulse-logo.svg", chart_sync)
self.assertIn("both stable and prerelease releases dispatch", runbook)
self.assertIn("Release `6.0.0` from `pulse/v6-release`", runbook)
self.assertIn(promotion_metadata_envelope(), normalize_ws(runbook))

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Keep Helm chart release metadata aligned with the packaged version."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def replace_one(text: str, pattern: str, replacement: str) -> str:
updated, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
if count != 1:
raise ValueError(f"expected exactly one match for pattern: {pattern}")
return updated
def sync_chart_metadata(text: str, version: str, repo: str) -> str:
tag = version if version.startswith("v") else f"v{version}"
icon_url = f"https://raw.githubusercontent.com/{repo}/{tag}/docs/images/pulse-logo.svg"
docs_url = f"https://github.com/{repo}/blob/{tag}/docs/KUBERNETES.md"
text = replace_one(text, r"^version: .*$", f"version: {version}")
text = replace_one(text, r'^appVersion: ".*"$', f'appVersion: "{version}"')
text = replace_one(text, r"^icon: .*$", f"icon: {icon_url}")
text = replace_one(
text,
r"(^\s+- name: Documentation\n\s+url: ).*$",
rf"\1{docs_url}",
)
return text
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--chart", required=True, help="Path to Chart.yaml")
parser.add_argument("--version", required=True, help="Chart/app version")
parser.add_argument("--repo", default="rcourtman/Pulse", help="GitHub owner/repo")
args = parser.parse_args()
chart_path = Path(args.chart)
original = chart_path.read_text(encoding="utf-8")
updated = sync_chart_metadata(original, args.version, args.repo)
if updated != original:
chart_path.write_text(updated, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Tests for scripts/sync_chart_release_metadata.py."""
from __future__ import annotations
import importlib.util
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "sync_chart_release_metadata.py"
SPEC = importlib.util.spec_from_file_location("sync_chart_release_metadata", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class SyncChartReleaseMetadataTest(unittest.TestCase):
def test_sync_chart_metadata_updates_versioned_links(self) -> None:
original = """apiVersion: v2
name: pulse
version: 5.1.7
appVersion: "5.1.7"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg
annotations:
artifacthub.io/links: |
- name: Documentation
url: https://github.com/rcourtman/Pulse/blob/main/docs/KUBERNETES.md
- name: Support
url: https://github.com/rcourtman/Pulse/discussions
"""
updated = MODULE.sync_chart_metadata(original, "6.0.0-rc.1", "example/pulse")
self.assertIn("version: 6.0.0-rc.1", updated)
self.assertIn('appVersion: "6.0.0-rc.1"', updated)
self.assertIn(
"icon: https://raw.githubusercontent.com/example/pulse/v6.0.0-rc.1/docs/images/pulse-logo.svg",
updated,
)
self.assertIn(
"url: https://github.com/example/pulse/blob/v6.0.0-rc.1/docs/KUBERNETES.md",
updated,
)
if __name__ == "__main__":
unittest.main()