From 56fec40f2d7eeed43774ac0aecc3c3d8d3f0973b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:27:09 +0100 Subject: [PATCH] Select only published server releases in the installer Use one complete-metadata selector for installer URLs and server archives. Exclude chart and draft releases, reject unusable metadata and transport bodies, and preserve explicit pins, signed assets and downgrade refusal. Exercise the actual selectors with bounded offline controls and retain native installation as separate acceptance. Change-source: pulse-maintainer Contract-Neutral: Restore server-only discovery within existing stable/preview channels; installer authority, signatures, architecture admission, downgrade refusal and state contracts are unchanged. --- .../subsystems/deployment-installability.md | 27 +++ install.sh | 138 ++++++------- scripts/installtests/install_sh_test.go | 24 ++- .../tests/test_server_release_selection.py | 191 ++++++++++++++++++ 4 files changed, 299 insertions(+), 81 deletions(-) create mode 100644 scripts/tests/test_server_release_selection.py diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0ea0b04b65..b94c42b819 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -7316,3 +7316,30 @@ machine setting. Cleanup restores only the captured prior setting, even when owned server teardown fails; an unconfirmed stop fails and retains the handle rather than reporting clean teardown. Real Windows execution and full terminal cleanup evidence still have to be returned by the existing native CI job. + +### Server-only installer release discovery (9 October 2026) + +The server installer uses one selector for its installer-script URL and server +archive target. Automatic discovery admits only published Pulse server tags: +three numeric version components, optionally followed by `-beta.N` or `-rc.N`. +Chart-only and unrelated tags never become a server version. Stable additionally +requires a false prerelease flag and a stable-shaped tag; preview keeps the +existing published-list order and admits stable releases as well as previews. + +Selection consumes one complete top-level JSON array with each selected tag's +own boolean draft/prerelease fields. Truncated, concatenated or failed transport +output cannot contribute a tag. Missing or failed jq no longer permits text +extraction that loses those fields. The existing public-redirect fallback checks +the same server/channel tag scope; if neither route yields a candidate, discovery +stops with connectivity/parser and exact-version guidance. Explicit pins and +cached targets, automatic-downgrade refusal, signature verification, architecture +admission and service/persistent-state boundaries remain unchanged. + +`TestRootInstallReleaseSelectionRejectsChartAndUnknownMetadata` executes the real +sourced selectors with offline transport doubles through the tag, installer URL +and archive-target consumers. Controls cover chart-before-preview ordering, +drafts, stable/beta/RC channels, compact/pretty and malformed metadata, missing +or failed parsers, failed transport with a plausible body, redirects, pins and +downgrade refusal. Existing extracted-function tests include the shared selector. +These are source-level selection controls, not native installation, published +release acceptance or a repair to a third-party community helper. diff --git a/install.sh b/install.sh index b2eb5300f2..6d63c75337 100755 --- a/install.sh +++ b/install.sh @@ -340,21 +340,40 @@ is_stable_release_tag() { [[ "$tag" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] } -latest_stable_release_tag_from_json() { +is_pulse_release_tag() { + local tag="${1:-}" + tag="${tag#v}" + + [[ "$tag" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-(beta|rc)[.][0-9]+)?$ ]] +} + +latest_pulse_release_tag_from_json() { local releases_json="${1:-}" + local channel="${2:-stable}" local latest_release="" - if [[ -z "$releases_json" ]]; then + if [[ -z "$releases_json" ]] || [[ "$channel" != stable && "$channel" != rc ]]; then return 1 fi - if command -v jq >/dev/null 2>&1; then - latest_release=$(printf '%s' "$releases_json" | jq -r '[.[] | select(.draft == false and .prerelease == false and (.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")))][0].tag_name // empty' 2>/dev/null || true) - else - latest_release=$(printf '%s' "$releases_json" | grep -oE '"tag_name":[[:space:]]*"v?[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true) - fi + # Tag-shaped text is not release metadata: regex extraction loses draft + # and prerelease ownership. Without a parser, use only the caller's checked + # public redirect (or an explicitly pinned version), never a guessed list. + command -v jq >/dev/null 2>&1 || return 1 + latest_release=$(printf '%s' "$releases_json" | jq -er -s --arg channel "$channel" ' + if length != 1 or (.[0] | type) != "array" then + error("Expected one release array") + else .[0] end + | [.[] | select(type == "object") + | select(.draft == false and (.prerelease | type) == "boolean") + | select(.tag_name | type == "string") + | select(.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-(beta|rc)\\.[0-9]+)?\\z")) + | select($channel == "rc" or (.prerelease == false + and (.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+\\z"))))] + | .[0].tag_name // empty + ' 2>/dev/null) || return 1 - if is_stable_release_tag "$latest_release"; then + if is_pulse_release_tag "$latest_release"; then printf '%s\n' "$latest_release" return 0 fi @@ -362,6 +381,10 @@ latest_stable_release_tag_from_json() { return 1 } +latest_stable_release_tag_from_json() { + latest_pulse_release_tag_from_json "${1:-}" stable +} + read_configured_update_channel() { if [[ "${IGNORE_CONFIGURED_UPDATE_CHANNEL:-false}" == "true" ]]; then return 1 @@ -1018,53 +1041,36 @@ repo_web_url() { resolve_latest_release_tag_for_channel() { local channel="${1:-stable}" - - if [[ "$channel" != "rc" ]]; then - local releases_json="" - local stable_release="" - - 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 - - stable_release=$(latest_stable_release_tag_from_json "$releases_json" 2>/dev/null || true) - if [[ -n "$stable_release" ]]; then - printf '%s\n' "$stable_release" - return 0 - fi - - stable_release=$(get_latest_release_from_redirect 2>/dev/null || true) - if is_stable_release_tag "$stable_release"; then - printf '%s\n' "$stable_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 release="" - 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) + [[ "$channel" == stable || "$channel" == rc ]] || return 1 + if command -v timeout >/dev/null 2>&1; then + if ! releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null); then + releases_json="" + fi + else + if ! releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null); then + releases_json="" fi fi - if [[ -z "$rc_release" || "$rc_release" == "null" ]]; then - return 1 + release=$(latest_pulse_release_tag_from_json "$releases_json" "$channel" 2>/dev/null || true) + if [[ -n "$release" ]]; then + printf '%s\n' "$release" + return 0 fi - printf '%s\n' "$rc_release" + release=$(get_latest_release_from_redirect 2>/dev/null || true) + if [[ "$channel" == rc ]] && is_pulse_release_tag "$release"; then + printf '%s\n' "$release" + return 0 + elif [[ "$channel" == stable ]] && is_stable_release_tag "$release"; then + printf '%s\n' "$release" + return 0 + fi + + return 1 } repo_release_docs_ref() { @@ -3298,40 +3304,12 @@ resolve_target_release() { fi fi - local releases_json="" - if command -v timeout >/dev/null 2>&1; then - releases_json=$(timeout 15 curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true) - else - releases_json=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true) - fi - - if [[ -n "$releases_json" ]]; then - if [[ "$UPDATE_CHANNEL" == "rc" ]]; then - # Prerelease channel: get latest release (including prereleases, but skip drafts) - if command -v jq >/dev/null 2>&1; then - LATEST_RELEASE=$(echo "$releases_json" | jq -r '[.[] | select(.draft == false)][0].tag_name' 2>/dev/null || true) - else - LATEST_RELEASE=$(echo "$releases_json" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true) - fi - else - LATEST_RELEASE=$(latest_stable_release_tag_from_json "$releases_json" 2>/dev/null || true) - fi - fi + # Installer-script URLs and server archives must select the same release. + LATEST_RELEASE=$(resolve_latest_release_tag_for_channel "$UPDATE_CHANNEL" 2>/dev/null || true) if [[ -z "$LATEST_RELEASE" ]]; then - print_info "GitHub API unavailable, trying alternative method..." - local redirect_version="" - redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true) - if [[ "$UPDATE_CHANNEL" == "rc" && -n "$redirect_version" ]]; then - LATEST_RELEASE="$redirect_version" - elif is_stable_release_tag "$redirect_version"; then - LATEST_RELEASE="$redirect_version" - fi - fi - - if [[ -z "$LATEST_RELEASE" ]]; then - print_error "Could not determine the latest Pulse release from GitHub" - print_info "GitHub may be unreachable or rate limiting. Retry later, or pin the release explicitly:" + print_error "Could not determine a published Pulse server release for the $UPDATE_CHANNEL channel" + print_info "Check GitHub connectivity and that jq is installed, or pin the release explicitly:" print_info " bash install.sh --version vX.Y.Z" exit 1 fi diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index ac61d81b87..3ba1d996de 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -4505,6 +4505,15 @@ func TestResolveInstallScriptDownloadURLUsesForcedVersion(t *testing.T) { } } +// Run the production selectors with bounded, offline transport doubles. This +// does not run installer main, mutate services or establish native acceptance. +func TestRootInstallReleaseSelectionRejectsChartAndUnknownMetadata(t *testing.T) { + out, err := exec.Command("python3", repoFile("scripts", "tests", "test_server_release_selection.py"), "-v").CombinedOutput() + if err != nil { + t.Fatalf("server release selection controls: %v\n%s", err, out) + } +} + func TestRootInstallStableReleaseTagRejectsPrereleaseShapes(t *testing.T) { script := ` ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` @@ -4566,6 +4575,8 @@ exit 1 printf '%s\n' v6.0.0-rc.2 } ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "latest_stable_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` resolve_latest_release_tag_for_channel stable @@ -4590,6 +4601,8 @@ func TestResolveLatestReleaseTagForStableChannelRejectsPrereleaseRedirect(t *tes printf '%s\n' v6.0.0-rc.2 } ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "latest_stable_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` if resolve_latest_release_tag_for_channel stable; then @@ -4637,6 +4650,8 @@ exit 1 printf '%s\n' v6.0.0-rc.2 } ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "latest_stable_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` ` + extractRootInstallShellFunction(t, "resolve_install_script_download_url") + ` @@ -4667,7 +4682,7 @@ func TestResolveInstallScriptDownloadURLUsesRCReleaseTag(t *testing.T) { curlStub := `#!/usr/bin/env bash for arg in "$@"; do if [[ "$arg" == "https://api.github.com/repos/rcourtman/Pulse/releases" ]]; then - printf '%s\n' '[{"draft":false,"tag_name":"v6.0.0-rc.2"},{"draft":false,"prerelease":false,"tag_name":"v5.9.0"}]' + printf '%s\n' '[{"draft":false,"prerelease":true,"tag_name":"v6.0.0-rc.2"},{"draft":false,"prerelease":false,"tag_name":"v5.9.0"}]' exit 0 fi done @@ -4684,6 +4699,8 @@ exit 1 FORCE_VERSION="" FORCE_CHANNEL="rc" UPDATE_CHANNEL="" +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` ` + extractRootInstallShellFunction(t, "resolve_install_script_download_url") + ` resolve_install_script_download_url @@ -5456,6 +5473,8 @@ func TestRepoDockerDocsURLFallsBackToReleaseLandingPageWhenVersionUnknown(t *tes timeout() { return 1; } ` + extractRootInstallShellFunction(t, "repo_web_url") + ` ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "latest_stable_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` ` + extractRootInstallShellFunction(t, "repo_release_docs_ref") + ` @@ -5737,7 +5756,10 @@ exit 1 get_latest_release_from_redirect() { return 1; } ` + extractRootInstallShellFunction(t, "read_configured_update_channel") + ` ` + extractRootInstallShellFunction(t, "is_stable_release_tag") + ` +` + extractRootInstallShellFunction(t, "is_pulse_release_tag") + ` +` + extractRootInstallShellFunction(t, "latest_pulse_release_tag_from_json") + ` ` + extractRootInstallShellFunction(t, "latest_stable_release_tag_from_json") + ` +` + extractRootInstallShellFunction(t, "resolve_latest_release_tag_for_channel") + ` ` + extractRootInstallShellFunction(t, "resolve_target_release") + ` resolve_target_release printf '%s\n' "$LATEST_RELEASE" diff --git a/scripts/tests/test_server_release_selection.py b/scripts/tests/test_server_release_selection.py new file mode 100644 index 0000000000..88fc62e6db --- /dev/null +++ b/scripts/tests/test_server_release_selection.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Execute the server installer's selectors with offline release-list fixtures. + +These are selector controls only: no download, service or native install proof. +""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +INSTALLER = Path(os.environ.get("PULSE_INSTALLER_UNDER_TEST", ROOT / "install.sh")) + + +def release(tag, *, draft=False, prerelease=False): + return {"tag_name": tag, "draft": draft, "prerelease": prerelease} + + +HARNESS = r''' +source "$INSTALLER" +# No installer main, systemd operation, package install or network call occurs. +print_info() { :; } +print_warn() { :; } +print_error() { printf '%s\n' "$*" >&2; } +timeout() { shift; "$@"; } +curl() { + local url="${@: -1}" + printf '%s\n' "$url" >> "$FIXTURE/calls" + case "$url" in + https://api.github.com/repos/rcourtman/Pulse/releases) + printf '%s' "$RELEASES_JSON" + return "$API_EXIT" + ;; + https://github.com/rcourtman/Pulse/releases/latest) + [[ -n "$REDIRECT_TAG" ]] || return 22 + printf 'https://github.com/rcourtman/Pulse/releases/tag/%s' "$REDIRECT_TAG" + ;; + https://api.github.com/repos/rcourtman/Pulse/releases/tags/*) return 0 ;; + *) printf 'unexpected transport: %s\n' "$url" >&2; return 97 ;; + esac +} +if [[ "$NO_JQ" == true ]]; then + command() { + [[ "$*" != '-v jq' ]] || return 1 + builtin command "$@" + } +elif [[ "$FAILED_JQ" == true ]]; then + jq() { printf 'v99.0.0\n'; return 3; } +fi +FORCE_VERSION="$PINNED_VERSION" +FORCE_CHANNEL="$CHANNEL" +UPDATE_CHANNEL="$CHANNEL" +LATEST_RELEASE="$CACHED_VERSION" +CURRENT_VERSION="$INSTALLED_VERSION" +case "$SELECTOR" in + parser) latest_pulse_release_tag_from_json "$RELEASES_JSON" "$CHANNEL" ;; + tag) resolve_latest_release_tag_for_channel "$CHANNEL" ;; + url) resolve_install_script_download_url ;; + target) resolve_target_release; printf '%s\n' "$LATEST_RELEASE" ;; + *) exit 98 ;; +esac +''' + + +class ServerReleaseSelectionTest(unittest.TestCase): + def exercise(self, data, channel="rc", selector="tag", redirect="", *, + no_jq=False, failed_jq=False, api_exit=0, pinned="", cached="", installed=""): + payload = data if isinstance(data, str) else json.dumps(data) + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) + env = {**os.environ, "FIXTURE": str(fixture), "INSTALLER": str(INSTALLER), + "PULSE_CONFIG_DIR": str(fixture / "config"), "RELEASES_JSON": payload, + "CHANNEL": channel, "SELECTOR": selector, "REDIRECT_TAG": redirect, + "NO_JQ": str(no_jq).lower(), "FAILED_JQ": str(failed_jq).lower(), + "API_EXIT": str(api_exit), "PINNED_VERSION": pinned, + "CACHED_VERSION": cached, "INSTALLED_VERSION": installed} + result = subprocess.run(["bash", "-c", HARNESS], env=env, + capture_output=True, text=True, timeout=15) + calls = (fixture / "calls").read_text().splitlines() if (fixture / "calls").exists() else [] + return result, calls + + def assert_selection(self, data, channel, expected, **kwargs): + for selector in ("tag", "url", "target"): + with self.subTest(selector=selector, channel=channel, options=kwargs): + result, calls = self.exercise(data, channel, selector, **kwargs) + if expected: + self.assertEqual(result.returncode, 0, result.stderr) + want = (f"https://github.com/rcourtman/Pulse/releases/download/{expected}/install.sh" + if selector == "url" else expected) + self.assertEqual(result.stdout.strip(), want) + else: + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(result.stdout, "") + # Only release discovery is admitted by the transport double. + self.assertLessEqual(len(calls), 2, calls) + self.assertFalse(any("/download/" in url for url in calls), calls) + + def test_chart_and_draft_entries_never_select_server_or_installer(self): + data = [release("helm-chart-6.6.0-rc.1", prerelease=True), + release("v6.7.0-rc.1", draft=True, prerelease=True), + release("helm-chart-6.5.0"), release("v6.6.0-rc.1", prerelease=True), + release("v6.5.0")] + for payload in (data, json.dumps(data, indent=2)): + self.assert_selection(payload, "rc", "v6.6.0-rc.1") + self.assert_selection(payload, "stable", "v6.5.0") + + def test_channel_and_published_order_are_preserved(self): + for tag in ("v6.6.0-beta.2", "v6.6.0-rc.2", "6.6.0-rc.2", "v6.6.0", "6.6.0"): + data = [release(tag, prerelease="-" in tag), release("v6.5.0")] + self.assert_selection(data, "rc", tag) + self.assert_selection(data, "stable", "v6.5.0" if "-" in tag else tag) + # Preview permits a newer published stable, not only prereleases. + self.assert_selection([release("v6.5.0"), release("v6.5.0-rc.1", prerelease=True)], "rc", "v6.5.0") + # A prerelease-shaped tag is not stable even with a false API flag. + self.assert_selection([release("v6.6.0-rc.1"), release("v6.5.0")], "stable", "v6.5.0") + + def test_complete_top_level_metadata_not_tag_shaped_text_is_required(self): + valid = json.dumps([release("v6.5.0")]) + for data in (valid + " trailing", valid + valid, valid[:-1], + json.dumps(release("v6.5.0")), "null", "", '"v6.5.0"'): + for channel in ("stable", "rc"): + self.assert_selection(data, channel, "", redirect="helm-chart-6.5.0") + data = [None, False, "v99.0.0", {"nested": release("v99.0.0")}, + release("v99.0.0", draft="false"), release("v99.0.0", prerelease="false"), + {"tag_name": "v99.0.0", "draft": False}, release(123), + release("v6.7.0\n"), release("v6.7.0;touch owned"), release("latest"), + release("v6.7.0-preview.1", prerelease=True), release("v6.5.0")] + for channel in ("stable", "rc"): + self.assert_selection(data, channel, "v6.5.0") + + def test_missing_or_failed_parser_does_not_guess_draft_tag(self): + data = [release("v99.0.0", draft=True), release("v6.5.0")] + for options in ({"no_jq": True}, {"failed_jq": True}): + for channel in ("stable", "rc"): + self.assert_selection(data, channel, "", **options) + self.assert_selection(data, channel, "", redirect="helm-chart-6.5.0", **options) + self.assert_selection(data, channel, "v6.5.0", redirect="v6.5.0", **options) + + def test_failed_transport_never_admits_even_complete_returned_list(self): + data = [release("v99.0.0")] + for channel in ("stable", "rc"): + self.assert_selection(data, channel, "", api_exit=22) + self.assert_selection(data, channel, "v6.5.0", redirect="v6.5.0", api_exit=22) + + def test_redirect_obeys_same_server_and_channel_tag_scope(self): + for tag in ("helm-chart-6.5.0", "null", "latest", "v6.5"): + for channel in ("stable", "rc"): + self.assert_selection("", channel, "", redirect=tag) + # Preserve the redirect extractor's intermediate-path/query tolerance: + # only its canonical server tag enters a download URL, not the suffix. + for tag in ("v6.5.0/other", "v6.5.0?other", "v6.5.0#other"): + self.assert_selection("", "stable", "v6.5.0", redirect=tag) + self.assert_selection("", "stable", "", redirect="v6.6.0-rc.1") + self.assert_selection("", "rc", "v6.6.0-rc.1", redirect="v6.6.0-rc.1") + self.assert_selection("", "rc", "v6.6.0-beta.1", redirect="v6.6.0-beta.1") + + def test_unknown_channel_is_rejected_before_transport(self): + result, calls = self.exercise([release("v6.5.0")], channel="other") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + + def test_explicit_pins_and_cached_target_remain_intentional(self): + for selector in ("url", "target"): + result, calls = self.exercise([], selector=selector, pinned="v6.5.0-rc.1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("v6.5.0-rc.1", result.stdout) + self.assertTrue(all("/releases/tags/" in call for call in calls)) + result, calls = self.exercise([], selector="target", cached="v6.5.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "v6.5.0") + self.assertEqual(calls, []) + + def test_automatic_downgrade_refusal_is_preserved(self): + result, _ = self.exercise([release("v6.5.0")], selector="target", installed="v6.6.0-rc.1") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + self.assertIn("refusing to downgrade automatically", result.stderr) + + def test_refused_selection_has_specific_safe_recovery_guidance(self): + result, _ = self.exercise([], selector="target", redirect="helm-chart-6.5.0") + self.assertNotEqual(result.returncode, 0) + self.assertIn("published Pulse server release", result.stderr) + self.assertIn("rc channel", result.stderr) + + +if __name__ == "__main__": + unittest.main()