Reconcile published lifecycle rehearsal with reviewed maintenance

Preserve the complete reviewed maintenance tip and published upstream source unchanged. Combine their installability contract and subsystem registry entries without changing runtime behaviour.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 10:10:22 +01:00
commit f047d7b5db
7 changed files with 1409 additions and 0 deletions

View file

@ -140,6 +140,17 @@ and verifies the live service health and exact version. The privileged systemd
smoke environment is digest-pinned because a floating container image would
otherwise be an unreviewed code path inside the release gate.
`release-lifecycle-rehearsal.yml` is a daily shadow rehearsal of the systemd
lifecycle between two published releases, in the same digest-pinned systemd
container. It installs the older release with its signature-verified
`install.sh --version`, seeds real settings through the API, upgrades with the
installed `/bin/update --version <to>` helper and rolls back with the documented
`/bin/update --version <from>`, checking version, health, unit state, settings
and data-dir survival after each step. It is read-only, uploads nothing and gates
nothing; results are in the job log and step summary. Run
`scripts/release_lifecycle_rehearsal.sh --from <tag> --to <tag>` to reproduce
it locally (`PULSE_REHEARSAL_ENGINE=podman` on hosts without Docker).
The shared `install-sh-smoke-body.yml` inherits its caller's token permissions;
keep it free of workflow- or job-level permission overrides. Continuity calls
that body directly with `contents: read`. The existing `install-sh-smoke.yml`

View file

@ -0,0 +1,81 @@
name: Release Lifecycle Rehearsal
# Shadow rehearsal of the systemd lifecycle users run between two PUBLISHED
# releases: install FROM with its signed install.sh, seed real settings, upgrade
# with /bin/update --version TO, then roll back with the documented
# /bin/update --version FROM, asserting identity, health, unit state, settings
# and data-dir survival after each step. It reports only. No release job
# depends on it, and it uploads nothing.
on:
workflow_dispatch:
inputs:
from_version:
description: 'Published tag to install first (e.g. v6.4.1). Empty = latest stable.'
required: false
type: string
default: ''
to_version:
description: 'Published tag to upgrade to. Empty = newest published release or prerelease newer than from_version.'
required: false
type: string
default: ''
schedule:
# Daily, well outside the 02:00-06:00 UTC unattended-update window that an
# upgraded install may arm, so the timer cannot race the assertions.
- cron: '23 13 * * *'
permissions:
contents: read
concurrency:
group: release-lifecycle-rehearsal
cancel-in-progress: false
jobs:
rehearse:
name: Install, upgrade and roll back on systemd
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout rehearsal harness (for README key and scripts)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Resolve published release pair
id: pair
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
FROM_INPUT: ${{ inputs.from_version }}
TO_INPUT: ${{ inputs.to_version }}
run: |
set -euo pipefail
releases_json="$(mktemp)"
gh api --paginate "repos/${REPOSITORY}/releases?per_page=100" \
| jq -s 'add // []' > "${releases_json}"
latest_tag="$(gh api "repos/${REPOSITORY}/releases/latest" --jq '.tag_name')"
resolved="$(python3 scripts/release_lifecycle_rehearsal_versions.py \
--releases-json "${releases_json}" \
--latest-tag "${latest_tag}" \
--from "${FROM_INPUT}" \
--to "${TO_INPUT}")"
rm -f "${releases_json}"
from_tag="$(sed -n 's/^from_tag=//p' <<<"${resolved}")"
to_tag="$(sed -n 's/^to_tag=//p' <<<"${resolved}")"
reason="$(sed -n 's/^reason=//p' <<<"${resolved}")"
python3 scripts/write_github_output.py from_tag "${from_tag}"
python3 scripts/write_github_output.py to_tag "${to_tag}"
echo "Rehearsing ${from_tag} -> ${to_tag} -> ${from_tag} (${reason})"
{
echo "Release pair: \`${from_tag}\` -> \`${to_tag}\` (${reason})."
echo
} >> "${GITHUB_STEP_SUMMARY}"
- name: Rehearse install, upgrade and rollback
env:
FROM_TAG: ${{ steps.pair.outputs.from_tag }}
TO_TAG: ${{ steps.pair.outputs.to_tag }}
PULSE_REHEARSAL_REPO: ${{ github.repository }}
PULSE_REHEARSAL_ENGINE: docker
run: scripts/release_lifecycle_rehearsal.sh --from "${FROM_TAG}" --to "${TO_TAG}"

View file

@ -842,6 +842,9 @@ release-latency optimization.
98. `scripts/verify-github-release-integrity.sh`
99. `scripts/verify-release-container-images.sh`
100. `scripts/release_control/verify_release_container_images_test.py`
101. `.github/workflows/release-lifecycle-rehearsal.yml`
102. `scripts/release_lifecycle_rehearsal.sh`
103. `scripts/release_lifecycle_rehearsal_versions.py`
## Shared Boundaries
@ -6125,6 +6128,82 @@ the current assignment; a host dependency acquisition on the next launch is
required before this contract's Go evidence can be produced. No passing Go
suite, installed build or release acceptance is claimed here.
### Published-release lifecycle rehearsal (shadow)
`.github/workflows/release-lifecycle-rehearsal.yml` rehearses the systemd
install, upgrade and rollback journey between two published releases. It is
the shadow first step toward making install, upgrade and rollback a publication
gate. It runs daily and on manual dispatch, holds only `contents: read`, runs
on the hosted `ubuntu-24.04` image, uses no secrets and uploads no artifacts.
No release workflow calls it or waits on it, so it reports without gating.
`scripts/release_lifecycle_rehearsal_versions.py` chooses the pair from
published, non-draft release tags under SemVer precedence. The default FROM is
the release GitHub advertises as latest and must be stable. The default TO is
the newest published release or prerelease newer than FROM. When FROM was
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
digest-pinned `jrei/systemd-debian:12` container as the install smoke.
1. Install FROM with that release's own `install.sh --version`, after the
asset verifies against the README-pinned `pulse-installer` key and passes
the server-installer identity checks.
2. Assert `/api/version` and `/opt/pulse/bin/pulse --version` both report
FROM, `/api/health` reports `healthy`, and `pulse.service` is active and
enabled with no newly failed unit.
3. Seed state through the real API. First-run security setup uses the
bootstrap token from `pulse bootstrap-token`, then a disabled webhook with
a secret header and a PVE node with fake token credentials are created. The
read-back must show that auth is required, that an unauthenticated request
gets 401, and that the API token and password both get 200. The webhook must
keep its id, URL, method, service, disabled state and header key. The node
must keep its host, token name, stored token secret (`hasToken`), no
password and `verifySSL=false`. The `/etc/pulse` file list and checksums
become the baseline.
4. Upgrade with the installed helper, `/bin/update --version TO`, after
checking that the helper is the Pulse server installer's. The helper
downloads the latest published `install.sh`, verifies it with its embedded
key and runs it.
5. After the upgrade, and again after the documented rollback
`/bin/update --version FROM`, assert the expected identity, health and unit
state. The settings read-back must equal the baseline and meet the fixed
expectations. No baseline file may be missing from `/etc/pulse`.
`.encryption.key`, `nodes.enc` and `webhooks.enc` must be byte-identical.
The API reports only `hasToken` and redacted header values, so byte
identity under an unchanged key is what proves the exact seeded secrets
survived. A release that legitimately rewrites those stores fails the
rehearsal instead of passing unproven. The API token and password checks
prove those exact credentials directly.
Rollback still runs when the upgrade phase fails. A phase also fails if
`pulse-update.service` started during the run, because an unattended update
would make the version assertions unattributable. Three D-Bus-activated host
services that cannot run in the container (`systemd-hostnamed`,
`systemd-timedated` and `systemd-localed`) are reported as warnings rather
than failures. A unit drop-in sets `PULSE_TELEMETRY=false` so daily CI installs
stay out of usage telemetry. It lives outside the installer's files and the
data dir.
Scope: only published assets and the amd64 systemd path are exercised. Docker,
Helm, Proxmox LXC creation, the in-app updater and a prerelease's own
`install.sh` (the helper always fetches the latest published installer) are
not covered. A passing run is not release admission.
Verification: `scripts/tests/test_release_lifecycle_rehearsal.py` covers pair
resolution (SemVer ordering, draft exclusion, forward-only and published-only
pairs, the previous-stable fallback), the fixed settings expectations (each lost
or altered seeded field fails `--check-snapshot`, and a deleted file or a
rewritten secret store fails `--compare-datadir`), the harness contract (the real updater
invocation without bypass flags, the signed installer, the shared image digest,
and per-phase identity, settings and data-dir checks) and the workflow trust
shape. A local podman run (amd64 emulation) of `v6.4.1 -> v6.4.5 -> v6.4.1`
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.
## Release-body updater ownership (30 September 2026)
Each executable `/bin/update --version` example in a published body must carry

