From fdf464578ab7f68713e82f93f314d77df14a7cae Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:15:25 +0100 Subject: [PATCH 01/23] test(release): exercise exact published RC through Tailscale browser Reuse the signed-install lifecycle assertions on a fresh public hosted VM without Docker identity overrides. Bind immutable release assets/control, observe one real UI apply/SSE/modal journey, retain sanitised terminal evidence and stop access refusals without reauthentication. Update the deployment contract and execute its recovery-access guard; release judgment and gates are unchanged. Change-source: pulse-maintainer --- .../qualify-browser-update-release.yml | 164 ++++++++++++++ .../subsystems/deployment-installability.md | 63 +++++- scripts/release_browser_packet.py | 99 +++++++++ scripts/release_lifecycle_rehearsal.sh | 171 +++++++++++++-- scripts/tests/test_release_browser_packet.py | 157 ++++++++++++++ .../tests/test_release_lifecycle_rehearsal.py | 21 ++ .../scripts/release-browser-journey.cjs | 202 ++++++++++++++++++ .../scripts/release-browser-journey.test.cjs | 127 +++++++++++ 8 files changed, 989 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/qualify-browser-update-release.yml create mode 100644 scripts/release_browser_packet.py create mode 100644 scripts/tests/test_release_browser_packet.py create mode 100644 tests/integration/scripts/release-browser-journey.cjs create mode 100644 tests/integration/scripts/release-browser-journey.test.cjs diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml new file mode 100644 index 0000000000..a78363a122 --- /dev/null +++ b/.github/workflows/qualify-browser-update-release.yml @@ -0,0 +1,164 @@ +name: Qualify Published RC Browser Update +run-name: Browser update ${{ inputs.tag }} at ${{ inputs.expected_workflow_sha }} + +# Control runs only from reviewed public main. No source build, production +# target, publishing credential, old stopped-target retry or arbitrary URL. +on: + workflow_dispatch: + inputs: + tag: + description: 'Exact published immutable vX.Y.Z-rc.N' + required: true + type: string + expected_workflow_sha: + description: 'Exact reviewed main control SHA' + required: true + type: string +permissions: + contents: read +concurrency: + group: browser-release-${{ inputs.tag }} + cancel-in-progress: false +jobs: + journey: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout reviewed control without credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Bind control and exact published release pair + id: pair + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + EXPECTED_CONTROL: ${{ inputs.expected_workflow_sha }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = rcourtman/Pulse + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL" + test "$GITHUB_SHA" = "$EXPECTED_CONTROL" + test "$(git rev-parse HEAD)" = "$EXPECTED_CONTROL" + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] + mkdir -m 0700 "$RUNNER_TEMP/browser-release" + gh api repos/rcourtman/Pulse/releases/latest > "$RUNNER_TEMP/browser-release/baseline.json" + gh api "repos/rcourtman/Pulse/releases/tags/$TAG" > "$RUNNER_TEMP/browser-release/candidate.json" + from=$(jq -er '.tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/browser-release/baseline.json") + peel() { + local object type sha + object=$(gh api "repos/rcourtman/Pulse/git/ref/tags/$1") + type=$(jq -er '.object.type' <<<"$object") + sha=$(jq -er '.object.sha' <<<"$object") + if [[ "$type" == tag ]]; then + object=$(gh api "repos/rcourtman/Pulse/git/tags/$sha") + test "$(jq -er '.tag' <<<"$object")" = "$1" + type=$(jq -er '.object.type' <<<"$object") + sha=$(jq -er '.object.sha' <<<"$object") + fi + test "$type" = commit + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] + printf '%s' "$sha" + } + from_commit=$(peel "$from") + to_commit=$(peel "$TAG") + python3 scripts/release_browser_packet.py \ + --baseline "$RUNNER_TEMP/browser-release/baseline.json" \ + --candidate "$RUNNER_TEMP/browser-release/candidate.json" \ + --from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \ + --output "$RUNNER_TEMP/browser-release/packet.json" + python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")' + echo "from=$from" >> "$GITHUB_OUTPUT" + echo "from_commit=$from_commit" >> "$GITHUB_OUTPUT" + echo "to_commit=$to_commit" >> "$GITHUB_OUTPUT" + - name: Authenticate both release archives before execution + env: + FROM: ${{ steps.pair.outputs.from }} + TAG: ${{ inputs.tag }} + FROM_COMMIT: ${{ steps.pair.outputs.from_commit }} + TO_COMMIT: ${{ steps.pair.outputs.to_commit }} + run: | + set -euo pipefail + root="$RUNNER_TEMP/browser-release" + key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1) + test -n "$key" + printf 'pulse-installer %s\n' "$key" > "$root/allowed_signers" + for tag in "$FROM" "$TAG"; do + mkdir -m 0700 "$root/$tag" + name="pulse-$tag-linux-amd64.tar.gz" + for file in "$name" "$name.sshsig"; do + curl --fail --silent --show-error --location --retry 0 --max-time 120 \ + "https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file" + done + ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \ + -s "$root/$tag/$name.sshsig" < "$root/$tag/$name" + done + python3 scripts/release_browser_packet.py --baseline "$root/baseline.json" --candidate "$root/candidate.json" \ + --from-commit "$FROM_COMMIT" --to-commit "$TO_COMMIT" --tag "$TAG" \ + --archives "$root" --output "$root/packet.json" + - name: Prepare pinned browser without lifecycle hooks + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.20.0' + - name: Prepare Chromium and native tools + env: + PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers + run: | + set -euo pipefail + cd tests/integration + npm ci --ignore-scripts + npx --no-install playwright install --with-deps chromium + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends jq curl openssh-client + - name: Register a fresh ephemeral project CI node + uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:infra + version: '1.94.2' + - name: Serve only this disposable install + id: serve + run: | + set -euo pipefail + # Never enumerate peer/private targets or use a supplied hostname. + origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)') + tailscale serve --bg --https=443 http://127.0.0.1:7655 + echo "origin=$origin" >> "$GITHUB_OUTPUT" + - name: Exercise native browser update and documented recovery + env: + FROM: ${{ steps.pair.outputs.from }} + TAG: ${{ inputs.tag }} + ORIGIN: ${{ steps.serve.outputs.origin }} + run: | + set -euo pipefail + sudo env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \ + GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse GITHUB_REF=refs/heads/main \ + PULSE_REHEARSAL_WORKDIR="$RUNNER_TEMP/browser-release/journey" \ + PULSE_REHEARSAL_BROWSER_ORIGIN="$ORIGIN" PULSE_REHEARSAL_NODE="$(command -v node)" \ + PULSE_REHEARSAL_PACKET_MANIFEST="$RUNNER_TEMP/browser-release/packet.json" \ + PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \ + bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG" + - name: Close ephemeral Serve route + if: always() && steps.serve.outcome == 'success' + run: tailscale serve reset + - name: Retain bounded identity and terminal receipts, never credentials + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: browser-release-${{ github.run_id }} + path: | + ${{ runner.temp }}/browser-release/control.json + ${{ runner.temp }}/browser-release/packet.json + ${{ runner.temp }}/browser-release/journey/state/browser-upgrade.json + ${{ runner.temp }}/browser-release/journey/state/browser-recovery.json + ${{ runner.temp }}/browser-release/journey/state/browser-cleanup.json + ${{ runner.temp }}/browser-release/journey/state/lifecycle-result.json + ${{ runner.temp }}/browser-release/journey/state/installer.json + ${{ runner.temp }}/browser-release/journey/state/browser-*.png + ${{ runner.temp }}/browser-release/journey/state/installed-*.json + ${{ runner.temp }}/browser-release/journey/state/settings.*.json + ${{ runner.temp }}/browser-release/journey/state/auto-updates.*.tsv + if-no-files-found: error + retention-days: 7 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0abcdb1188..dc291872a9 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6360,7 +6360,7 @@ defaulted and nothing newer is published, the pair is the previous stable to the latest stable. An explicit pair must be published and move forward, so a same-version or missing-target run fails instead of passing. -`scripts/release_lifecycle_rehearsal.sh` runs every phase inside the same +`scripts/release_lifecycle_rehearsal.sh` defaults to running every phase inside the same digest-pinned `jrei/systemd-debian:12` container as the install smoke. 1. Install FROM with that release's own `install.sh --version`, after the @@ -6419,6 +6419,67 @@ passed all four phases. An earlier run correctly failed phase 1 on a new failed unit (`systemd-hostnamed`), which led to the container-only list above. The hosted Docker run is not yet claimed. +### Exact published-RC Tailscale browser acceptance + +`qualify-browser-update-release.yml` uses the lifecycle harness's separate +`--hosted-browser` mode on a **fresh public hosted Ubuntu 24.04 VM**. The shadow +container mode above remains unchanged in purpose. A real native install is +required for browser updates: Docker detection stays truthful, with no marker +removal, mock updater, TLS bypass or `PULSE_ALLOW_DOCKER_UPDATES` override. + +Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and +`GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable +stable and the requested published immutable RC. Peeled commit, release/asset +IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH +signatures verify before execution. Each installed binary hash must match its +signed archive, rather than just a reported version. No rebuilt main binary, +frozen-product insertion, candidate re-cut or stable promotion is performed. + +A pinned Tailscale action registers an ephemeral project CI node; Serve exposes +only its own loopback Pulse install through its derived `tawny-powan.ts.net` +HTTPS identity. No caller-selected endpoint, peer or previous stopped target is +used. Registration alone receives the existing CI OAuth credential; acceptance +commands receive a scrubbed environment with no provider, production or +publishing credential. State is synthetic, disabled resources and throwaway +admin/API authentication, with the existing telemetry opt-out. + +The real Chromium UI signs in once, chooses Preview, checks the actual feed's +exact RC/archive, and confirms one Install Update. The observer requires real +EventSource open/progress, exact served version, healthy status, authenticated +readiness and progress-modal completion; wrong selection, silent progress, +rejected apply or a stuck modal fails. API interception and fabricated version +or progress are not acceptance. HTTP401/403 stops browser access, including +skipping a recovery login after recorded refusal; no alternate identity or apply +replay is used. + +The same harness checks systemd, the seeded settings/encrypted stores and the +original auto-update/timer intent after forward update and documented CLI +`/bin/update --version FROM` recovery. Browser authentication/health readback +then checks the restored baseline. This is CLI recovery plus browser readback, +**not** the separate history-based UI rollback action. Cleanup stops the +throwaway service/timer, records its result and fails the process on failure; +the workflow resets the new Serve route and its hosted VM is disposed of. + +Bounded control, signed packet, installer/binary, phase, browser, cleanup and +redacted settings receipts are retained for seven days. Successful screenshots +contain only synthetic state. Auth files, bootstrap/API/node tokens, encrypted +file manifests and raw installer/updater/service logs are not artifacts. +Delivery must reconcile terminal logs and all receipts with the exact RC; +a workflow conclusion alone is insufficient. Existing qualification, soak, +release-steward judgment and publication boundaries remain unchanged. The +previous target's undiagnosed HTTP401 is not diagnosed or cleared by this mode. + +Verification: `scripts/tests/test_release_browser_packet.py` executes immutable +release/asset/binary identity rejection, worker-mode exclusion, scrubbed command +environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py` +executes the recovery-access stop before another browser process can start. +`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real +observer against offline Chromium success/recovery and login401, wrong-target, +silent-SSE, stuck-modal and authenticated-read401 fixtures. These are observer +and tooling proofs, **not native Tailscale/update/recovery acceptance**. That +requires reviewed publication, infrastructure activation and a terminal hosted +collection through the fixed Delivery dispatcher. + ### Release-line dependency advisory watch (1 October 2026) On 30 September and 1 October 2026 `GHSA-q2hr-2g5m-vwhr` (`brace-expansion`) diff --git a/scripts/release_browser_packet.py b/scripts/release_browser_packet.py new file mode 100644 index 0000000000..0916cbe2a3 --- /dev/null +++ b/scripts/release_browser_packet.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Bind a hosted browser journey to two exact published, signed release assets. + +Reads local metadata and archives; performs no network, extraction or execution. +The workflow separately verifies SSH signatures before --archives is supplied. +""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import tarfile + + +def release_identity(release, *, tag, commit, prerelease): + pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + if prerelease: + pattern += r'-rc\.[1-9][0-9]*' + if (not re.fullmatch(pattern, tag) or not re.fullmatch(r'[0-9a-f]{40}', commit) + or release.get('tag_name') != tag or release.get('target_commitish') != commit + or release.get('draft') is not False or release.get('prerelease') is not prerelease + or release.get('immutable') is not True or not release.get('published_at') + or type(release.get('id')) is not int or release['id'] <= 0): + raise ValueError('Exact published immutable release/tag identity required') + asset_name = f'pulse-{tag}-linux-amd64.tar.gz' + assets = [] + for name in (asset_name, asset_name + '.sshsig'): + matches = [a for a in release.get('assets', []) if a.get('name') == name] + if len(matches) != 1: + raise ValueError('Missing or ambiguous release asset') + asset = matches[0] + if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}' + or type(asset.get('id')) is not int or asset['id'] <= 0 + or type(asset.get('size')) is not int or not 0 < asset['size'] <= 256 * 1024 * 1024 + or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))): + raise ValueError('Unsafe or unbound release asset') + assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']}) + return {'tag': tag, 'release_id': release['id'], 'commit': commit, 'assets': assets} + + +def file_sha256(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def archive_identity(path): + # Inspect only one bounded regular binary; do not extract the package. + with tarfile.open(path, 'r:gz') as archive: + matches = [] + total_size = 0 + for i, member in enumerate(archive): + total_size += member.size + if total_size > 1024 * 1024 * 1024: + raise ValueError('Oversized expanded release archive') + if i >= 4096: + raise ValueError('Oversized release archive inventory') + if member.name in ('./bin/pulse', 'bin/pulse'): + matches.append(member) + if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= 256 * 1024 * 1024: + raise ValueError('Missing or ambiguous regular server binary') + stream = archive.extractfile(matches[0]) + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def packet(baseline, candidate, from_commit, to_commit, tag, archives=None): + stable_tag = baseline.get('tag_name', '') + releases = [release_identity(baseline, tag=stable_tag, commit=from_commit, prerelease=False), + release_identity(candidate, tag=tag, commit=to_commit, prerelease=True)] + if tuple(map(int, stable_tag[1:].split('.'))) >= tuple(map(int, tag[1:].split('-')[0].split('.'))): + raise ValueError('The candidate must be newer than its published stable baseline') + if archives is not None: + for release in releases: + for asset in release['assets']: + p = archives / release['tag'] / asset['name'] + if p.is_symlink() or not p.is_file() or p.stat().st_size != asset['size']: + raise ValueError('Release asset size/type differs from live identity') + if file_sha256(p) != asset['digest'].split(':')[1]: + raise ValueError('Release asset digest differs from live identity') + release['binary_sha256'] = archive_identity(archives / release['tag'] / release['assets'][0]['name']) + return {'schema_version': 1, 'repository': 'rcourtman/Pulse', 'releases': releases} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--baseline', required=True, type=Path) + parser.add_argument('--candidate', required=True, type=Path) + parser.add_argument('--from-commit', required=True) + parser.add_argument('--to-commit', required=True) + parser.add_argument('--tag', required=True) + parser.add_argument('--archives', type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + result = packet(json.loads(args.baseline.read_text()), json.loads(args.candidate.read_text()), + args.from_commit, args.to_commit, args.tag, args.archives) + args.output.write_text(json.dumps(result, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/release_lifecycle_rehearsal.sh b/scripts/release_lifecycle_rehearsal.sh index 100fc33889..22a41e1802 100755 --- a/scripts/release_lifecycle_rehearsal.sh +++ b/scripts/release_lifecycle_rehearsal.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # # Release lifecycle rehearsal: install, upgrade and roll back Pulse between two -# PUBLISHED releases on a real systemd host (a privileged systemd container). +# PUBLISHED releases on a real systemd host. Default: shadow systemd container. +# --hosted-browser: fresh public GitHub VM, authenticated Tailscale Serve UI +# update/SSE, documented CLI recovery and browser/session readback. # # 1. install FROM with that release's signature-verified install.sh --version # 2. assert FROM identity, health and unit state @@ -49,10 +51,12 @@ SEED_ADMIN_USER="rehearsal-admin" FROM_TAG="" TO_TAG="" +HOSTED_BROWSER=false CHECK_SNAPSHOT="" COMPARE_DATADIR=() while [[ $# -gt 0 ]]; do case "$1" in + --hosted-browser) HOSTED_BROWSER=true; shift ;; --from) FROM_TAG="${2:-}"; shift 2 ;; --to) TO_TAG="${2:-}"; shift 2 ;; # Offline self-tests used by the contract tests: apply the fixed @@ -78,6 +82,30 @@ if [[ -z "$CHECK_SNAPSHOT" && ${#COMPARE_DATADIR[@]} -eq 0 ]]; then fi fi +# The browser route is a real first-level public CI host, not a container +# overriding Docker detection. It must never install onto a maintainer host. +if [[ "$HOSTED_BROWSER" == true ]]; then + if [[ "$EUID" != 0 || "${GITHUB_ACTIONS:-}" != true \ + || "${RUNNER_ENVIRONMENT:-}" != github-hosted \ + || "${GITHUB_REPOSITORY:-}" != rcourtman/Pulse \ + || "${GITHUB_REF:-}" != refs/heads/main \ + || -e /opt/pulse || -e /etc/pulse \ + || -e /etc/systemd/system/pulse.service || -e /rehearsal ]]; then + echo "::error::Browser lifecycle requires a fresh disposable public hosted runner." + exit 2 + fi + if [[ ! -f "${PULSE_REHEARSAL_PACKET_MANIFEST:-}" ]]; then + echo "::error::A verified immutable release asset manifest is required." + exit 2 + fi + # The workflow derives this from the new runner's own Tailscale identity. + # No caller endpoint, old stopped target or TLS-verification bypass exists. + if [[ ! "${PULSE_REHEARSAL_BROWSER_ORIGIN:-}" =~ ^https://[a-z0-9-]+\.tawny-powan\.ts\.net$ ]]; then + echo "::error::Browser lifecycle requires the ephemeral runner's HTTPS Serve origin." + exit 2 + fi +fi + REPO="${PULSE_REHEARSAL_REPO:-rcourtman/Pulse}" if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "::error::invalid PULSE_REHEARSAL_REPO '${REPO}'" >&2 @@ -126,11 +154,22 @@ cexec() { for pair in "$@"; do env_args+=(-e "$pair") done - "$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program" + if [[ "$HOSTED_BROWSER" == true ]]; then + # Never pass workflow, GitHub or tailnet-registration credentials to + # the installed application or an acceptance command. + env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \ + "${@}" bash -c "$program" + else + "$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program" + fi } print_diagnostics() { [[ "$CONTAINER_STARTED" == "true" ]] || return 0 + if [[ "$HOSTED_BROWSER" == true ]]; then + echo "::error::Hosted lifecycle failed; inspect bounded browser/identity receipts. No raw credentials or journal retained." + return + fi echo "::group::Diagnostics: pulse journal (tail)" cexec 'journalctl -u pulse --no-pager | tail -n 150' || true echo "::endgroup::" @@ -155,8 +194,28 @@ cleanup() { print_diagnostics fi if [[ "$CONTAINER_STARTED" == "true" ]]; then - "$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true + if [[ "$HOSTED_BROWSER" == true ]]; then + local cleanup_status=0 + systemctl stop pulse.service || cleanup_status=1 + if [[ "$(systemctl show pulse-update.timer --property=LoadState --value)" != not-found ]]; then + systemctl stop pulse-update.timer || cleanup_status=1 + fi + if systemctl is-active --quiet pulse.service; then cleanup_status=1; fi + if systemctl is-active --quiet pulse-update.timer; then cleanup_status=1; fi + printf '{"cleanup_exit":%s,"host_disposal":"github-hosted-job"}\n' "$cleanup_status" > "${WORK_DIR}/state/browser-cleanup.json" + [[ "$cleanup_status" == 0 ]] || status=1 + else + "$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true + fi fi + if [[ "$HOSTED_BROWSER" == true ]]; then + # Ephemeral application credentials are never retained as evidence. + if [[ -f "${WORK_DIR}/state/browser-auth.json" ]]; then + : > "${WORK_DIR}/state/browser-auth.json" + fi + fi + trap - EXIT + exit "$status" } trap cleanup EXIT @@ -165,7 +224,11 @@ write_summary() { table=$( echo "## Release lifecycle rehearsal" echo - echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate." + if [[ "$HOSTED_BROWSER" == true ]]; then + echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a fresh public hosted VM with HTTPS Tailscale Serve. Browser acceptance evidence, not a stable promotion decision." + else + echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate." + fi echo echo "| Phase | Expected | Reported version | Health | Settings | Data dir | Units | Result |" echo "| --- | --- | --- | --- | --- | --- | --- | --- |" @@ -182,6 +245,12 @@ write_summary() { done fi ) + if [[ "$HOSTED_BROWSER" == true ]]; then + jq -cn --argjson overall_status "$OVERALL_STATUS" \ + --argjson phases "$(printf '%s\n' "${PHASE_ROWS[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \ + '{schema_version:1, overall_status:$overall_status, phases:$phases}' \ + > "${WORK_DIR}/state/lifecycle-result.json" + fi echo echo "$table" if [[ -n "$SUMMARY_FILE" ]]; then @@ -216,8 +285,10 @@ fetch_and_verify_installer() { local dest="${WORK_DIR}/assets" local base="https://github.com/${REPO}/releases/download/${tag}" local asset + local -a retry_args=(--retry 5 --retry-delay 5 --retry-all-errors) + if [[ "$HOSTED_BROWSER" == true ]]; then retry_args=(--retry 0 --max-time 120); fi for asset in install.sh install.sh.sshsig; do - if ! curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \ + if ! curl -fsSL "${retry_args[@]}" \ -o "${dest}/${asset}" "${base}/${asset}"; then echo "::error::could not download ${base}/${asset}" return 1 @@ -246,6 +317,13 @@ fetch_and_verify_installer() { echo "::error::${tag} install.sh is not the Pulse server installer with --version support" return 1 fi + if [[ "$HOSTED_BROWSER" == true ]]; then + jq -cn --arg tag "$tag" \ + --arg installer_sha256 "$(sha256sum "${dest}/install.sh" | cut -d' ' -f1)" \ + --arg signature_sha256 "$(sha256sum "${dest}/install.sh.sshsig" | cut -d' ' -f1)" \ + '{tag:$tag,installer_sha256:$installer_sha256,signature_sha256:$signature_sha256,signature_verified:true}' \ + > "${WORK_DIR}/state/installer.json" + fi log "${tag} install.sh signature verifies against the README-pinned key" } @@ -254,6 +332,14 @@ fetch_and_verify_installer() { # --------------------------------------------------------------------------- start_container() { + if [[ "$HOSTED_BROWSER" == true ]]; then + CONTAINER_STARTED=true + BASELINE_FAILED_UNITS=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort') + # Keep the same telemetry opt-out, without changing deployment identity, + # enabling Docker updates, mocking feeds or weakening authentication. + cexec 'mkdir -p /etc/systemd/system/pulse.service.d && printf "[Service]\nEnvironment=PULSE_TELEMETRY=false\n" > /etc/systemd/system/pulse.service.d/50-rehearsal-no-telemetry.conf' + return + fi local -a run_args=(run -d --name "$CONTAINER" --privileged -v "${WORK_DIR}/assets:/rehearsal:ro") if [[ "$ENGINE" == "docker" ]]; then @@ -349,6 +435,15 @@ assert_runtime() { note_failure "/opt/pulse/bin/pulse --version reported '${binary_version:-none}', expected ${expected_tag}" fi + if [[ "$HOSTED_BROWSER" == true ]]; then + local observed_digest expected_digest + observed_digest=$(cexec 'sha256sum /opt/pulse/bin/pulse | cut -d" " -f1') + expected_digest=$(jq -er --arg tag "$expected_tag" '.releases[] | select(.tag == $tag) | .binary_sha256' "$PULSE_REHEARSAL_PACKET_MANIFEST") + printf '{"tag":"%s","binary_sha256":"%s","expected_binary_sha256":"%s"}\n' \ + "$expected_tag" "$observed_digest" "$expected_digest" > "${WORK_DIR}/state/installed-${expected_tag}.json" + [[ "$observed_digest" == "$expected_digest" ]] || note_failure "Installed binary differs from the verified published archive" + fi + local enabled failed_now new_failed enabled=$(cexec 'systemctl is-enabled pulse' 2>/dev/null || true) failed_now=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort' 2>/dev/null || true) @@ -605,12 +700,27 @@ check_settings() { # Phases # --------------------------------------------------------------------------- +install_baseline() { + if [[ "$HOSTED_BROWSER" == true ]]; then + cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \ + > "${WORK_DIR}/state/private-install.log" 2>&1 + else + cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \ + 2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log" + fi +} + phase_install() { reset_phase_cells log "Phase 1: install ${FROM_TAG} with its published install.sh --version" # No TTY: install.sh's prompts take their defaults, as for curl | bash. - if ! cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \ - 2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"; then + if [[ "$HOSTED_BROWSER" == true ]]; then + # /rehearsal is the fixed mount spelling used by the existing harness. + # It is absent on a fresh hosted runner; never overwrite an existing path. + [[ ! -e /rehearsal ]] || return 1 + ln -s "${WORK_DIR}/assets" /rehearsal + fi + if ! install_baseline; then note_failure "install.sh --version ${FROM_TAG} exited non-zero" fi assert_runtime "$FROM_TAG" @@ -635,7 +745,7 @@ phase_seed() { if ! setup_response=$(cexec 'curl -fsS -X POST -H "Content-Type: application/json" -H "X-Setup-Token: $SETUP_TOKEN" --data "$BODY" "$API/api/security/quick-setup"' \ "API=${API}" "SETUP_TOKEN=${setup_token}" "BODY=${setup_body}") \ || [[ "$(jq -r '.success // false' <<<"$setup_response" 2>/dev/null)" != "true" ]]; then - note_failure "first-run security setup failed: ${setup_response:-no response}" + note_failure "first-run security setup did not return success" fi local webhook_body node_body @@ -695,28 +805,61 @@ run_updater() { fi # /bin/update downloads the latest published install.sh, verifies it with # its embedded signer key and runs it with the given arguments. - if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \ + if [[ "$HOSTED_BROWSER" == true ]]; then + if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \ + > "${WORK_DIR}/state/private-updater.log" 2>&1; then + note_failure "/bin/update --version ${target} exited non-zero" + fi + elif ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \ 2>&1 | tee "${WORK_DIR}/state/${label}-${target}.log"; then note_failure "/bin/update --version ${target} exited non-zero" fi } +run_browser_journey() { + local mode="$1" expected="$2" + if [[ "$mode" == recovery && -f "${WORK_DIR}/state/browser-upgrade.json" ]] \ + && jq -e '.access_refused == true' "${WORK_DIR}/state/browser-upgrade.json" >/dev/null; then + printf '{"status":"not-executed","reason":"stopped-access-no-reauthentication"}\n' \ + > "${WORK_DIR}/state/browser-recovery.json" + return 1 + fi + (umask 077; jq -cn --arg username "$SEED_ADMIN_USER" --arg password "$ADMIN_PASSWORD" \ + '{username:$username,password:$password}' > "${WORK_DIR}/state/browser-auth.json") + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="${WORK_DIR}" \ + PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pulse-browser}" \ + "${PULSE_REHEARSAL_NODE:?}" "${ROOT_DIR}/tests/integration/scripts/release-browser-journey.cjs" \ + --origin "$PULSE_REHEARSAL_BROWSER_ORIGIN" --mode "$mode" \ + --from "$FROM_TAG" --to "$TO_TAG" --expected "$expected" \ + --auth-file "${WORK_DIR}/state/browser-auth.json" --output-dir "${WORK_DIR}/state" +} + phase_upgrade() { reset_phase_cells - log "Phase 3: upgrade with /bin/update --version ${TO_TAG}" - run_updater "$TO_TAG" upgrade || true + if [[ "$HOSTED_BROWSER" == true ]]; then + log "Phase 3: upgrade to ${TO_TAG} through the authenticated Tailscale Serve browser" + run_browser_journey upgrade "$TO_TAG" || note_failure "Tailscale browser upgrade failed; no apply replay" + else + log "Phase 3: upgrade with /bin/update --version ${TO_TAG}" + run_updater "$TO_TAG" upgrade || true + fi assert_runtime "$TO_TAG" check_auto_update_intent upgrade || true check_settings upgrade || true check_datadir upgrade || true - record_phase "3. upgrade (/bin/update)" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS" + record_phase "3. upgrade ($([[ "$HOSTED_BROWSER" == true ]] && printf browser || printf /bin/update))" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS" } phase_rollback() { reset_phase_cells log "Phase 4: roll back with /bin/update --version ${FROM_TAG}" + # The documented recovery command is exercised even after an adverse + # browser update. It is not a second attempt to apply the candidate. run_updater "$FROM_TAG" rollback || true assert_runtime "$FROM_TAG" + if [[ "$HOSTED_BROWSER" == true ]]; then + run_browser_journey recovery "$FROM_TAG" || note_failure "Tailscale browser recovery/readback failed" + fi check_auto_update_intent rollback || true check_settings rollback || true check_datadir rollback || true @@ -724,13 +867,13 @@ phase_rollback() { } main() { - log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO} with ${ENGINE}" + log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO}; hosted browser=${HOSTED_BROWSER}" if ! fetch_and_verify_installer "$FROM_TAG"; then CURRENT_FAILURE="published install.sh for ${FROM_TAG} failed verification" record_phase "0. verify installer" "$FROM_TAG" "-" "-" "-" "-" "-" || abort_run fi if ! start_container; then - CURRENT_FAILURE="systemd container did not start" + CURRENT_FAILURE="disposable systemd host did not start" record_phase "0. systemd host" "-" "-" "-" "-" "-" "-" || abort_run fi diff --git a/scripts/tests/test_release_browser_packet.py b/scripts/tests/test_release_browser_packet.py new file mode 100644 index 0000000000..8a802b70cb --- /dev/null +++ b/scripts/tests/test_release_browser_packet.py @@ -0,0 +1,157 @@ +"""Exact release identity and fail-closed hosted browser control tests, offline.""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('browser_packet', ROOT / 'scripts/release_browser_packet.py') +packet = importlib.util.module_from_spec(spec) +spec.loader.exec_module(packet) + + +def release(tag, commit): + return {'id': 123, 'tag_name': tag, 'target_commitish': commit, 'draft': False, + 'prerelease': '-rc.' in tag, 'immutable': True, 'published_at': '2026-10-02T19:21:28Z', + 'assets': [{'id': i + 1, 'name': name, 'size': 5, 'digest': 'sha256:' + 'b' * 64, + 'browser_download_url': f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'} + for i, name in enumerate((f'pulse-{tag}-linux-amd64.tar.gz', f'pulse-{tag}-linux-amd64.tar.gz.sshsig'))]} + + +class BrowserPacketTest(unittest.TestCase): + def setUp(self): + self.baseline = release('v6.4.5', 'a' * 40) + self.candidate = release('v6.4.6-rc.1', 'c' * 40) + + def bind(self, archives=None): + return packet.packet(self.baseline, self.candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1', archives) + + def test_binds_two_immutable_exact_commits_not_control_head(self): + result = self.bind() + self.assertEqual(['a' * 40, 'c' * 40], [r['commit'] for r in result['releases']]) + self.assertEqual('rcourtman/Pulse', result['repository']) + self.assertNotIn('assets', result) # Only bounded constituent identities are retained. + + def test_rejects_unpublished_mutable_wrong_maturity_or_ref(self): + for field, value in [('draft', True), ('immutable', False), ('prerelease', False), + ('target_commitish', 'main'), ('tag_name', 'v6.4.6-rc.2'), + ('published_at', None), ('id', True)]: + with self.subTest(field=field), self.assertRaises(ValueError): + candidate = self.candidate | {field: value} + packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1') + with self.assertRaises(ValueError): + packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9') + + def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self): + for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0), + ('size', 257 * 1024 * 1024), ('digest', None), ('id', True)]: + with self.subTest(field=field), self.assertRaises(ValueError): + candidate = copy.deepcopy(self.candidate) + candidate['assets'][0][field] = value + packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1') + self.candidate['assets'].append(self.candidate['assets'][0]) + with self.assertRaises(ValueError): + self.bind() + + def make_assets(self, root): + for r in (self.baseline, self.candidate): + folder = root / r['tag_name']; folder.mkdir() + for asset in r['assets']: + path = folder / asset['name'] + if asset['name'].endswith('.sshsig'): + path.write_bytes(b'fixture signature - cryptographic verification belongs to SSH workflow') + else: + data = ('fixture-' + r['tag_name']).encode() + with tarfile.open(path, 'w:gz') as archive: + member = tarfile.TarInfo('./bin/pulse'); member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + asset['size'] = path.stat().st_size + asset['digest'] = 'sha256:' + hashlib.sha256(path.read_bytes()).hexdigest() + + def test_asset_digest_and_binary_digest_both_checked_without_extraction(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); self.make_assets(root) + result = self.bind(root) + self.assertEqual(hashlib.sha256(b'fixture-v6.4.6-rc.1').hexdigest(), result['releases'][1]['binary_sha256']) + target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name'] + target.write_bytes(b'X' * target.stat().st_size) + with self.assertRaisesRegex(ValueError, 'digest'): + self.bind(root) + + def test_symlink_and_duplicate_binary_are_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); self.make_assets(root) + target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name'] + real = target.with_suffix('.real'); target.rename(real); target.symlink_to(real) + with self.assertRaisesRegex(ValueError, 'size/type'): + self.bind(root) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'bad.tar.gz' + for names in (['./bin/pulse', 'bin/pulse'], ['./bin/pulse']): + with self.subTest(names=names): + with tarfile.open(path, 'w:gz') as archive: + for name in names: + member = tarfile.TarInfo(name); member.type = tarfile.SYMTYPE; member.linkname = '/etc/passwd' + archive.addfile(member) + with self.assertRaises(ValueError): + packet.archive_identity(path) + + def test_native_mode_refuses_worker_before_install_or_network(self): + result = subprocess.run(['bash', ROOT / 'scripts/release_lifecycle_rehearsal.sh', '--hosted-browser', + '--from', 'v6.4.5', '--to', 'v6.4.6-rc.1'], + env={'PATH': '/usr/bin:/bin'}, capture_output=True, text=True) + self.assertEqual(2, result.returncode) + self.assertIn('fresh disposable public hosted runner', result.stdout) + + def test_cleanup_failure_sets_process_exit_and_scrubs_only_ephemeral_auth(self): + source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text() + cleanup = source.split('cleanup() {', 1)[1].split('\ntrap cleanup EXIT', 1)[0] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); (root / 'state').mkdir(); (root / 'state/browser-auth.json').write_text('throwaway') + # Execute the real cleanup function with fixture systemctl: not a system/installation proof. + script = 'set -u\nOVERALL_STATUS=0; CONTAINER_STARTED=true; HOSTED_BROWSER=true\n' + script += f'WORK_DIR={json.dumps(tmp)}\nprint_diagnostics() {{ :; }}\nsystemctl() {{ return 1; }}\n' + script += 'cleanup() {' + cleanup + '\ntrap cleanup EXIT\nexit 0\n' + result = subprocess.run(['bash', '-c', script], capture_output=True, text=True) + self.assertEqual(1, result.returncode, result.stderr) + self.assertEqual('', (root / 'state/browser-auth.json').read_text()) + self.assertEqual(1, json.loads((root / 'state/browser-cleanup.json').read_text())['cleanup_exit']) + + def test_cexec_native_environment_drops_provider_and_registration_secrets(self): + source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text() + function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0] + script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n' + result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin', + 'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'}, + capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn('synthetic-secret', result.stdout) + self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout) + self.assertIn('EXTRA=fixture', result.stdout) + + def test_workflow_has_no_arbitrary_target_build_publish_or_credential_in_acceptance(self): + source = (ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text() + self.assertNotIn('pull_request:', source) + self.assertNotIn('contents: write', source) + self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text()) + self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source) + self.assertIn('runs-on: ubuntu-24.04', source) + self.assertIn('"Self"]["DNSName"]', source) + self.assertIn('ssh-keygen -Y verify', source) + acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0] + self.assertIn('sudo env -i', acceptance) + for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'): + self.assertNotIn(name, acceptance) + artifact = source.split('- name: Retain bounded', 1)[1] + for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'): + self.assertNotIn(private, artifact) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index b778c40c2e..f36a90acca 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -177,6 +177,27 @@ class HarnessContractTest(unittest.TestCase): self.assertEqual(result.returncode, 2, result.stdout + result.stderr) +class BrowserRecoveryAccessStopTest(unittest.TestCase): + def test_refused_upgrade_does_not_launch_another_login(self) -> None: + source = HARNESS_PATH.read_text(encoding="utf-8") + function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}" + for status in (401, 403): + with self.subTest(status=status), tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "state").mkdir() + (work / "state/browser-upgrade.json").write_text(json.dumps({"access_refused": True, "login_status": status})) + script = "set -euo pipefail\nWORK_DIR=" + json.dumps(tmp) + "\n" + script += function + "\nrun_browser_journey recovery v6.4.5\n" + # NODE/auth/origin variables are intentionally absent. Reaching + # process/auth preparation would fail this exact guard test. + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + self.assertEqual(1, result.returncode, result.stderr) + self.assertEqual({"status": "not-executed", "reason": "stopped-access-no-reauthentication"}, + json.loads((work / "state/browser-recovery.json").read_text())) + self.assertFalse((work / "state/browser-auth.json").exists()) + self.assertEqual("", result.stderr) + + class AutoUpdateIntentTest(unittest.TestCase): """Execute the real observer/comparator, with guest systemctl observations.""" diff --git a/tests/integration/scripts/release-browser-journey.cjs b/tests/integration/scripts/release-browser-journey.cjs new file mode 100644 index 0000000000..bb9eecae45 --- /dev/null +++ b/tests/integration/scripts/release-browser-journey.cjs @@ -0,0 +1,202 @@ +/* Real, bounded browser observation of the disposable release install. + * No intercepted API responses, mocked versions, TLS bypass or apply replay. + * The CLI accepts only the new runner's HTTPS Tailscale Serve origin. Exported + * functions allow offline adversarial fixtures to test the observer itself. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { chromium } = require('playwright'); + +function validate(options) { + assert.match(options.origin, /^https:\/\/[a-z0-9-]+\.tawny-powan\.ts\.net$/); + assert.match(options.from, /^v\d+\.\d+\.\d+$/); + assert.match(options.to, /^v\d+\.\d+\.\d+-rc\.[1-9]\d*$/); + assert.ok(['upgrade', 'recovery'].includes(options.mode)); + assert.equal(options.expected, options.mode === 'upgrade' ? options.to : options.from); +} + +function judge(record) { + assert.equal(record.login_status, 200, 'authenticated UI login must succeed'); + assert.equal(record.observed_version, record.expected.replace(/^v/, ''), 'wrong served version'); + assert.equal(record.health, 'healthy', 'installed server is not healthy'); + assert.equal(record.protected_read_status, 200, 'session did not survive/recover'); + if (record.mode === 'upgrade') { + assert.equal(record.apply_requests, 1, 'exactly one UI apply is required'); + assert.equal(record.apply_status, 200, 'apply was not accepted'); + assert.equal(record.selected_version, record.to.replace(/^v/, ''), 'wrong release selected'); + assert.ok(record.sse_opens > 0, 'no real EventSource connection opened'); + assert.ok(record.sse_progress.some(p => p.status !== 'idle' && p.progress > 0), + 'no live SSE progress received'); + assert.equal(record.progress_dialog_visible, false, 'progress modal remained stuck after readiness'); + } +} + +async function runJourney(browser, options) { + const record = { schema_version: 1, mode: options.mode, from: options.from, to: options.to, + expected: options.expected, browser_version: browser.version(), + client_version: require('playwright/package.json').version, active_step: 'start', login_status: null, apply_requests: 0, apply_status: null, + sse_opens: 0, sse_progress: [], access_refused: false, status: 'incomplete' }; + assert.equal(record.client_version, '1.56.1', 'browser client must match the reviewed lock'); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + page.setDefaultTimeout(options.timeout || 30000); + // Retain only closed status/progress fields, not messages, headers, cookies, + // request bodies, full response JSON or private resource data. + await page.exposeFunction('__observeReleaseStream', item => { + if (item.open === true) record.sse_opens++; + if (['idle', 'downloading', 'verifying', 'extracting', 'installing', 'restarting', + 'completed', 'error', 'failed', 'checking', 'backing_up'].includes(item.status) + && Number.isFinite(item.progress) && item.progress >= 0 && item.progress <= 100 + && record.sse_progress.length < 1000) { + record.sse_progress.push({ status: item.status, progress: item.progress }); + } + }); + await page.addInitScript(() => { + const NativeEventSource = window.EventSource; + window.EventSource = class extends NativeEventSource { + constructor(url, config) { + super(url, config); + if (new URL(url, location.href).pathname === '/api/updates/stream') { + this.addEventListener('open', () => window.__observeReleaseStream({ open: true })); + this.addEventListener('message', event => { + try { + const data = JSON.parse(event.data); + void window.__observeReleaseStream({ status: data.status, progress: data.progress }); + } catch (_) { /* malformed data is not progress evidence */ } + }); + } + } + }; + }); + page.on('request', request => { + if (new URL(request.url()).pathname === '/api/updates/apply' && request.method() === 'POST') { + record.apply_requests++; + } + }); + page.on('response', response => { + const endpoint = new URL(response.url()).pathname; + if (endpoint === '/api/login' && response.request().method() === 'POST') { + record.login_status = response.status(); + if ([401, 403].includes(response.status())) record.access_refused = true; + } + if (endpoint === '/api/updates/apply' && response.request().method() === 'POST') { + record.apply_status = response.status(); + if ([401, 403].includes(response.status())) record.access_refused = true; + } + }); + try { + record.active_step = 'login'; + await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' }); + await page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username); + await page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password); + const login = page.waitForResponse(r => new URL(r.url()).pathname === '/api/login' + && r.request().method() === 'POST'); + await page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click(); + assert.equal((await login).status(), 200, 'login rejected; do not retry or change identity'); + await page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' }); + await page.goto(`${options.origin}/settings/system-updates`, { waitUntil: 'domcontentloaded' }); + if (options.mode === 'upgrade') { + record.active_step = 'select_exact_release'; + const initial = await page.evaluate(async () => (await fetch('/api/version')).json()); + assert.equal(initial.version.replace(/^v/, ''), options.from.replace(/^v/, '')); + assert.equal(initial.isDocker, false, 'a Docker override is not native acceptance'); + assert.equal(initial.isSourceBuild, false, 'must run the published binary'); + await page.getByRole('button', { name: /^Preview\b/ }).click(); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + const check = page.waitForResponse(r => new URL(r.url()).pathname === '/api/updates/check' + && new URL(r.url()).searchParams.get('channel') === 'rc'); + await page.getByRole('button', { name: /Check for Updates/i }).click(); + const checked = await check; + if ([401, 403].includes(checked.status())) record.access_refused = true; + assert.equal(checked.status(), 200, 'update check rejected; no alternate route'); + const selected = await checked.json(); + record.selected_version = selected.latestVersion?.replace(/^v/, ''); + assert.equal(record.selected_version, options.to.replace(/^v/, ''), 'refuse a different preview'); + assert.equal(selected.downloadUrl, + `https://github.com/rcourtman/Pulse/releases/download/${options.to}/pulse-${options.to}-linux-amd64.tar.gz`); + await page.getByRole('button', { name: 'Install Update', exact: true }).first().click(); + const confirmation = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: 'Start Update' }) }); + record.active_step = 'confirm_apply_once'; + await confirmation.getByRole('checkbox').check(); + // No command is replayed after a timeout, rejected POST or lost response. + await confirmation.getByRole('button', { name: 'Start Update', exact: true }).click(); + await page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i }) + .waitFor({ state: 'visible' }); + } + record.active_step = 'version_health_session_readiness'; + const deadline = Date.now() + (options.readinessTimeout || 600000); + let ready = false; + while (Date.now() < deadline) { + // Read-only observations during a known restart may temporarily have no + // listener. An HTTP401 is not such a transport gap and stops this journey. + const observed = await page.evaluate(async () => { + try { + const version = await fetch('/api/version'); + const health = await fetch('/api/health'); + const protectedRead = await fetch('/api/config/nodes'); + const result = { versionStatus: version.status, healthStatus: health.status, + protectedStatus: protectedRead.status }; + try { result.version = await version.json(); result.health = await health.json(); } + catch (_) { result.body_unavailable = true; } + return result; + } catch (_) { return { transport_unavailable: true }; } + }).catch(() => ({ transport_unavailable: true })); // page navigation during automatic reload + record.protected_read_status = observed.protectedStatus ?? null; + if ([401, 403].includes(observed.protectedStatus)) record.access_refused = true; + assert.ok(![401, 403].includes(observed.protectedStatus), 'session read rejected; stop without reauthentication'); + if (observed.versionStatus === 200 && observed.healthStatus === 200 + && observed.protectedStatus === 200 && typeof observed.version?.version === 'string' + && observed.version.version.replace(/^v/, '') === options.expected.replace(/^v/, '') + && observed.health?.status === 'healthy') { + record.observed_version = observed.version.version.replace(/^v/, ''); + record.health = observed.health.status; + record.protected_read_status = observed.protectedStatus; + ready = true; + break; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + assert.ok(ready, 'readiness deadline expired'); + // The UI gets its own bounded window to dismiss/reload after version readiness. + record.active_step = 'progress_modal_completion'; + const progress = page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i }); + await progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 }); + record.progress_dialog_visible = await progress.isVisible(); + if (options.outputDir) await page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true }); + judge(record); + record.active_step = 'complete'; + record.status = 'passed'; + } catch (error) { + record.status = 'failed'; + // Playwright errors can contain URLs, page text or values. Preserve a fixed + // type, not arbitrary error text or credentials, in the public receipt. + record.failure_type = error.name === 'TimeoutError' ? 'TimeoutError' : 'AssertionOrOperationError'; + throw error; + } finally { + if (options.outputDir) fs.writeFileSync(path.join(options.outputDir, `browser-${options.mode}.json`), JSON.stringify(record, null, 2) + '\n'); + await context.close(); + } + return record; +} + +async function main() { + const args = process.argv.slice(2); + const fields = { '--origin': 'origin', '--mode': 'mode', '--from': 'from', '--to': 'to', + '--expected': 'expected', '--auth-file': 'authFile', '--output-dir': 'outputDir' }; + // Seven fixed options; no endpoint, credential, browser or command override. + assert.equal(args.length, 14); + const options = {}; + for (let i = 0; i < args.length; i += 2) { + assert.ok(fields[args[i]] && options[fields[args[i]]] === undefined); + options[fields[args[i]]] = args[i + 1]; + } + validate(options); + options.auth = JSON.parse(fs.readFileSync(options.authFile, 'utf8')); + fs.mkdirSync(options.outputDir, { recursive: true }); + const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + try { await runJourney(browser, options); } finally { await browser.close(); } +} + +module.exports = { validate, judge, runJourney }; +if (require.main === module) main().catch(() => { console.error('Release browser journey failed; inspect bounded receipt. No replay.'); process.exitCode = 1; }); diff --git a/tests/integration/scripts/release-browser-journey.test.cjs b/tests/integration/scripts/release-browser-journey.test.cjs new file mode 100644 index 0000000000..b729378380 --- /dev/null +++ b/tests/integration/scripts/release-browser-journey.test.cjs @@ -0,0 +1,127 @@ +/* Offline adversarial observer controls. These fixtures are not Pulse release, + * systemd, installed update, Tailscale or qualification evidence. */ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); +const { validate, judge, runJourney } = require('./release-browser-journey.cjs'); +const from = 'v6.4.5', to = 'v6.4.6-rc.1'; +const downloadUrl = `https://github.com/rcourtman/Pulse/releases/download/${to}/pulse-${to}-linux-amd64.tar.gz`; + +function pureControls() { + const valid = { origin: 'https://ephemeral.tawny-powan.ts.net', mode: 'upgrade', from, to, expected: to }; + validate(valid); + for (const origin of ['http://ephemeral.tawny-powan.ts.net', 'https://private.example.com', + 'https://ephemeral.other.ts.net', 'https://ephemeral.tawny-powan.ts.net:443', 'https://ephemeral.tawny-powan.ts.net/path']) { + assert.throws(() => validate({ ...valid, origin })); + } + for (const delta of [{ mode: 'apply' }, { to: 'v6.4.6' }, { expected: from }]) assert.throws(() => validate({ ...valid, ...delta })); + const receipt = { ...valid, login_status: 200, observed_version: to.slice(1), health: 'healthy', + protected_read_status: 200, apply_requests: 1, apply_status: 200, selected_version: to.slice(1), + sse_opens: 1, sse_progress: [{ status: 'downloading', progress: 10 }], progress_dialog_visible: false }; + judge(receipt); + for (const delta of [{ login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' }, + { protected_read_status: 403 }, { apply_requests: 0 }, { apply_requests: 2 }, { apply_status: 500 }, + { selected_version: '6.5.0-rc.1' }, { sse_opens: 0 }, { sse_progress: [] }, { progress_dialog_visible: true }]) { + assert.throws(() => judge({ ...receipt, ...delta })); + } +} + +function fixture(kind, mode) { + let version = (mode === 'recovery' ? from : from).slice(1), applies = 0, logins = 0; + let applied = false; + const sockets = new Set(); + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + const json = (code, data) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(data)); }; + if (url.pathname === '/api/login') { + logins++; + if (kind === 'login-401') return json(401, { error: 'fixture denial' }); + res.setHeader('Set-Cookie', 'fixture-session=true; Path=/; HttpOnly; SameSite=Lax'); + return json(200, { success: true }); + } + if (url.pathname === '/api/version') return json(200, { version, isDocker: false, isSourceBuild: false }); + if (url.pathname === '/api/health') return json(200, { status: 'healthy' }); + if (url.pathname === '/api/config/nodes') return json(applied && kind === 'read-401' ? 401 : 200, []); + if (url.pathname === '/api/updates/check') return json(200, { + latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl }); + if (url.pathname === '/api/updates/apply') { + applies++; applied = true; + setTimeout(() => { version = to.slice(1); }, 350); + return json(200, { status: 'started' }); + } + if (url.pathname === '/api/updates/stream') { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + res.write(': real loopback fixture stream\n\n'); + if (kind !== 'silent-stream') res.write(`data: ${JSON.stringify({ status: 'downloading', progress: 10, message: 'DO NOT RETAIN FIXTURE PRIVATE MESSAGE' })}\n\n`); + req.on('close', () => res.end()); return; + } + res.writeHead(200, { 'content-type': 'text/html' }); + const signedIn = /fixture-session=true/.test(req.headers.cookie || ''); + res.end(`
Published fixture ${version}
+ ` : ` + + `}`); + }); + server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); + return { server, counts: () => ({ applies, logins }), close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(resolve)); + } }; +} + +async function runControls(outputDir) { + pureControls(); + const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + const results = []; + try { + for (const [kind, mode, expectedPass, expectedApplies] of [ + ['success', 'upgrade', true, 1], ['success', 'recovery', true, 0], + ['login-401', 'upgrade', false, 0], ['wrong-preview', 'upgrade', false, 0], + ['silent-stream', 'upgrade', false, 1], ['stuck-modal', 'upgrade', false, 1], + ['read-401', 'upgrade', false, 1], + ]) { + const target = fixture(kind, mode); + await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve)); + const folder = path.join(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true }); + let passed = false; + try { + await runJourney(browser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to, + expected: mode === 'upgrade' ? to : from, auth: { username: 'synthetic', password: 'synthetic' }, + timeout: 1500, readinessTimeout: 3000, outputDir: folder }); + passed = true; + } catch (_) { /* observer must reject each deliberately adverse fixture */ } + finally { await target.close(); } + assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`); + assert.deepEqual(target.counts(), { applies: expectedApplies, logins: 1 }, 'one login, no apply replay'); + const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`))); + assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE')); + assert.ok(!JSON.stringify(receipt).includes('synthetic')); + if (kind === 'read-401') assert.equal(receipt.protected_read_status, 401); + results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status, + active_step: receipt.active_step, ...target.counts() }); + } + fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ scope: 'offline adversarial observer controls only', + browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n'); + console.log(JSON.stringify({ observer_controls: results.length, scope: 'fixtures, not native qualification', results })); + } finally { await browser.close(); } +} +module.exports = { runControls, pureControls }; +if (require.main === module) runControls(path.resolve(process.argv[2] || 'tmp/browser-journey-proof')).catch(error => { console.error(error); process.exitCode = 1; }); From 619133aa54bb41b2b5def44b26b3a31deed41dcd Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:00:30 +0100 Subject: [PATCH 02/23] fix(release): admit real archive sizes and enforce workflow trust Live stable and RC archive metadata exceed 256 MiB; retain a streaming bounded 512 MiB archive/16 KiB signature/1 GiB binary contract instead. Disable automatic setup caches in the CI credential job and encode derived command outputs with the reviewed writer. Add regressions; do not relax workflow or release guards. Change-source: pulse-maintainer --- .github/workflows/qualify-browser-update-release.yml | 10 ++++++---- .../internal/subsystems/deployment-installability.md | 8 +++++++- scripts/release_browser_packet.py | 11 ++++++++--- scripts/tests/test_release_browser_packet.py | 12 +++++++++++- scripts/tests/test_release_lifecycle_rehearsal.py | 10 ++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml index a78363a122..7ebbbfbde4 100644 --- a/.github/workflows/qualify-browser-update-release.yml +++ b/.github/workflows/qualify-browser-update-release.yml @@ -69,9 +69,9 @@ jobs: --from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \ --output "$RUNNER_TEMP/browser-release/packet.json" python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")' - echo "from=$from" >> "$GITHUB_OUTPUT" - echo "from_commit=$from_commit" >> "$GITHUB_OUTPUT" - echo "to_commit=$to_commit" >> "$GITHUB_OUTPUT" + python3 scripts/write_github_output.py from "$from" + python3 scripts/write_github_output.py from_commit "$from_commit" + python3 scripts/write_github_output.py to_commit "$to_commit" - name: Authenticate both release archives before execution env: FROM: ${{ steps.pair.outputs.from }} @@ -88,7 +88,7 @@ jobs: mkdir -m 0700 "$root/$tag" name="pulse-$tag-linux-amd64.tar.gz" for file in "$name" "$name.sshsig"; do - curl --fail --silent --show-error --location --retry 0 --max-time 120 \ + curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \ "https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file" done ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \ @@ -101,6 +101,8 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24.20.0' + cache: '' + package-manager-cache: false - name: Prepare Chromium and native tools env: PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index dc291872a9..fa4ee93db3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6431,7 +6431,13 @@ Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and `GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable stable and the requested published immutable RC. Peeled commit, release/asset IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH -signatures verify before execution. Each installed binary hash must match its +signatures verify before execution. Archives are bounded to512MiB (current +stable/RC archives are about265MiB), signatures to16KiB, the fixed regular +server binary to1GiB and expanded inventory to2GiB/4096 entries. Streaming +hashes avoid allocating an entire archive; anonymous downloads have the same +archive-size ceiling. Setup-action caches are explicitly disabled in this +CI-credential-bearing job, and derived command-file outputs use the reviewed +collision-safe writer. Each installed binary hash must match its signed archive, rather than just a reported version. No rebuilt main binary, frozen-product insertion, candidate re-cut or stable promotion is performed. diff --git a/scripts/release_browser_packet.py b/scripts/release_browser_packet.py index 0916cbe2a3..3f1cffd8a9 100644 --- a/scripts/release_browser_packet.py +++ b/scripts/release_browser_packet.py @@ -11,6 +11,11 @@ from pathlib import Path import re import tarfile +MAX_ARCHIVE = 512 * 1024 * 1024 +MAX_SIGNATURE = 16 * 1024 +MAX_BINARY = 1024 * 1024 * 1024 +MAX_EXPANDED = 2 * MAX_BINARY + def release_identity(release, *, tag, commit, prerelease): pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' @@ -31,7 +36,7 @@ def release_identity(release, *, tag, commit, prerelease): asset = matches[0] if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}' or type(asset.get('id')) is not int or asset['id'] <= 0 - or type(asset.get('size')) is not int or not 0 < asset['size'] <= 256 * 1024 * 1024 + or type(asset.get('size')) is not int or not 0 < asset['size'] <= (MAX_SIGNATURE if name.endswith('.sshsig') else MAX_ARCHIVE) or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))): raise ValueError('Unsafe or unbound release asset') assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']}) @@ -50,13 +55,13 @@ def archive_identity(path): total_size = 0 for i, member in enumerate(archive): total_size += member.size - if total_size > 1024 * 1024 * 1024: + if total_size > MAX_EXPANDED: raise ValueError('Oversized expanded release archive') if i >= 4096: raise ValueError('Oversized release archive inventory') if member.name in ('./bin/pulse', 'bin/pulse'): matches.append(member) - if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= 256 * 1024 * 1024: + if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= MAX_BINARY: raise ValueError('Missing or ambiguous regular server binary') stream = archive.extractfile(matches[0]) return hashlib.file_digest(stream, 'sha256').hexdigest() diff --git a/scripts/tests/test_release_browser_packet.py b/scripts/tests/test_release_browser_packet.py index 8a802b70cb..c3763d6b6c 100644 --- a/scripts/tests/test_release_browser_packet.py +++ b/scripts/tests/test_release_browser_packet.py @@ -48,9 +48,19 @@ class BrowserPacketTest(unittest.TestCase): with self.assertRaises(ValueError): packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9') + def test_current_published_archives_fit_bounded_admission(self): + # Actual GitHub metadata readback: stable276780330 and RC277779060 + # bytes both exceed the original256MiB limit. No asset bytes executed. + self.baseline['assets'][0]['size'] = 276780330 + self.candidate['assets'][0]['size'] = 277779060 + self.assertEqual(2, len(self.bind()['releases'])) + self.candidate['assets'][1]['size'] = packet.MAX_SIGNATURE + 1 + with self.assertRaises(ValueError): + self.bind() + def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self): for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0), - ('size', 257 * 1024 * 1024), ('digest', None), ('id', True)]: + ('size', 513 * 1024 * 1024), ('digest', None), ('id', True)]: with self.subTest(field=field), self.assertRaises(ValueError): candidate = copy.deepcopy(self.candidate) candidate['assets'][0][field] = value diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index f36a90acca..aa8a143b39 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -177,6 +177,16 @@ class HarnessContractTest(unittest.TestCase): self.assertEqual(result.returncode, 2, result.stdout + result.stderr) +class HostedWorkflowCommandFilesTest(unittest.TestCase): + def test_bound_pair_uses_the_reviewed_collision_safe_output_writer(self) -> None: + source = (REPO_ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text() + for field in ('from', 'from_commit', 'to_commit'): + self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source) + self.assertNotIn('echo "to_commit=', source) + self.assertIn("cache: ''", source) + self.assertIn('package-manager-cache: false', source) + + class BrowserRecoveryAccessStopTest(unittest.TestCase): def test_refused_upgrade_does_not_launch_another_login(self) -> None: source = HARNESS_PATH.read_text(encoding="utf-8") From 729b2cf7b42b6e8606e4cd1fd9a6b99f5128bddc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:04:29 +0100 Subject: [PATCH 03/23] fix(release): omit redundant setup-node cache selection Use the established setup-node disable contract: no explicit cache backend and package-manager-cache false. The empty cache value was rejected by the workflow trust guard; retain the guard and add an omission check. Contract-Neutral: The exact published-RC browser, credential isolation and cache prohibition contract is unchanged; this fixes its setup-node representation. Change-source: pulse-maintainer --- .github/workflows/qualify-browser-update-release.yml | 1 - scripts/tests/test_release_lifecycle_rehearsal.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml index 7ebbbfbde4..2e1d1f9f24 100644 --- a/.github/workflows/qualify-browser-update-release.yml +++ b/.github/workflows/qualify-browser-update-release.yml @@ -101,7 +101,6 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24.20.0' - cache: '' package-manager-cache: false - name: Prepare Chromium and native tools env: diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index aa8a143b39..d8eb1c128d 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -183,7 +183,8 @@ class HostedWorkflowCommandFilesTest(unittest.TestCase): for field in ('from', 'from_commit', 'to_commit'): self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source) self.assertNotIn('echo "to_commit=', source) - self.assertIn("cache: ''", source) + node_setup = source.split('uses: actions/setup-node@', 1)[1].split(' - name:', 1)[0] + self.assertNotRegex(node_setup, r'(?m)^\s*cache:') self.assertIn('package-manager-cache: false', source) From 1c999113a18505c869a92c8c1c2b0249cb640254 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:28:45 +0100 Subject: [PATCH 04/23] Separate TrueNAS setup acceptance from connection probes Remove the one-cycle data guarantee at the first setup steps, explain which ordinary reads a successful system-information probe has not exercised, and retain the published 6.4.1/6.4.5 probe-timestamp warning without applying it to repaired builds. Preserve read-only, credential-free polling checks and verify their shipped rendering and keyboard path. Contract-Neutral: Existing help and its verification only; no runtime, API, polling, access, persistence or retention change. Change-source: pulse-maintainer --- docs/TRUENAS.md | 29 ++++--- .../docs-fragment-navigation.tsx | 1 + .../browser-tests/truenas-connection-help.cjs | 75 +++++++++++++++++++ frontend-modern/public/docs/TRUENAS.md | 29 ++++--- .../src/utils/__tests__/docsLinks.test.ts | 18 +++++ 5 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 frontend-modern/browser-tests/truenas-connection-help.cjs diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 54dac2b6e6..270d15e838 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -9,8 +9,13 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR are listed under **Platform connections**. 3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the username that owns the key. -4. Click **Test Connection** → **Save**. -5. Data appears within one configured polling cycle (60 seconds by default). +4. Click **Test Connection**, then **Save**. The test reads system information + on a separate connection; it does not validate inventory or metric collection. +5. Leave the saved connection enabled and allow at least one configured polling + interval (60 seconds by default). Check that the appliance and expected + inventory appear. If they do not, follow the [polling checks](#stale-truenas-data) + without repeatedly testing or restarting; an elapsed interval is not proof + that a poll completed. ## Creating a TrueNAS API Key @@ -155,9 +160,11 @@ its trusted HTTPS origin without disabling certificate verification. ### No data appearing after adding connection - Allow one configured polling cycle (60 seconds by default), not a fixed 30-second wait. Check that the saved connection is enabled. -- **Test Connection** checks authentication and the selected transport, not - successful collection of every metric. The legacy REST diagnostic is - expected for recognized CORE 13 systems; it is not itself a connection error. +- **Test Connection** reads system information on a separate connection. Pools, + datasets, disks and alerts can still fail during ordinary polling, even when + the test succeeds. A successful test also does not establish live CPU, memory + or History readings. The legacy REST diagnostic is expected for recognized + CORE 13 systems; it is not itself a connection error. - Inspect a bounded local log excerpt for TrueNAS-related errors: ```bash journalctl -u pulse -n 100 --no-pager | grep -i truenas @@ -236,11 +243,13 @@ collection step. | No timestamps advance, or no completed attempt is recorded | Check that the connection and integration are enabled, then preserve the bounded local error above. This is not proof of an invalid key. | | Reconnects or TrueNAS sign-ins increase while `observed.collectedAt` advances | Session turnover alone does not establish failed authentication or stopped polling. Record the timing and any existing close/error message separately from the data freshness. | -**Test Connection** checks connectivity and authentication, not a complete -poll. Testing a saved connection can update `lastAttemptAt` and `lastSuccessAt` -and reset its failure count without refreshing `observed.collectedAt`. Record -any manual test and its time separately; a successful test is not evidence -that missing data or stopped polling has recovered. +**Test Connection** is not a complete poll. Some older builds, including Pulse +6.4.1 and 6.4.5, also let a saved-connection test update `lastAttemptAt` and +`lastSuccessAt` and reset its failure count without refreshing +`observed.collectedAt`. Record any manual test and its time separately, and +use the inventory observation time rather than the test time to check freshness. +A successful test is not evidence that missing data or stopped polling has +recovered. An `app.stats` error such as **Apps are not available** concerns Apps collection, not necessarily the key. Check the existing Apps status in diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx index 5c11587d77..2c125f2a3b 100644 --- a/frontend-modern/browser-tests/docs-fragment-navigation.tsx +++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx @@ -22,6 +22,7 @@ const targets: RecordPublished fixture ${version}
` : ` - `}`); + `}