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
This commit is contained in:
pulse-triage[bot]
2026-10-04 02:15:25 +01:00
parent a131e4be7d
commit fdf464578a
8 changed files with 989 additions and 15 deletions
@@ -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
@@ -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`)
+99
View File
@@ -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()
+157 -14
View File
@@ -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
@@ -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()
@@ -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."""
@@ -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; });
@@ -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(`<!doctype html><html><head><title>Offline release observer control</title></head>
<body><h1>Observer fixture — not Pulse/native qualification</h1>${signedIn ? `
<h2>System updates</h2><button id="preview">Preview beta builds</button><button id="save">Save Changes</button>
<button id="check">Check for Updates</button><button id="install" hidden>Install Update</button>
<main id="dialogs"></main><p id="state">Published fixture ${version}</p>
<script>
document.querySelector('#check').onclick = async () => { await fetch('/api/updates/check?channel=rc'); document.querySelector('#install').hidden=false; };
document.querySelector('#install').onclick = () => {
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Confirm update"><h2>Confirm Update</h2><label><input type="checkbox"> Acknowledge</label><button id="start">Start Update</button></section>';
document.querySelector('#start').onclick = async () => {
await fetch('/api/updates/apply', {method:'POST'});
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Updating Pulse"><h2>Updating Pulse</h2><p>Downloading update 10%</p></section>';
const stream=new EventSource('/api/updates/stream');
setTimeout(() => { ${kind === 'stuck-modal' ? '' : "document.querySelector('#dialogs').innerHTML='';"}
document.querySelector('#state').textContent='Fixture ready'; stream.close(); }, 700);
};
};
</script>` : `
<input placeholder="Username"><input placeholder="Password" type="password"><button id="login">Sign in to Pulse</button>
<script>document.querySelector('#login').onclick = async () => { const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };</script>`}</body></html>`);
});
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; });