View file

@ -4536,6 +4536,7 @@
".github/workflows/recover-release-activation.yml",
".github/workflows/release-convergence.yml",
".github/workflows/release-dry-run.yml",
".github/workflows/release-lifecycle-rehearsal.yml",
".github/workflows/retry-release-convergence.yml",
".github/workflows/update-demo-server.yml",
".github/workflows/validate-release-assets.yml",
@ -4630,6 +4631,8 @@
"scripts/release_control/secure_runtime_source_manifest_v7.json",
"scripts/release_control/validate_artifact_release_line.py",
"scripts/release_ldflags.sh",
"scripts/release_lifecycle_rehearsal.sh",
"scripts/release_lifecycle_rehearsal_versions.py",
"scripts/release_update_key.go",
"scripts/run-release-backend-tests.sh",
"scripts/run-release-preflight.sh",
@ -4817,6 +4820,21 @@
"scripts/release_control/verify_github_release_integrity_test.py"
]
},
{
"id": "release-lifecycle-rehearsal",
"label": "published-release install, upgrade and rollback rehearsal proof",
"match_prefixes": [],
"match_files": [
".github/workflows/release-lifecycle-rehearsal.yml",
"scripts/release_lifecycle_rehearsal.sh",
"scripts/release_lifecycle_rehearsal_versions.py"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/tests/test_release_lifecycle_rehearsal.py"
]
},
{
"id": "release-candidate-manifest-runtime",
"label": "immutable release candidate manifest proof",

View file

@ -0,0 +1,700 @@
#!/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).
#
# 1. install FROM with that release's signature-verified install.sh --version
# 2. assert FROM identity, health and unit state
# 3. seed persistent state through the real API (first-run security setup,
# a webhook, a PVE node with fake credentials) and snapshot the data dir
# 4. upgrade with the installed updater users run: /bin/update --version TO
# 5. assert TO identity, health, unit state, seeded settings and data dir
# 6. roll back with the documented command: /bin/update --version FROM
# 7. assert FROM identity, health, unit state, seeded settings and data dir
#
# The result is a phase table on stdout and in $GITHUB_STEP_SUMMARY (when set).
# Nothing is uploaded. Exit status is non-zero when any phase fails.
#
# Usage:
# scripts/release_lifecycle_rehearsal.sh --from vX.Y.Z --to vX.Y.Z
#
# Environment:
# PULSE_REHEARSAL_REPO owner/repo for release assets (default rcourtman/Pulse)
# PULSE_REHEARSAL_ENGINE docker (default) or podman
# PULSE_REHEARSAL_WORKDIR scratch directory (default: mktemp -d)
# PULSE_REHEARSAL_README README holding the pinned installer key
# (default: README.md next to this script's repo)
# Programs passed to cexec are single-quoted on purpose: they expand inside
# the container from -e variables. Mount options legitimately contain commas.
# shellcheck disable=SC2016,SC2054
set -euo pipefail
export LC_ALL=C
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SYSTEMD_IMAGE="docker.io/jrei/systemd-debian:12@sha256:61d70dc3e574337bd9df794674a60ae73113460fff16ab41a2d234b4a11dcd98"
TAG_PATTERN='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
API="http://127.0.0.1:7655"
DATA_DIR="/etc/pulse"
SEED_WEBHOOK_NAME="lifecycle-rehearsal-webhook"
SEED_WEBHOOK_URL="https://example.com/pulse-lifecycle-rehearsal"
# PVE add-node skips live cluster detection for 192.168.77.x hosts and
# "test-" names, so the fake node is persisted without a reachable endpoint.
SEED_NODE_NAME="test-lifecycle-rehearsal"
SEED_NODE_HOST="https://192.168.77.10:8006"
SEED_NODE_TOKEN_NAME="root@pam!rehearsal"
SEED_WEBHOOK_HEADER="X-Rehearsal-Secret"
SEED_ADMIN_USER="rehearsal-admin"
FROM_TAG=""
TO_TAG=""
CHECK_SNAPSHOT=""
COMPARE_DATADIR=()
while [[ $# -gt 0 ]]; do
case "$1" in
--from) FROM_TAG="${2:-}"; shift 2 ;;
--to) TO_TAG="${2:-}"; shift 2 ;;
# Offline self-tests used by the contract tests: apply the fixed
# settings expectations to a snapshot file, or compare two data-dir
# manifests (path<TAB>sha256), then exit.
--check-snapshot) CHECK_SNAPSHOT="${2:-}"; shift 2 ;;
--compare-datadir) COMPARE_DATADIR=("${2:-}" "${3:-}"); shift 3 ;;
-h|--help) sed -n '2,/^$/p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [[ -z "$CHECK_SNAPSHOT" && ${#COMPARE_DATADIR[@]} -eq 0 ]]; then
for tag in "$FROM_TAG" "$TO_TAG"; do
if [[ ! "$tag" =~ $TAG_PATTERN ]]; then
echo "::error::--from and --to must be Pulse release tags (vX.Y.Z[-pre]); got '${tag}'" >&2
exit 2
fi
done
if [[ "$FROM_TAG" == "$TO_TAG" ]]; then
echo "::error::--from and --to must differ; a same-version run proves no upgrade" >&2
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
exit 2
fi
ENGINE="${PULSE_REHEARSAL_ENGINE:-docker}"
case "$ENGINE" in
docker|podman) ;;
*) echo "::error::PULSE_REHEARSAL_ENGINE must be docker or podman" >&2; exit 2 ;;
esac
README_PATH="${PULSE_REHEARSAL_README:-${ROOT_DIR}/README.md}"
WORK_DIR="${PULSE_REHEARSAL_WORKDIR:-$(mktemp -d)}"
mkdir -p "${WORK_DIR}/assets" "${WORK_DIR}/state"
CONTAINER="pulse-lifecycle-rehearsal-$$"
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-}"
# Throwaway credentials for an ephemeral container. They never leave it.
ADMIN_PASSWORD="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
API_TOKEN="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
NODE_TOKEN_VALUE="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
PHASE_ROWS=()
FAILURES=()
CURRENT_FAILURE=""
OVERALL_STATUS=0
CONTAINER_STARTED=false
BASELINE_FAILED_UNITS=""
# D-Bus-activated host services that cannot run inside the test container
# (no hostname/clock/locale ownership). Any other new failed unit, and every
# pulse* unit, fails the phase.
CONTAINER_TOLERATED_UNITS="systemd-hostnamed.service systemd-timedated.service systemd-localed.service"
log() { printf '[rehearsal] %s\n' "$*"; }
note_failure() {
CURRENT_FAILURE="${CURRENT_FAILURE:+${CURRENT_FAILURE}; }$*"
echo "::error::$*"
}
# Run a bash program inside the container. Data reaches the program only as
# environment variables, never by interpolation into the program text.
cexec() {
local program="$1"
shift
local -a env_args=(-e PULSE_INSTALL_ALLOW_DOCKER=1)
local pair
for pair in "$@"; do
env_args+=(-e "$pair")
done
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
}
print_diagnostics() {
[[ "$CONTAINER_STARTED" == "true" ]] || return 0
echo "::group::Diagnostics: pulse journal (tail)"
cexec 'journalctl -u pulse --no-pager | tail -n 150' || true
echo "::endgroup::"
echo "::group::Diagnostics: unit state"
cexec 'systemctl status pulse --no-pager; systemctl list-units --state=failed --no-pager; systemctl list-timers --all --no-pager | grep -i pulse' || true
echo "::endgroup::"
echo "::group::Diagnostics: pulse-update journal"
cexec 'journalctl -u pulse-update --no-pager | tail -n 60' || true
echo "::endgroup::"
local logfile
for logfile in "${WORK_DIR}"/state/*.log; do
[[ -f "$logfile" ]] || continue
echo "::group::Diagnostics: $(basename "$logfile") (tail)"
tail -n 80 "$logfile" || true
echo "::endgroup::"
done
}
cleanup() {
local status=$?
if [[ "$OVERALL_STATUS" -ne 0 || "$status" -ne 0 ]]; then
print_diagnostics
fi
if [[ "$CONTAINER_STARTED" == "true" ]]; then
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
write_summary() {
local table
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."
echo
echo "| Phase | Expected | Reported version | Health | Settings | Data dir | Units | Result |"
echo "| --- | --- | --- | --- | --- | --- | --- | --- |"
local row
for row in "${PHASE_ROWS[@]}"; do
echo "$row"
done
if [[ ${#FAILURES[@]} -gt 0 ]]; then
echo
echo "### Failures"
local failure
for failure in "${FAILURES[@]}"; do
echo "- ${failure}"
done
fi
)
echo
echo "$table"
if [[ -n "$SUMMARY_FILE" ]]; then
printf '%s\n' "$table" >> "$SUMMARY_FILE"
fi
}
# record_phase NAME EXPECTED VERSION HEALTH SETTINGS DATADIR UNITS
record_phase() {
local result="pass"
if [[ -n "$CURRENT_FAILURE" ]]; then
result="FAIL"
FAILURES+=("$1: ${CURRENT_FAILURE}")
OVERALL_STATUS=1
fi
PHASE_ROWS+=("| $1 | \`$2\` | ${3:--} | ${4:--} | ${5:--} | ${6:--} | ${7:--} | ${result} |")
CURRENT_FAILURE=""
[[ "$result" == "pass" ]]
}
abort_run() {
write_summary
exit 1
}
# ---------------------------------------------------------------------------
# Published installer, verified against the README-pinned key
# ---------------------------------------------------------------------------
fetch_and_verify_installer() {
local tag="$1"
local dest="${WORK_DIR}/assets"
local base="https://github.com/${REPO}/releases/download/${tag}"
local asset
for asset in install.sh install.sh.sshsig; do
if ! curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
-o "${dest}/${asset}" "${base}/${asset}"; then
echo "::error::could not download ${base}/${asset}"
return 1
fi
done
local readme_key allowed_signers
readme_key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' "$README_PATH" | head -1)
if [[ -z "$readme_key" ]]; then
echo "::error::Could not extract the pulse-installer key from ${README_PATH}"
return 1
fi
allowed_signers="${WORK_DIR}/allowed_signers"
printf 'pulse-installer %s\n' "$readme_key" > "$allowed_signers"
if ! ssh-keygen -Y verify \
-f "$allowed_signers" \
-I pulse-installer \
-n pulse-install \
-s "${dest}/install.sh.sshsig" < "${dest}/install.sh"; then
echo "::error::${tag} install.sh.sshsig does not verify against the README's pinned key"
return 1
fi
if ! grep -qE '^# Pulse Installer Script' "${dest}/install.sh" \
|| grep -q 'Pulse Unified Agent Installer' "${dest}/install.sh" \
|| ! grep -qE '^[[:space:]]*--version\)' "${dest}/install.sh"; then
echo "::error::${tag} install.sh is not the Pulse server installer with --version support"
return 1
fi
log "${tag} install.sh signature verifies against the README-pinned key"
}
# ---------------------------------------------------------------------------
# Container
# ---------------------------------------------------------------------------
start_container() {
local -a run_args=(run -d --name "$CONTAINER" --privileged
-v "${WORK_DIR}/assets:/rehearsal:ro")
if [[ "$ENGINE" == "docker" ]]; then
# GHA ubuntu-24.04 uses the cgroup v2 unified hierarchy; without
# --cgroupns=host systemd PID 1 cannot mount the cgroup tree.
run_args+=(--cgroupns=host -v /sys/fs/cgroup:/sys/fs/cgroup:rw
--tmpfs /tmp:rw,size=2g --tmpfs /run --tmpfs /run/lock)
else
# Podman's systemd mode mounts /run, /run/lock and the cgroup tree.
# The pinned image is amd64-only.
run_args+=(--systemd=always --arch amd64 --tmpfs /tmp:rw,size=2g)
fi
CONTAINER_STARTED=true
if ! "$ENGINE" "${run_args[@]}" "$SYSTEMD_IMAGE" >/dev/null; then
echo "::error::${ENGINE} could not start ${SYSTEMD_IMAGE}"
return 1
fi
local i state
for i in $(seq 1 45); do
if ! "$ENGINE" inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true; then
"$ENGINE" logs "$CONTAINER" || true
echo "::error::systemd container exited during boot"
return 1
fi
state=$("$ENGINE" exec "$CONTAINER" systemctl is-system-running 2>/dev/null || true)
if [[ "$state" == "running" || "$state" == "degraded" ]]; then
break
fi
if [[ "$i" -eq 45 ]]; then
echo "::error::systemd did not become ready inside the container (state: ${state:-unknown})"
return 1
fi
sleep 2
done
BASELINE_FAILED_UNITS=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort')
log "systemd is up (pre-existing failed units: ${BASELINE_FAILED_UNITS:-none})"
if ! cexec 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq curl ca-certificates jq >/dev/null'; then
echo "::error::could not install curl/jq inside the container"
return 1
fi
# Keep a daily CI install out of real usage telemetry. A unit drop-in is
# outside the installer's ownership and the data dir, so it does not
# change what install, upgrade or rollback do.
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'
}
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
PHASE_VERSION=""
PHASE_HEALTH=""
PHASE_SETTINGS=""
PHASE_DATADIR=""
PHASE_UNITS=""
reset_phase_cells() {
PHASE_VERSION="-"; PHASE_HEALTH="-"; PHASE_SETTINGS="-"; PHASE_DATADIR="-"; PHASE_UNITS="-"
}
# Identity, health and systemd state for the expected release.
assert_runtime() {
local expected_tag="$1"
local expected="${expected_tag#v}"
local active="" i
for i in $(seq 1 60); do
active=$(cexec 'systemctl is-active pulse' 2>/dev/null || true)
[[ "$active" == "active" ]] && break
sleep 2
done
local health_body health_status
health_body=$(cexec 'curl -fsS --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors "$API/api/health"' "API=${API}" 2>/dev/null || true)
health_status=$(jq -r '.status // empty' <<<"$health_body" 2>/dev/null || true)
if [[ "$health_status" == "healthy" ]]; then
PHASE_HEALTH="healthy"
else
PHASE_HEALTH="${health_status:-no response}"
note_failure "/api/health did not report healthy (got '${PHASE_HEALTH}')"
fi
local version_body reported binary_version
version_body=$(cexec 'curl -fsS "$API/api/version"' "API=${API}" 2>/dev/null || true)
reported=$(jq -r '.version // empty' <<<"$version_body" 2>/dev/null || true)
PHASE_VERSION="${reported:-none}"
if [[ "${reported#v}" != "$expected" ]]; then
note_failure "/api/version reported '${reported:-none}', expected ${expected_tag}"
fi
binary_version=$(cexec '/opt/pulse/bin/pulse --version 2>/dev/null | grep -oE "v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?" | head -1' 2>/dev/null || true)
if [[ "${binary_version#v}" != "$expected" ]]; then
note_failure "/opt/pulse/bin/pulse --version reported '${binary_version:-none}', expected ${expected_tag}"
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)
new_failed=$(comm -13 <(printf '%s\n' "$BASELINE_FAILED_UNITS" | sed '/^$/d') \
<(printf '%s\n' "$failed_now" | sed '/^$/d') | tr '\n' ' ')
PHASE_UNITS="pulse ${active:-unknown}/${enabled:-unknown}"
if [[ "$active" != "active" ]]; then
note_failure "pulse.service is '${active:-unknown}', expected active"
fi
if [[ "$enabled" != "enabled" ]]; then
note_failure "pulse.service is '${enabled:-unknown}', expected enabled"
fi
local unit tolerated="" unexpected=""
for unit in $new_failed; do
if [[ " ${CONTAINER_TOLERATED_UNITS} " == *" ${unit} "* ]]; then
tolerated="${tolerated:+${tolerated} }${unit}"
else
unexpected="${unexpected:+${unexpected} }${unit}"
fi
done
if [[ -n "$unexpected" ]]; then
PHASE_UNITS="${PHASE_UNITS}, failed: ${unexpected}"
note_failure "systemd units failed during the rehearsal: ${unexpected}"
fi
if [[ -n "$tolerated" ]]; then
PHASE_UNITS="${PHASE_UNITS}, container-only failures ignored: ${tolerated}"
echo "::warning::container-environment units failed and were not counted: ${tolerated}"
fi
if ! cexec 'systemctl show -p Environment --value pulse | grep -q "PULSE_TELEMETRY=false"' 2>/dev/null; then
echo "::warning::the rehearsal telemetry opt-out drop-in is no longer applied to pulse.service"
fi
# An unattended update firing mid-rehearsal would invalidate the version
# assertions, so prove the timer-driven updater never started.
local auto_update_started
auto_update_started=$(cexec 'systemctl show -p ExecMainStartTimestampMonotonic --value pulse-update.service 2>/dev/null || echo 0' 2>/dev/null || echo 0)
if [[ -n "$auto_update_started" && "$auto_update_started" != "0" ]]; then
note_failure "pulse-update.service ran during the rehearsal; results are not attributable to the manual updater"
fi
}
api_status() {
# api_status METHOD PATH [auth: none|token|basic] [body]
cexec 'args=(-sS -o /dev/null -w "%{http_code}" -X "$METHOD")
case "$AUTH" in
token) args+=(-H "X-API-Token: $TOKEN") ;;
basic) args+=(-u "$BASIC") ;;
esac
curl "${args[@]}" "$API$REQ_PATH"' \
"API=${API}" "METHOD=$1" "REQ_PATH=$2" "AUTH=${3:-none}" \
"TOKEN=${API_TOKEN}" "BASIC=${SEED_ADMIN_USER}:${ADMIN_PASSWORD}"
}
api_json() {
# api_json METHOD PATH [body]; authenticates with the seeded API token.
cexec 'if [[ -n "$BODY" ]]; then
curl -fsS -X "$METHOD" -H "X-API-Token: $TOKEN" -H "Content-Type: application/json" --data "$BODY" "$API$REQ_PATH"
else
curl -fsS -X "$METHOD" -H "X-API-Token: $TOKEN" "$API$REQ_PATH"
fi' "API=${API}" "METHOD=$1" "REQ_PATH=$2" "BODY=${3:-}" "TOKEN=${API_TOKEN}"
}
# Canonical, comparable view of the seeded settings as the API returns them.
settings_snapshot() {
local webhooks nodes security unauth token basic
webhooks=$(api_json GET /api/notifications/webhooks) || return 1
nodes=$(api_json GET /api/config/nodes) || return 1
security=$(cexec 'curl -fsS "$API/api/security/status"' "API=${API}") || return 1
unauth=$(api_status GET /api/config/nodes none)
token=$(api_status GET /api/config/nodes token)
basic=$(api_status GET /api/config/nodes basic)
jq -n -S \
--argjson webhooks "$webhooks" \
--argjson nodes "$nodes" \
--argjson security "$security" \
--arg unauth "$unauth" --arg token "$token" --arg basic "$basic" \
--arg webhook_name "$SEED_WEBHOOK_NAME" --arg node_name "$SEED_NODE_NAME" '
{
auth: {
requiresAuth: ($security.requiresAuth // null),
unauthenticated: $unauth,
api_token: $token,
password: $basic
},
webhook: ([$webhooks | .. | objects | select(.name? == $webhook_name)
| {id, name, url, method, service, enabled,
header_keys: ((.headers // {}) | keys)}] | first),
node: ([$nodes | .. | objects | select(.name? == $node_name)
| {name, type, host, tokenName, hasToken, hasPassword, verifySSL}] | first)
}'
}
# Expected shape of a healthy snapshot (independent of the baseline, so a
# baseline that silently lost data cannot make later phases pass).
check_snapshot_shape() {
local snapshot="$1"
local problems
problems=$(jq -r \
--arg webhook_name "$SEED_WEBHOOK_NAME" --arg webhook_url "$SEED_WEBHOOK_URL" \
--arg node_name "$SEED_NODE_NAME" --arg node_host "$SEED_NODE_HOST" \
--arg node_token_name "$SEED_NODE_TOKEN_NAME" --arg header "$SEED_WEBHOOK_HEADER" '
[ (if .auth.requiresAuth != true then "security status does not require auth" else empty end),
(if .auth.unauthenticated != "401" then "unauthenticated request returned \(.auth.unauthenticated), expected 401" else empty end),
(if .auth.api_token != "200" then "API token request returned \(.auth.api_token), expected 200" else empty end),
(if .auth.password != "200" then "password request returned \(.auth.password), expected 200" else empty end),
(if .webhook == null then "seeded webhook \($webhook_name) missing" else empty end),
(if .webhook != null and .webhook.url != $webhook_url then "seeded webhook URL changed to \(.webhook.url)" else empty end),
(if .webhook != null and .webhook.method != "POST" then "seeded webhook method changed to \(.webhook.method)" else empty end),
(if .webhook != null and .webhook.service != "generic" then "seeded webhook service changed to \(.webhook.service)" else empty end),
(if .webhook != null and .webhook.enabled != false then "seeded webhook enabled state changed to \(.webhook.enabled)" else empty end),
(if .webhook != null and ((.webhook.id // "") == "") then "seeded webhook lost its id" else empty end),
(if .webhook != null and .webhook.header_keys != [$header] then "seeded webhook headers changed to \(.webhook.header_keys)" else empty end),
(if .node == null then "seeded node \($node_name) missing" else empty end),
(if .node != null and .node.type != "pve" then "seeded node type changed to \(.node.type)" else empty end),
(if .node != null and .node.host != $node_host then "seeded node host changed to \(.node.host)" else empty end),
(if .node != null and .node.tokenName != $node_token_name then "seeded node token name changed to \(.node.tokenName)" else empty end),
(if .node != null and .node.hasToken != true then "seeded node lost its API token secret" else empty end),
(if .node != null and .node.hasPassword != false then "seeded node gained a password" else empty end),
(if .node != null and .node.verifySSL != false then "seeded node verifySSL changed to \(.node.verifySSL)" else empty end)
] | .[]' <<<"$snapshot") || problems="snapshot is not valid JSON"
if [[ -n "$problems" ]]; then
while IFS= read -r problem; do
note_failure "settings: ${problem}"
done <<<"$problems"
return 1
fi
}
datadir_manifest() {
# path<TAB>sha256 for every regular file, excluding SQLite sidecars and
# temp files whose presence depends on shutdown timing.
cexec 'cd "$DATA_DIR" && find . -type f ! -name "*-wal" ! -name "*-shm" ! -name "*.tmp" ! -name "*.lock" -printf "%P\n" | LC_ALL=C sort | while IFS= read -r f; do printf "%s\t%s\n" "$f" "$(sha256sum "$f" | cut -d" " -f1)"; done' \
"DATA_DIR=${DATA_DIR}"
}
# Encrypted stores holding the seeded secrets (node token value, webhook
# header value). The API only exposes hasToken and redacted header values, so
# byte identity of these files under an unchanged key is the proof that the
# exact secrets survived. A version that legitimately rewrites them fails the
# rehearsal loudly rather than passing unproven.
SECRET_STORES=".encryption.key nodes.enc webhooks.enc"
# compare_datadir BASELINE CURRENT LABEL: manifest comparison (pure, testable).
compare_datadir() {
local baseline="$1" current="$2" label="$3"
local missing changed added store before after
missing=$(comm -23 <(cut -f1 "$baseline") <(cut -f1 "$current") | sed '/^$/d')
added=$(comm -13 <(cut -f1 "$baseline") <(cut -f1 "$current") | sed '/^$/d' | wc -l | tr -d ' ')
changed=$(join -t $'\t' "$baseline" "$current" | awk -F'\t' '$2 != $3' | wc -l | tr -d ' ')
PHASE_DATADIR="$(wc -l < "$current" | tr -d ' ') files, ${changed} changed, ${added} new"
log "${DATA_DIR} after ${label}: ${PHASE_DATADIR}"
if [[ -n "$missing" ]]; then
PHASE_DATADIR="${PHASE_DATADIR}, $(wc -l <<<"$missing" | tr -d ' ') missing"
note_failure "files present before the upgrade are gone from ${DATA_DIR}: $(tr '\n' ' ' <<<"$missing")"
fi
for store in $SECRET_STORES; do
before=$(awk -F'\t' -v f="$store" '$1 == f {print $2}' "$baseline")
after=$(awk -F'\t' -v f="$store" '$1 == f {print $2}' "$current")
if [[ -z "$before" || "$before" != "$after" ]]; then
note_failure "${DATA_DIR}/${store} is missing or was rewritten; the seeded secrets are no longer provably intact"
fi
done
if [[ "$changed" -gt 0 ]]; then
log "files rewritten in place (informational unless listed above):"
join -t $'\t' "$baseline" "$current" | awk -F'\t' '$2 != $3 {print " " $1}'
fi
}
check_datadir() {
local baseline="${WORK_DIR}/state/datadir.baseline.tsv"
local current="${WORK_DIR}/state/datadir.$1.tsv"
datadir_manifest > "$current" || { note_failure "could not list ${DATA_DIR}"; PHASE_DATADIR="unreadable"; return 1; }
compare_datadir "$baseline" "$current" "$1"
}
check_settings() {
local snapshot
if ! snapshot=$(settings_snapshot); then
note_failure "settings could not be read back through the API"
PHASE_SETTINGS="unreadable"
return 1
fi
printf '%s\n' "$snapshot" > "${WORK_DIR}/state/settings.$1.json"
local ok=true
check_snapshot_shape "$snapshot" || ok=false
if ! diff -u "${WORK_DIR}/state/settings.baseline.json" "${WORK_DIR}/state/settings.$1.json"; then
note_failure "seeded settings read back differently than before the upgrade (diff above)"
ok=false
fi
if [[ "$ok" == "true" ]]; then
PHASE_SETTINGS="auth, webhook, node intact"
else
PHASE_SETTINGS="drift"
return 1
fi
}
# ---------------------------------------------------------------------------
# Phases
# ---------------------------------------------------------------------------
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
note_failure "install.sh --version ${FROM_TAG} exited non-zero"
fi
assert_runtime "$FROM_TAG"
record_phase "1. install" "$FROM_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "-" "-" "$PHASE_UNITS"
}
phase_seed() {
reset_phase_cells
log "Phase 2: seed first-run security, a webhook and a PVE node through the API"
local token_output setup_token setup_response
token_output=$(cexec 'runuser -u pulse -- env PULSE_DATA_DIR="$DATA_DIR" /opt/pulse/bin/pulse bootstrap-token' "DATA_DIR=${DATA_DIR}" 2>&1 || true)
setup_token=$(grep -oE 'Token: [^[:space:]]+' <<<"$token_output" | head -1 | cut -d' ' -f2)
if [[ -z "$setup_token" ]]; then
note_failure "pulse bootstrap-token did not print a setup token"
record_phase "2. seed" "$FROM_TAG" "-" "-" "-" "-" "-"
return 1
fi
local setup_body
setup_body=$(jq -cn --arg u "$SEED_ADMIN_USER" --arg p "$ADMIN_PASSWORD" --arg t "$API_TOKEN" \
'{username: $u, password: $p, apiToken: $t}')
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}"
fi
local webhook_body node_body
webhook_body=$(jq -cn --arg n "$SEED_WEBHOOK_NAME" --arg u "$SEED_WEBHOOK_URL" \
--arg hk "$SEED_WEBHOOK_HEADER" --arg hv "$NODE_TOKEN_VALUE" \
'{name: $n, url: $u, method: "POST", service: "generic", enabled: false,
headers: {($hk): $hv}}')
if ! api_json POST /api/notifications/webhooks "$webhook_body" >/dev/null; then
note_failure "creating the seed webhook failed"
fi
node_body=$(jq -cn --arg n "$SEED_NODE_NAME" --arg h "$SEED_NODE_HOST" --arg v "$NODE_TOKEN_VALUE" \
--arg tn "$SEED_NODE_TOKEN_NAME" \
'{type: "pve", name: $n, host: $h, tokenName: $tn, tokenValue: $v, verifySSL: false}')
if ! api_json POST /api/config/nodes "$node_body" >/dev/null; then
note_failure "creating the seed PVE node failed"
fi
local snapshot=""
if snapshot=$(settings_snapshot) && check_snapshot_shape "$snapshot"; then
printf '%s\n' "$snapshot" > "${WORK_DIR}/state/settings.baseline.json"
PHASE_SETTINGS="auth, webhook, node seeded"
log "Seeded settings as read back through the API:"
printf '%s\n' "$snapshot"
else
if [[ -n "$snapshot" ]]; then
printf '%s\n' "$snapshot"
fi
note_failure "seeded settings did not read back through the API"
PHASE_SETTINGS="not readable"
fi
# Persistence is asynchronous for some stores; let writes settle before
# the data dir becomes the upgrade baseline.
sleep 5
if datadir_manifest > "${WORK_DIR}/state/datadir.baseline.tsv" \
&& grep -q $'^\\.encryption\\.key\t' "${WORK_DIR}/state/datadir.baseline.tsv"; then
PHASE_DATADIR="$(wc -l < "${WORK_DIR}/state/datadir.baseline.tsv" | tr -d ' ') files recorded"
log "${DATA_DIR} baseline:"
cut -f1 "${WORK_DIR}/state/datadir.baseline.tsv" | sed 's/^/ /'
else
note_failure "could not record ${DATA_DIR} (or it has no .encryption.key)"
PHASE_DATADIR="not recorded"
fi
record_phase "2. seed" "$FROM_TAG" "-" "-" "$PHASE_SETTINGS" "$PHASE_DATADIR" "-"
}
run_updater() {
local target="$1" label="$2"
# The helper must be the one the Pulse server installer wrote, not a
# community-scripts updater that ignores --version.
if ! cexec 'test -x /bin/update && grep -q "^# Pulse update command" /bin/update'; then
note_failure "/bin/update is missing or is not the Pulse server installer's update helper"
return 1
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}" \
2>&1 | tee "${WORK_DIR}/state/${label}-${target}.log"; then
note_failure "/bin/update --version ${target} exited non-zero"
fi
}
phase_upgrade() {
reset_phase_cells
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
run_updater "$TO_TAG" upgrade || true
assert_runtime "$TO_TAG"
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"
}
phase_rollback() {
reset_phase_cells
log "Phase 4: roll back with /bin/update --version ${FROM_TAG}"
run_updater "$FROM_TAG" rollback || true
assert_runtime "$FROM_TAG"
check_settings rollback || true
check_datadir rollback || true
record_phase "4. rollback (/bin/update)" "$FROM_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
}
main() {
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO} with ${ENGINE}"
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"
record_phase "0. systemd host" "-" "-" "-" "-" "-" "-" || abort_run
fi
phase_install || abort_run
phase_seed || abort_run
# Rollback runs even when the upgrade fails: it is the recovery path a
# user reaches for in exactly that situation.
phase_upgrade || true
phase_rollback || true
write_summary
return "$OVERALL_STATUS"
}
if [[ -n "$CHECK_SNAPSHOT" ]]; then
check_snapshot_shape "$(cat "$CHECK_SNAPSHOT")"
exit $?
fi
if [[ ${#COMPARE_DATADIR[@]} -gt 0 ]]; then
compare_datadir "${COMPARE_DATADIR[0]}" "${COMPARE_DATADIR[1]}" self-test
[[ -z "$CURRENT_FAILURE" ]]
exit $?
fi
main

View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Choose the published release pair for the release lifecycle rehearsal.
The rehearsal installs FROM, upgrades to TO and rolls back to FROM. Both tags
must name published (non-draft) Pulse server releases and TO must be strictly
newer than FROM under SemVer precedence, so a shadow run can never report a
same-version "upgrade" as proof.
Defaults:
FROM = the release GitHub advertises as latest (must be stable).
TO = the newest published release or prerelease newer than FROM.
When FROM was defaulted and nothing newer exists yet (the common case right
after a stable release), the pair becomes previous stable -> latest stable,
which is the upgrade users are taking at that moment.
"""
from __future__ import annotations
import argparse
import functools
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
TAG_RE = re.compile(
r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
)
class ResolutionError(ValueError):
"""The requested or default release pair cannot be rehearsed."""
@dataclass(frozen=True)
class Selection:
from_tag: str
to_tag: str
reason: str
def parse_tag(tag: str) -> tuple[tuple[int, int, int], tuple[str, ...]]:
match = TAG_RE.fullmatch(tag)
if not match:
raise ResolutionError(f"not a Pulse release tag: {tag!r}")
core = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
pre = tuple(match.group(4).split(".")) if match.group(4) else ()
return core, pre
def is_stable(tag: str) -> bool:
return not parse_tag(tag)[1]
def _compare_identifiers(left: tuple[str, ...], right: tuple[str, ...]) -> int:
# SemVer 2.0.0 section 11: a release outranks any prerelease of the same
# core; numeric identifiers compare numerically and rank below
# alphanumeric ones; a longer identifier list wins when all shared
# identifiers are equal.
if not left and not right:
return 0
if not left:
return 1
if not right:
return -1
for a, b in zip(left, right):
if a == b:
continue
a_num, b_num = a.isdigit(), b.isdigit()
if a_num and b_num:
return -1 if int(a) < int(b) else 1
if a_num != b_num:
return -1 if a_num else 1
return -1 if a < b else 1
if len(left) == len(right):
return 0
return -1 if len(left) < len(right) else 1
def compare_tags(left: str, right: str) -> int:
left_core, left_pre = parse_tag(left)
right_core, right_pre = parse_tag(right)
if left_core != right_core:
return -1 if left_core < right_core else 1
return _compare_identifiers(left_pre, right_pre)
def published_tags(releases: list[dict]) -> list[str]:
tags: set[str] = set()
for release in releases:
if not isinstance(release, dict) or release.get("draft") is not False:
continue
tag = release.get("tag_name")
if isinstance(tag, str) and TAG_RE.fullmatch(tag):
tags.add(tag)
return sorted(tags, key=functools.cmp_to_key(compare_tags))
def resolve(
releases: list[dict],
latest_tag: str,
requested_from: str = "",
requested_to: str = "",
) -> Selection:
tags = published_tags(releases)
published = set(tags)
for label, value in (("from_version", requested_from), ("to_version", requested_to)):
if value:
parse_tag(value)
if value not in published:
raise ResolutionError(f"{label} {value} is not a published Pulse release")
if requested_from:
from_tag = requested_from
else:
if not latest_tag or latest_tag not in published or not is_stable(latest_tag):
raise ResolutionError(
f"latest advertised release {latest_tag!r} is not a published stable Pulse release"
)
from_tag = latest_tag
if requested_to:
if compare_tags(requested_to, from_tag) <= 0:
raise ResolutionError(
f"to_version {requested_to} must be newer than from_version {from_tag}"
)
return Selection(from_tag, requested_to, "requested pair")
newer = [tag for tag in tags if compare_tags(tag, from_tag) > 0]
if newer:
reason = "requested from_version" if requested_from else "latest stable"
return Selection(from_tag, newer[-1], f"{reason} -> newest published release")
if requested_from:
raise ResolutionError(f"no published release is newer than from_version {from_tag}")
older_stable = [
tag for tag in tags if is_stable(tag) and compare_tags(tag, from_tag) < 0
]
if not older_stable:
raise ResolutionError(f"no stable release precedes latest stable {from_tag}")
return Selection(
older_stable[-1],
from_tag,
"nothing newer than latest stable is published; previous stable -> latest stable",
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--releases-json", required=True, type=Path,
help="JSON array of GitHub release objects (all pages)")
parser.add_argument("--latest-tag", default="",
help="tag_name of GET /repos/{repo}/releases/latest")
parser.add_argument("--from", dest="from_version", default="")
parser.add_argument("--to", dest="to_version", default="")
args = parser.parse_args(argv)
try:
releases = json.loads(args.releases_json.read_text(encoding="utf-8"))
if not isinstance(releases, list):
raise ResolutionError("releases JSON must be an array")
selection = resolve(
releases,
args.latest_tag.strip(),
args.from_version.strip(),
args.to_version.strip(),
)
except (ResolutionError, json.JSONDecodeError, OSError) as error:
print(f"release lifecycle rehearsal: {error}", file=sys.stderr)
return 1
print(f"from_tag={selection.from_tag}")
print(f"to_tag={selection.to_tag}")
print(f"reason={selection.reason}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""Contract tests for the shadow release lifecycle rehearsal."""
from __future__ import annotations
import importlib.util
import json
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
RESOLVER_PATH = REPO_ROOT / "scripts" / "release_lifecycle_rehearsal_versions.py"
HARNESS_PATH = REPO_ROOT / "scripts" / "release_lifecycle_rehearsal.sh"
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release-lifecycle-rehearsal.yml"
SMOKE_BODY_PATH = REPO_ROOT / ".github" / "workflows" / "install-sh-smoke-body.yml"
SPEC = importlib.util.spec_from_file_location("release_lifecycle_rehearsal_versions", RESOLVER_PATH)
assert SPEC and SPEC.loader
versions = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = versions
SPEC.loader.exec_module(versions)
def release(tag: str, *, draft: bool = False, prerelease: bool | None = None) -> dict:
return {
"tag_name": tag,
"draft": draft,
"prerelease": ("-" in tag) if prerelease is None else prerelease,
}
RELEASES = [
release("v6.4.5"),
release("helm-chart-6.4.5"),
release("v6.4.5-rc.5"),
release("v6.4.5-rc.10"),
release("v6.4.5-beta.3"),
release("v6.4.1"),
release("v6.4.0"),
release("v6.4.0-rc.9"),
release("v6.4.6-rc.1", draft=True),
]
class SemverOrderingTest(unittest.TestCase):
def test_release_outranks_its_prereleases(self) -> None:
self.assertGreater(versions.compare_tags("v6.4.5", "v6.4.5-rc.10"), 0)
def test_numeric_identifiers_compare_numerically(self) -> None:
self.assertGreater(versions.compare_tags("v6.4.5-rc.10", "v6.4.5-rc.5"), 0)
def test_alphanumeric_identifiers_compare_lexically(self) -> None:
self.assertGreater(versions.compare_tags("v6.4.5-rc.1", "v6.4.5-beta.3"), 0)
def test_rejects_non_release_tags(self) -> None:
for tag in ("6.4.5", "helm-chart-6.4.5", "v6.4", "v6.4.5;id", "v06.4.5"):
with self.subTest(tag=tag), self.assertRaises(versions.ResolutionError):
versions.parse_tag(tag)
class ResolvePairTest(unittest.TestCase):
def test_defaults_to_newer_prerelease_after_latest_stable(self) -> None:
releases = RELEASES + [release("v6.4.6-rc.2"), release("v6.4.6-beta.1")]
selection = versions.resolve(releases, "v6.4.5")
self.assertEqual((selection.from_tag, selection.to_tag), ("v6.4.5", "v6.4.6-rc.2"))
def test_defaults_fall_back_to_previous_stable_when_nothing_is_newer(self) -> None:
selection = versions.resolve(RELEASES, "v6.4.5")
self.assertEqual((selection.from_tag, selection.to_tag), ("v6.4.1", "v6.4.5"))
self.assertIn("previous stable", selection.reason)
def test_draft_releases_are_never_targets(self) -> None:
selection = versions.resolve(RELEASES, "v6.4.5")
self.assertNotEqual(selection.to_tag, "v6.4.6-rc.1")
with self.assertRaises(versions.ResolutionError):
versions.resolve(RELEASES, "v6.4.5", "v6.4.5", "v6.4.6-rc.1")
def test_explicit_from_selects_newest_newer_release(self) -> None:
selection = versions.resolve(RELEASES, "v6.4.5", "v6.4.0")
self.assertEqual((selection.from_tag, selection.to_tag), ("v6.4.0", "v6.4.5"))
def test_explicit_from_without_newer_release_fails_instead_of_passing(self) -> None:
with self.assertRaises(versions.ResolutionError):
versions.resolve(RELEASES, "v6.4.5", "v6.4.5")
def test_explicit_pair_must_move_forward(self) -> None:
for from_tag, to_tag in (("v6.4.5", "v6.4.1"), ("v6.4.5", "v6.4.5")):
with self.subTest(pair=(from_tag, to_tag)), self.assertRaises(versions.ResolutionError):
versions.resolve(RELEASES, "v6.4.5", from_tag, to_tag)
def test_unpublished_or_malformed_requests_fail(self) -> None:
for from_tag, to_tag in (("v6.3.0", ""), ("v6.4.1", "v6.9.9"), ("v6.4.1 ; id", "")):
with self.subTest(pair=(from_tag, to_tag)), self.assertRaises(versions.ResolutionError):
versions.resolve(RELEASES, "v6.4.5", from_tag, to_tag)
def test_latest_must_be_published_stable(self) -> None:
for latest in ("", "v6.4.5-rc.5", "v9.9.9"):
with self.subTest(latest=latest), self.assertRaises(versions.ResolutionError):
versions.resolve(RELEASES, latest)
def test_cli_emits_key_value_pair(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "releases.json"
path.write_text(json.dumps(RELEASES), encoding="utf-8")
result = subprocess.run(
[sys.executable, str(RESOLVER_PATH), "--releases-json", str(path),
"--latest-tag", "v6.4.5", "--from", "v6.4.0", "--to", "v6.4.1"],
capture_output=True, text=True, check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("from_tag=v6.4.0\n", result.stdout)
self.assertIn("to_tag=v6.4.1\n", result.stdout)
class HarnessContractTest(unittest.TestCase):
harness = HARNESS_PATH.read_text(encoding="utf-8")
def test_upgrade_and_rollback_use_the_installed_update_helper(self) -> None:
self.assertIn("cexec '/bin/update --version \"$TARGET\"'", self.harness)
self.assertIn('run_updater "$TO_TAG" upgrade', self.harness)
self.assertIn('run_updater "$FROM_TAG" rollback', self.harness)
self.assertIn('grep -q "^# Pulse update command" /bin/update', self.harness)
# The updater invocation carries no flags that would bypass the
# behavior users get (archives, preflight skips, forced auto-update).
updater_lines = [line for line in self.harness.splitlines() if "/bin/update --" in line]
for line in updater_lines:
for flag in ("--archive", "--skip-upgrade-preflight", "--disable-auto-updates"):
self.assertNotIn(flag, line)
def test_initial_install_uses_signed_published_installer(self) -> None:
for needle in (
"releases/download/${tag}",
"grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer'",
"-I pulse-installer",
"-n pulse-install",
"bash /rehearsal/install.sh --version \"$FROM_TAG\"",
):
self.assertIn(needle, self.harness)
def test_uses_the_same_pinned_systemd_image_as_install_smoke(self) -> None:
digest = re.search(r"jrei/systemd-debian:12@sha256:[0-9a-f]{64}", self.harness)
self.assertIsNotNone(digest)
self.assertIn(digest.group(0), SMOKE_BODY_PATH.read_text(encoding="utf-8"))
def test_every_post_change_phase_checks_identity_health_settings_and_data(self) -> None:
for phase in ("phase_upgrade", "phase_rollback"):
body = self.harness.split(f"{phase}() {{", 1)[1].split("\n}\n", 1)[0]
for check in ("assert_runtime", "check_settings", "check_datadir", "record_phase"):
with self.subTest(phase=phase, check=check):
self.assertIn(check, body)
def test_settings_are_checked_against_fixed_expectations_not_only_baseline(self) -> None:
body = self.harness.split("check_settings() {", 1)[1].split("\n}\n", 1)[0]
self.assertIn("check_snapshot_shape", body)
self.assertIn("settings.baseline.json", body)
def test_harness_passes_shellcheck_syntax(self) -> None:
result = subprocess.run(["bash", "-n", str(HARNESS_PATH)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_rejects_unsafe_tags_before_touching_a_container(self) -> None:
for args in (["--from", "v6.4.1", "--to", "v6.4.1"],
["--from", "v6.4.1;id", "--to", "v6.4.5"],
["--from", "", "--to", "v6.4.5"]):
with self.subTest(args=args):
result = subprocess.run(
["bash", str(HARNESS_PATH), *args],
capture_output=True, text=True,
env={"PATH": "/usr/bin:/bin", "PULSE_REHEARSAL_ENGINE": "none-such"},
)
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
GOOD_SNAPSHOT = {
"auth": {"api_token": "200", "password": "200", "requiresAuth": True, "unauthenticated": "401"},
"node": {
"hasPassword": False,
"hasToken": True,
"host": "https://192.168.77.10:8006",
"name": "test-lifecycle-rehearsal",
"tokenName": "root@pam!rehearsal",
"type": "pve",
"verifySSL": False,
},
"webhook": {
"enabled": False,
"header_keys": ["X-Rehearsal-Secret"],
"id": "webhook-123",
"method": "POST",
"name": "lifecycle-rehearsal-webhook",
"service": "generic",
"url": "https://example.com/pulse-lifecycle-rehearsal",
},
}
def mutated(path: tuple[str, ...], value: object) -> dict:
snapshot = json.loads(json.dumps(GOOD_SNAPSHOT))
target = snapshot
for key in path[:-1]:
target = target[key]
target[path[-1]] = value
return snapshot
class SnapshotExpectationTest(unittest.TestCase):
"""Lost or altered settings must fail even when the baseline also lost them."""
def check(self, snapshot: object) -> subprocess.CompletedProcess:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "snapshot.json"
path.write_text(json.dumps(snapshot) if not isinstance(snapshot, str) else snapshot,
encoding="utf-8")
return subprocess.run(
["bash", str(HARNESS_PATH), "--check-snapshot", str(path)],
capture_output=True, text=True, check=False,
env={"PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"},
)
def test_intact_snapshot_passes(self) -> None:
result = self.check(GOOD_SNAPSHOT)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_lost_or_changed_settings_fail(self) -> None:
cases = {
"node token secret lost": (("node", "hasToken"), False),
"node token name reset": (("node", "tokenName"), ""),
"node TLS verification flipped": (("node", "verifySSL"), True),
"node host rewritten": (("node", "host"), "https://192.168.77.11:8006"),
"node missing": (("node",), None),
"webhook missing": (("webhook",), None),
"webhook header lost": (("webhook", "header_keys"), []),
"webhook re-enabled": (("webhook", "enabled"), True),
"webhook URL changed": (("webhook", "url"), "https://example.org/"),
"auth no longer required": (("auth", "requiresAuth"), False),
"anonymous access allowed": (("auth", "unauthenticated"), "200"),
"API token rejected": (("auth", "api_token"), "401"),
"password rejected": (("auth", "password"), "401"),
}
for label, (path, value) in cases.items():
with self.subTest(case=label):
result = self.check(mutated(path, value))
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("::error::settings:", result.stdout)
def test_unparseable_snapshot_fails(self) -> None:
self.assertEqual(self.check("not json").returncode, 1)
BASELINE_MANIFEST = {
".encryption.key": "a" * 64,
".env": "b" * 64,
"metrics.db": "c" * 64,
"nodes.enc": "d" * 64,
"system.json": "e" * 64,
"webhooks.enc": "f" * 64,
}
class DataDirComparisonTest(unittest.TestCase):
def compare(self, current: dict[str, str]) -> subprocess.CompletedProcess:
def manifest(entries: dict[str, str]) -> str:
return "".join(f"{path}\t{digest}\n" for path, digest in sorted(entries.items()))
with tempfile.TemporaryDirectory() as tmp:
baseline_path = Path(tmp) / "baseline.tsv"
current_path = Path(tmp) / "current.tsv"
baseline_path.write_text(manifest(BASELINE_MANIFEST), encoding="utf-8")
current_path.write_text(manifest(current), encoding="utf-8")
return subprocess.run(
["bash", str(HARNESS_PATH), "--compare-datadir", str(baseline_path), str(current_path)],
capture_output=True, text=True, check=False,
env={"PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"},
)
def test_rewritten_plain_files_and_new_files_pass(self) -> None:
current = dict(BASELINE_MANIFEST, **{"system.json": "1" * 64, "metrics.db": "2" * 64,
"new-store.db": "3" * 64})
result = self.compare(current)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_missing_files_and_rewritten_secret_stores_fail(self) -> None:
cases = {
"file deleted": {k: v for k, v in BASELINE_MANIFEST.items() if k != "metrics.db"},
"encryption key replaced": dict(BASELINE_MANIFEST, **{".encryption.key": "0" * 64}),
"node secrets rewritten": dict(BASELINE_MANIFEST, **{"nodes.enc": "0" * 64}),
"webhook secrets rewritten": dict(BASELINE_MANIFEST, **{"webhooks.enc": "0" * 64}),
"data dir emptied": {},
}
for label, current in cases.items():
with self.subTest(case=label):
result = self.compare(current)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("::error::", result.stdout)
class WorkflowContractTest(unittest.TestCase):
workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
def test_triggers_are_dispatch_and_daily_schedule_only(self) -> None:
self.assertIn("workflow_dispatch:", self.workflow)
self.assertIn("from_version:", self.workflow)
self.assertIn("to_version:", self.workflow)
self.assertRegex(self.workflow, r"(?m)^ schedule:\n # [^\n]*\n(?: #[^\n]*\n)* - cron: '23 13 \* \* \*'$")
for trigger in ("pull_request", "push:", "workflow_run", "workflow_call", "release:"):
self.assertNotIn(trigger, self.workflow)
def test_read_only_hosted_and_report_only(self) -> None:
self.assertRegex(self.workflow, r"(?m)^permissions:\n contents: read$")
self.assertNotIn(": write", self.workflow)
self.assertNotIn("secrets.", self.workflow)
self.assertNotIn("self-hosted", self.workflow)
self.assertIn("runs-on: ubuntu-24.04", self.workflow)
self.assertIn("persist-credentials: false", self.workflow)
self.assertNotIn("upload-artifact", self.workflow)
def test_dispatch_inputs_reach_shell_only_through_env(self) -> None:
for line in self.workflow.splitlines():
if "${{" in line and ("inputs." in line or "steps." in line):
self.assertRegex(line.strip(), r"^[A-Z_]+: \$\{\{ [a-z_.]+ \}\}$")
def test_no_release_workflow_depends_on_the_rehearsal(self) -> None:
for path in (REPO_ROOT / ".github" / "workflows").glob("*.yml"):
if path == WORKFLOW_PATH:
continue
with self.subTest(workflow=path.name):
self.assertNotIn("release-lifecycle-rehearsal", path.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()