From 589d7e18b5526f94e98b41201f5cc920e83148db Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:08:04 +0100 Subject: [PATCH 1/3] Read manual update channels from complete top-level settings Stop the generated helper before downloads when saved channel evidence is malformed or unreadable. Preserve explicit selectors, source-marker precedence and signed-installer verification; exercise the actual helper with signed local fixtures. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 21 ++ install.sh | 26 ++- scripts/installtests/install_sh_test.go | 2 +- .../manual_update_channel_test.go | 200 ++++++++++++++++++ 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 scripts/installtests/manual_update_channel_test.go diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f51bf26dbe..5c52503cd7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5186,6 +5186,26 @@ curl configuration and have five-second connect/twenty-second overall bounds. `scripts/installtests/pulse_auto_update_test.go` own this proof. Signature, edition, consent, backup, service-restoration and installation gates remain separate and unchanged. +### Manual update channel admission (7 October 2026) + +The installed `update` helper selects a saved release channel only from one +complete `system.json` object and its top-level `updateChannel`. Missing, null +or empty channels retain the stable default; recognised strings are trimmed +and case-normalised. Nested or quoted settings cannot opt into RC. Malformed, +multiple, non-object or unknown-channel configuration, a non-regular path or +missing jq stops before downloading or executing the installer, with explicit +`--stable`/`--rc` recovery guidance and no configuration rewrite. A missing file +retains the default. Explicit existing selectors (including `--prerelease`) +and the existing source-build marker keep precedence over saved configuration. + +`TestManualUpdateChannelAdmission` generates and executes the actual helper +against local transport fixtures and genuinely signed installer/sidecar bytes. +It checks final arguments, pre-download refusal, configuration preservation, +selector/marker precedence and signature rejection. This is bounded source +acceptance, not installed upgrade, native service restoration or published +release acceptance. The installer, archive, signing, rollback and service gates +remain unchanged. + That same boundary also owns operator-facing management entry points for existing self-hosted installs: the installer's printed update/reset/uninstall commands and the active install or upgrade docs must route supported @@ -7084,3 +7104,4 @@ contract checks the helper staging, unchanged cache/graph inputs and bounded command. These are offline command proofs, not a hosted Docker build, public proxy recovery, E2E acceptance or installed customer result. The hosted checks still have to build the image and execute the unchanged suites. + diff --git a/install.sh b/install.sh index 1e3b05be86..8d2b3061bc 100755 --- a/install.sh +++ b/install.sh @@ -4042,7 +4042,7 @@ auto_selector_allowed=true if [[ \${#helper_args[@]} -gt 0 ]]; then for helper_arg in "\${helper_args[@]}"; do case "\$helper_arg" in - -h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight) + -h|--help|--uninstall|--version|--rc|--pre|--prerelease|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight) auto_selector_allowed=false break ;; @@ -4055,8 +4055,28 @@ if [[ "\$auto_selector_allowed" == "true" ]]; then if [[ -n "\$branch" ]]; then extra_args+=(--source "\$branch") fi - elif [[ -f "\${CONFIG_DIR}/system.json" ]]; then - configured_channel=\$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "\${CONFIG_DIR}/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true) + elif [[ -e "\${CONFIG_DIR}/system.json" || -L "\${CONFIG_DIR}/system.json" ]]; then + # Never infer a release channel from nested, quoted or partial JSON. + # Missing/null channels retain the stable default; malformed or unknown + # preferences stop before download. An explicit selector above wins. + if [[ ! -f "\${CONFIG_DIR}/system.json" ]] || ! command -v jq >/dev/null 2>&1; then + echo "Cannot read the saved update channel; a regular system.json and jq are required. Select --stable or --rc explicitly, or restore the configuration." >&2 + exit 1 + fi + if ! configured_channel=\$(jq -er -s ' + if length != 1 or (.[0] | type) != "object" then + error("expected one configuration object") + else .[0] end + | .updateChannel + | if . == null then "stable" else . end + | if type != "string" then error("invalid update channel") else . end + | gsub("^\\\\s+|\\\\s+\$"; "") | ascii_downcase + | if . == "" then "stable" else . end + | if . == "stable" or . == "rc" then . else error("unknown update channel") end + ' "\${CONFIG_DIR}/system.json" 2>/dev/null); then + echo "Cannot parse the saved update channel; no installer downloaded. Select --stable or --rc explicitly, or restore the configuration." >&2 + exit 1 + fi if [[ "\$configured_channel" == "rc" ]]; then extra_args+=(--rc) fi diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 0251e9bddc..a162387152 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -4366,7 +4366,7 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) { if !strings.Contains(got, `helper_args=()`) || !strings.Contains(got, `helper_args=("$@")`) { t.Fatalf("update helper missing passthrough helper args:\n%s", got) } - if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)`) { + if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--prerelease|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)`) { t.Fatalf("update helper missing auto-selector guard for explicit flags:\n%s", got) } if !strings.Contains(got, `extra_args+=("${helper_args[@]}")`) { diff --git a/scripts/installtests/manual_update_channel_test.go b/scripts/installtests/manual_update_channel_test.go new file mode 100644 index 0000000000..83791b1c1d --- /dev/null +++ b/scripts/installtests/manual_update_channel_test.go @@ -0,0 +1,200 @@ +package installtests + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// Exercise the generated helper, not a second copy of its channel parser. The +// transport is local-only, but the installer and sidecar use real SSH signatures. +func TestManualUpdateChannelAdmission(t *testing.T) { + for _, tool := range []string{"bash", "jq", "ssh-keygen"} { + if _, err := exec.LookPath(tool); err != nil { + t.Fatalf("manual update proof requires %s: %v", tool, err) + } + } + source, err := filepath.Abs(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Fatal(err) + } + + assets := t.TempDir() + key := filepath.Join(assets, "signing-key") + if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key).CombinedOutput(); err != nil { + t.Fatalf("create fixture key: %v\n%s", err, out) + } + publicKey, err := os.ReadFile(key + ".pub") + if err != nil { + t.Fatal(err) + } + installer := filepath.Join(assets, "install.sh") + if err := os.WriteFile(installer, []byte("#!/bin/bash\nset -eu\nprintf '%s\\n' EXECUTED \"$@\" > \"$MANUAL_UPDATE_CALLS\"\n"), 0600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("ssh-keygen", "-q", "-Y", "sign", "-f", key, "-n", "pulse-install", installer).CombinedOutput(); err != nil { + t.Fatalf("sign fixture installer: %v\n%s", err, out) + } + + cases := []struct { + name, config, marker string + args, want []string + blocked bool + missingConfig bool + missingJQ bool + directory bool + brokenLink bool + badSignature bool + }{ + {name: "stable", config: `{"updateChannel":"stable"}`}, + {name: "rc", config: `{"updateChannel":"rc"}`, want: []string{"--rc"}}, + {name: "normalized_rc", config: `{"updateChannel":" RC "}`, want: []string{"--rc"}}, + {name: "normalized_stable", config: `{"updateChannel":" STABLE "}`}, + {name: "missing_setting", config: `{}`}, + {name: "null_setting", config: `{"updateChannel":null}`}, + {name: "empty_setting", config: `{"updateChannel":" "}`}, + {name: "missing_file", missingConfig: true}, + {name: "nested_only", config: `{"other":{"updateChannel":"rc"}}`}, + {name: "stable_with_nested_rc", config: `{"updateChannel":"stable","other":{"updateChannel":"rc"}}`}, + {name: "rc_with_nested_stable", config: `{"updateChannel":"rc","other":{"updateChannel":"stable"}}`, want: []string{"--rc"}}, + {name: "escaped_setting_name", config: `{"update\u0043hannel":"rc"}`, want: []string{"--rc"}}, + {name: "quoted_only", config: `{"note":"\"updateChannel\":\"rc\""}`}, + {name: "truncated", config: `{"updateChannel":"rc"`, blocked: true}, + {name: "valid_object_then_garbage", config: `{"updateChannel":"rc"} trailing`, blocked: true}, + {name: "two_objects", config: `{"updateChannel":"stable"} {"updateChannel":"rc"}`, blocked: true}, + {name: "empty_file", blocked: true}, + {name: "array", config: `[{"updateChannel":"rc"}]`, blocked: true}, + {name: "string", config: `"rc"`, blocked: true}, + {name: "boolean", config: `{"updateChannel":false}`, blocked: true}, + {name: "number", config: `{"updateChannel":1}`, blocked: true}, + {name: "channel_array", config: `{"updateChannel":["rc"]}`, blocked: true}, + {name: "unknown_channel", config: `{"updateChannel":"beta"}`, blocked: true}, + {name: "missing_jq", config: `{"updateChannel":"rc"}`, missingJQ: true, blocked: true}, + {name: "directory", directory: true, blocked: true}, + {name: "broken_link", brokenLink: true, blocked: true}, + {name: "explicit_stable_over_rc", config: `{"updateChannel":"rc"}`, args: []string{"--stable"}, want: []string{"--stable"}}, + {name: "explicit_rc_over_stable", config: `{"updateChannel":"stable"}`, args: []string{"--rc"}, want: []string{"--rc"}}, + {name: "explicit_prerelease_over_invalid", config: `{"updateChannel":false}`, args: []string{"--prerelease"}, want: []string{"--prerelease"}}, + {name: "explicit_version_over_invalid", config: `{`, args: []string{"--version", "v6.5.0"}, want: []string{"--version", "v6.5.0"}}, + {name: "explicit_source_over_invalid", config: `{`, args: []string{"--source", "main"}, want: []string{"--source", "main"}}, + {name: "explicit_archive_over_invalid", config: `{`, args: []string{"--archive", "/tmp/signed-release.tar.gz"}, want: []string{"--archive", "/tmp/signed-release.tar.gz"}}, + {name: "ordinary_options_keep_rc", config: `{"updateChannel":"rc"}`, args: []string{"--no-auto-update"}, want: []string{"--rc", "--no-auto-update"}}, + {name: "source_marker_over_invalid", config: `{`, marker: "maintenance\n", want: []string{"--source", "maintenance"}}, + {name: "explicit_channel_over_source_marker", config: `{`, marker: "main\n", args: []string{"--stable"}, want: []string{"--stable"}}, + {name: "signature_still_required", config: `{"updateChannel":"rc"}`, badSignature: true, blocked: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, "config") + if err := os.Mkdir(configDir, 0700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configDir, "system.json") + switch { + case tc.directory: + err = os.Mkdir(configPath, 0700) + case tc.brokenLink: + err = os.Symlink(filepath.Join(dir, "absent"), configPath) + case !tc.missingConfig: + err = os.WriteFile(configPath, []byte(tc.config), 0600) + } + if err != nil { + t.Fatal(err) + } + if tc.marker != "" { + if err := os.WriteFile(filepath.Join(dir, "BUILD_FROM_SOURCE"), []byte(tc.marker), 0600); err != nil { + t.Fatal(err) + } + } + helper := filepath.Join(dir, "update") + generate := exec.Command(bash, "-c", `source "$INSTALLER_SOURCE" +PINNED_RELEASE_SSH_PUBLIC_KEY="$FIXTURE_PUBLIC_KEY" +setup_update_command +`) + generate.Env = append(os.Environ(), "INSTALLER_SOURCE="+source, "FIXTURE_PUBLIC_KEY="+strings.TrimSpace(string(publicKey)), + "PULSE_INSTALL_DIR="+dir, "PULSE_CONFIG_DIR="+configDir, "PULSE_UPDATE_HELPER_PATH="+helper, + "PULSE_PROFILE_PATH="+filepath.Join(dir, "profile"), "PULSE_BASHRC_PATH="+filepath.Join(dir, "bashrc")) + if out, err := generate.CombinedOutput(); err != nil { + t.Fatalf("generate helper: %v\n%s", err, out) + } + if out, err := exec.Command(bash, "-n", helper).CombinedOutput(); err != nil { + t.Fatalf("generated Bash syntax: %v\n%s", err, out) + } + curl := `#!/bin/bash +set -eu +url="" +dest="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + https://github.com/rcourtman/Pulse/releases/latest/download/install.sh*) url="$1"; shift ;; + -fsSL) shift ;; + *) exit 98 ;; + esac +done +[[ -n "$url" && -n "$dest" ]] || exit 97 +printf '%s\n' "$url" >> "$MANUAL_UPDATE_TRANSPORT" +case "$url" in + *.sshsig) if [[ "$BAD_SIGNATURE" == true ]]; then printf invalid > "$dest"; else cp "$FIXTURE_ASSETS/install.sh.sig" "$dest"; fi ;; + */install.sh) cp "$FIXTURE_ASSETS/install.sh" "$dest" ;; + *) exit 96 ;; +esac +` + if err := os.WriteFile(filepath.Join(dir, "curl"), []byte(curl), 0700); err != nil { + t.Fatal(err) + } + path := dir + string(os.PathListSeparator) + os.Getenv("PATH") + if tc.missingJQ { + path = dir // parser fails before any non-builtin operation + } + calls := filepath.Join(dir, "calls") + transport := filepath.Join(dir, "transport") + cmd := exec.Command(bash, append([]string{helper}, tc.args...)...) + cmd.Env = append(os.Environ(), "PATH="+path, "FIXTURE_ASSETS="+assets, "MANUAL_UPDATE_CALLS="+calls, "MANUAL_UPDATE_TRANSPORT="+transport, + "BAD_SIGNATURE="+map[bool]string{true: "true", false: "false"}[tc.badSignature]) + out, runErr := cmd.CombinedOutput() + if tc.blocked { + if runErr == nil { + t.Fatalf("invalid preference/signature admitted: %s", out) + } + if _, err := os.Stat(calls); !os.IsNotExist(err) { + t.Fatalf("rejected helper executed installer: %v\n%s", err, out) + } + if !tc.badSignature { + if _, err := os.Stat(transport); !os.IsNotExist(err) { + t.Fatalf("invalid preference reached download: %v\n%s", err, out) + } + if !strings.Contains(string(out), "saved update channel") || !strings.Contains(string(out), "--stable or --rc") { + t.Fatalf("missing safe recovery guidance: %s", out) + } + } + } else { + if runErr != nil { + t.Fatalf("valid helper failed: %v\n%s", runErr, out) + } + got, err := os.ReadFile(calls) + if err != nil { + t.Fatal(err) + } + want := append([]string{"EXECUTED"}, tc.want...) + if !reflect.DeepEqual(strings.Split(strings.TrimSuffix(string(got), "\n"), "\n"), want) { + t.Fatalf("installer args %q, want %q", got, want) + } + } + if !tc.missingConfig && !tc.directory && !tc.brokenLink { + got, err := os.ReadFile(configPath) + if err != nil || string(got) != tc.config { + t.Fatalf("helper changed configuration: %v, %q", err, got) + } + } + }) + } +} From 493e0b5fa989f48a1cb7755a8a2f04d44ae897dc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:23:46 +0100 Subject: [PATCH 2/3] Bind parsed manual channels through installer execution The installer rereads saved preferences without an explicit selector. Forward the parsed stable default as well as RC, and prove the containing installer's actual channel-selection functions against the same signed fixtures. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 8 ++- install.sh | 4 ++ scripts/installtests/install_sh_test.go | 3 + .../manual_update_channel_test.go | 64 ++++++++++++++++--- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5c52503cd7..9c86a3aada 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5197,11 +5197,15 @@ missing jq stops before downloading or executing the installer, with explicit `--stable`/`--rc` recovery guidance and no configuration rewrite. A missing file retains the default. Explicit existing selectors (including `--prerelease`) and the existing source-build marker keep precedence over saved configuration. +The helper forwards both parsed channels explicitly, including the stable +default for an existing file, so the containing installer cannot reinterpret +that same configuration through its older saved-preference reader. `TestManualUpdateChannelAdmission` generates and executes the actual helper against local transport fixtures and genuinely signed installer/sidecar bytes. -It checks final arguments, pre-download refusal, configuration preservation, -selector/marker precedence and signature rejection. This is bounded source +It checks final arguments and the actual installer channel-selection functions, +pre-download refusal, configuration preservation, selector/marker precedence +and signature rejection. This is bounded source acceptance, not installed upgrade, native service restoration or published release acceptance. The installer, archive, signing, rollback and service gates remain unchanged. diff --git a/install.sh b/install.sh index 8d2b3061bc..e2678b30ba 100755 --- a/install.sh +++ b/install.sh @@ -4079,6 +4079,10 @@ if [[ "\$auto_selector_allowed" == "true" ]]; then fi if [[ "\$configured_channel" == "rc" ]]; then extra_args+=(--rc) + else + # Bind the parsed default too: the installer has its own saved + # preference reader, which must not re-interpret the same file. + extra_args+=(--stable) fi fi fi diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index a162387152..55efa1a672 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -4375,6 +4375,9 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) { if !strings.Contains(got, `extra_args+=(--rc)`) { t.Fatalf("update helper missing rc channel forwarding:\n%s", got) } + if !strings.Contains(got, `extra_args+=(--stable)`) { + t.Fatalf("update helper must bind the parsed stable channel before the installer rereads configuration:\n%s", got) + } if !strings.Contains(got, `INSTALLER_URL="https://github.com/example/pulse-fork/releases/latest/download/install.sh"`) { t.Fatalf("update helper missing configured repo installer url:\n%s", got) } diff --git a/scripts/installtests/manual_update_channel_test.go b/scripts/installtests/manual_update_channel_test.go index 83791b1c1d..13efa987f5 100644 --- a/scripts/installtests/manual_update_channel_test.go +++ b/scripts/installtests/manual_update_channel_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "reflect" + "regexp" "strings" "testing" ) @@ -35,8 +36,40 @@ func TestManualUpdateChannelAdmission(t *testing.T) { if err != nil { t.Fatal(err) } + rootSource, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + selectionFunctions := "" + for _, name := range []string{"read_configured_update_channel", "selected_update_channel"} { + body := regexp.MustCompile(`(?ms)^` + name + `\(\) \{\n.*?^\}`).Find(rootSource) + if body == nil { + t.Fatalf("cannot find actual installer channel function %s", name) + } + selectionFunctions += string(body) + "\n" + } installer := filepath.Join(assets, "install.sh") - if err := os.WriteFile(installer, []byte("#!/bin/bash\nset -eu\nprintf '%s\\n' EXECUTED \"$@\" > \"$MANUAL_UPDATE_CALLS\"\n"), 0600); err != nil { + // The real installer reads configuration again unless the helper forwards + // an explicit channel. Use its actual selection functions to catch that + // crossing rather than accepting helper arguments alone as the outcome. + fixture := `#!/bin/bash +set -eu +CONFIG_DIR="$PULSE_CONFIG_DIR" +FORCE_CHANNEL="" +FORCE_VERSION="" +UPDATE_CHANNEL="" +IGNORE_CONFIGURED_UPDATE_CHANNEL=false +` + selectionFunctions + ` +for arg in "$@"; do + case "$arg" in + --stable) FORCE_CHANNEL=stable ;; + --rc|--pre|--prerelease) FORCE_CHANNEL=rc ;; + esac +done +printf '%s\n' EXECUTED "$@" > "$MANUAL_UPDATE_CALLS" +selected_update_channel > "$MANUAL_UPDATE_CHANNEL" +` + if err := os.WriteFile(installer, []byte(fixture), 0600); err != nil { t.Fatal(err) } if out, err := exec.Command("ssh-keygen", "-q", "-Y", "sign", "-f", key, "-n", "pulse-install", installer).CombinedOutput(); err != nil { @@ -53,19 +86,19 @@ func TestManualUpdateChannelAdmission(t *testing.T) { brokenLink bool badSignature bool }{ - {name: "stable", config: `{"updateChannel":"stable"}`}, + {name: "stable", config: `{"updateChannel":"stable"}`, want: []string{"--stable"}}, {name: "rc", config: `{"updateChannel":"rc"}`, want: []string{"--rc"}}, {name: "normalized_rc", config: `{"updateChannel":" RC "}`, want: []string{"--rc"}}, - {name: "normalized_stable", config: `{"updateChannel":" STABLE "}`}, - {name: "missing_setting", config: `{}`}, - {name: "null_setting", config: `{"updateChannel":null}`}, - {name: "empty_setting", config: `{"updateChannel":" "}`}, + {name: "normalized_stable", config: `{"updateChannel":" STABLE "}`, want: []string{"--stable"}}, + {name: "missing_setting", config: `{}`, want: []string{"--stable"}}, + {name: "null_setting", config: `{"updateChannel":null}`, want: []string{"--stable"}}, + {name: "empty_setting", config: `{"updateChannel":" "}`, want: []string{"--stable"}}, {name: "missing_file", missingConfig: true}, - {name: "nested_only", config: `{"other":{"updateChannel":"rc"}}`}, - {name: "stable_with_nested_rc", config: `{"updateChannel":"stable","other":{"updateChannel":"rc"}}`}, + {name: "nested_only", config: `{"other":{"updateChannel":"rc"}}`, want: []string{"--stable"}}, + {name: "stable_with_nested_rc", config: `{"updateChannel":"stable","other":{"updateChannel":"rc"}}`, want: []string{"--stable"}}, {name: "rc_with_nested_stable", config: `{"updateChannel":"rc","other":{"updateChannel":"stable"}}`, want: []string{"--rc"}}, {name: "escaped_setting_name", config: `{"update\u0043hannel":"rc"}`, want: []string{"--rc"}}, - {name: "quoted_only", config: `{"note":"\"updateChannel\":\"rc\""}`}, + {name: "quoted_only", config: `{"note":"\"updateChannel\":\"rc\""}`, want: []string{"--stable"}}, {name: "truncated", config: `{"updateChannel":"rc"`, blocked: true}, {name: "valid_object_then_garbage", config: `{"updateChannel":"rc"} trailing`, blocked: true}, {name: "two_objects", config: `{"updateChannel":"stable"} {"updateChannel":"rc"}`, blocked: true}, @@ -157,8 +190,9 @@ esac } calls := filepath.Join(dir, "calls") transport := filepath.Join(dir, "transport") + channel := filepath.Join(dir, "channel") cmd := exec.Command(bash, append([]string{helper}, tc.args...)...) - cmd.Env = append(os.Environ(), "PATH="+path, "FIXTURE_ASSETS="+assets, "MANUAL_UPDATE_CALLS="+calls, "MANUAL_UPDATE_TRANSPORT="+transport, + cmd.Env = append(os.Environ(), "PATH="+path, "FIXTURE_ASSETS="+assets, "MANUAL_UPDATE_CALLS="+calls, "MANUAL_UPDATE_TRANSPORT="+transport, "MANUAL_UPDATE_CHANNEL="+channel, "BAD_SIGNATURE="+map[bool]string{true: "true", false: "false"}[tc.badSignature]) out, runErr := cmd.CombinedOutput() if tc.blocked { @@ -188,6 +222,16 @@ esac if !reflect.DeepEqual(strings.Split(strings.TrimSuffix(string(got), "\n"), "\n"), want) { t.Fatalf("installer args %q, want %q", got, want) } + selected, err := os.ReadFile(channel) + wantChannel := "stable" + for _, arg := range tc.want { + if arg == "--rc" || arg == "--prerelease" { + wantChannel = "rc" + } + } + if err != nil || strings.TrimSpace(string(selected)) != wantChannel { + t.Fatalf("installer selected %q: %v, want %s", selected, err, wantChannel) + } } if !tc.missingConfig && !tc.directory && !tc.brokenLink { got, err := os.ReadFile(configPath) From 545ca061d8b945c839e38268e467e643b0446663 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:41:37 +0100 Subject: [PATCH 3/3] Remove trailing contract whitespace from manual update candidate Remove exactly one surplus newline reported by deterministic preflight. Installer runtime, regression tests and substantive contract text remain unchanged. Contract-Neutral: EOF whitespace only; no runtime, test or contract delta. Change-source: pulse-maintainer --- .../v6/internal/subsystems/deployment-installability.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 9c86a3aada..0429c55dcd 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -7108,4 +7108,3 @@ contract checks the helper staging, unchanged cache/graph inputs and bounded command. These are offline command proofs, not a hosted Docker build, public proxy recovery, E2E acceptance or installed customer result. The hosted checks still have to build the image and execute the unchanged suites. -