mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:47:49 +00:00
fix(ci): keep stable install smoke within caller permissions
Stable continuity run 33592377446 was rejected before any job ran: its read-only caller invoked a reusable job requesting contents:write. Extract the unchanged smoke execution into a body that inherits the caller budget, keeping the existing draft-capable entry point and its write-level draft GET access. Continuity now calls the shared body directly without broadening its token. Pin the permission boundary in regression coverage; do not relax immutable-release admission. Change-source: pulse-maintainer
This commit is contained in:
parent
d60da624c8
commit
fb9e4335e1
5 changed files with 331 additions and 278 deletions
7
.github/workflows/README.md
vendored
7
.github/workflows/README.md
vendored
|
|
@ -140,6 +140,13 @@ 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.
|
||||
|
||||
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`
|
||||
manual/release entry point retains `contents: write` for unpublished draft asset
|
||||
GETs and forwards the same inputs to the body. Calling that draft-capable entry
|
||||
point from read-only continuity prevents workflow admission before any job runs.
|
||||
|
||||
Future release candidates also carry
|
||||
`release-build-provenance.sigstore.json`, produced by the hosted
|
||||
`build-release-candidate.yml` job after complete candidate validation. The
|
||||
|
|
|
|||
302
.github/workflows/install-sh-smoke-body.yml
vendored
Normal file
302
.github/workflows/install-sh-smoke-body.yml
vendored
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
name: install.sh Smoke Body (Caller Permissions)
|
||||
|
||||
# No permissions declaration here: inherit the caller's explicit budget.
|
||||
# Continuity supplies contents:read; the draft-capable entry point supplies
|
||||
# contents:write to read unpublished assets. Reusable workflows cannot elevate
|
||||
# the caller's token, even when a write-requiring job would be skipped.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g., v6.0.0-rc.6)'
|
||||
required: true
|
||||
type: string
|
||||
version:
|
||||
description: 'Version without v prefix (e.g., 6.0.0-rc.6)'
|
||||
required: true
|
||||
type: string
|
||||
repository:
|
||||
description: 'owner/repo to pull the release from. Defaults to the workflow repository.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
asset_source:
|
||||
description: 'Asset source: staged for a draft release, or published for the public release URL.'
|
||||
required: false
|
||||
type: string
|
||||
default: 'published'
|
||||
release_id:
|
||||
description: 'Draft release ID. Required when asset_source is staged.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository (for README key extraction)
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve smoke inputs
|
||||
id: inputs
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_REPO: ${{ inputs.repository }}
|
||||
INPUT_ASSET_SOURCE: ${{ inputs.asset_source }}
|
||||
INPUT_RELEASE_ID: ${{ inputs.release_id }}
|
||||
DEFAULT_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# workflow_call and workflow_dispatch both require tag + version,
|
||||
# so these should always be present when this job runs.
|
||||
tag="${INPUT_TAG}"
|
||||
version="${INPUT_VERSION}"
|
||||
if [ -z "$tag" ] || [ -z "$version" ]; then
|
||||
echo "::error::install-sh-smoke requires both tag and version inputs"
|
||||
exit 1
|
||||
fi
|
||||
repo="${INPUT_REPO:-$DEFAULT_REPO}"
|
||||
asset_source="${INPUT_ASSET_SOURCE:-published}"
|
||||
release_id="${INPUT_RELEASE_ID:-}"
|
||||
case "$asset_source" in
|
||||
published) ;;
|
||||
staged)
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "::error::release_id is required when asset_source=staged"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::asset_source must be staged or published, got: $asset_source"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
python3 scripts/write_github_output.py tag "$tag"
|
||||
python3 scripts/write_github_output.py version "$version"
|
||||
python3 scripts/write_github_output.py repo "$repo"
|
||||
python3 scripts/write_github_output.py asset_source "$asset_source"
|
||||
python3 scripts/write_github_output.py release_id "$release_id"
|
||||
echo "Resolved: tag=$tag version=$version repo=$repo asset_source=$asset_source release_id=${release_id:-none}"
|
||||
|
||||
- name: Download install.sh + sshsig + linux-amd64 tarball
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
REPO: ${{ steps.inputs.outputs.repo }}
|
||||
ASSET_SOURCE: ${{ steps.inputs.outputs.asset_source }}
|
||||
RELEASE_ID: ${{ steps.inputs.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p smoke-workspace
|
||||
cd smoke-workspace
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
assets=(install.sh install.sh.sshsig "${tarball}" "${tarball}.sshsig")
|
||||
|
||||
if [ "$ASSET_SOURCE" = "staged" ]; then
|
||||
assets_json=$(mktemp)
|
||||
gh api --paginate "repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
| jq -s 'add' > "$assets_json"
|
||||
|
||||
release_state=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \
|
||||
--jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv')
|
||||
actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state")
|
||||
is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state")
|
||||
published_at=$(awk -F '\t' '{print $3}' <<<"$release_state")
|
||||
if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then
|
||||
echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for asset_name in "${assets[@]}"; do
|
||||
asset_id=$(jq -r --arg name "$asset_name" \
|
||||
'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \
|
||||
"$assets_json")
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset."
|
||||
exit 1
|
||||
fi
|
||||
gh api \
|
||||
-H 'Accept: application/octet-stream' \
|
||||
"repos/${REPO}/releases/assets/${asset_id}" > "$asset_name"
|
||||
done
|
||||
rm -f "$assets_json"
|
||||
else
|
||||
base="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
echo "Pulling from ${base}/"
|
||||
for asset_name in "${assets[@]}"; do
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o "$asset_name" "${base}/${asset_name}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Downloaded:"
|
||||
ls -la
|
||||
|
||||
- name: Verify install.sh signature with README's pinned key
|
||||
env:
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
readme_key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
|
||||
if [ -z "$readme_key" ]; then
|
||||
echo "::error::Could not extract pulse-installer key from README.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "README pins: $readme_key"
|
||||
|
||||
allowed_signers=$(mktemp)
|
||||
printf 'pulse-installer %s\n' "$readme_key" > "$allowed_signers"
|
||||
|
||||
cd smoke-workspace
|
||||
if ! ssh-keygen -Y verify \
|
||||
-f "$allowed_signers" \
|
||||
-I pulse-installer \
|
||||
-n pulse-install \
|
||||
-s install.sh.sshsig < install.sh; then
|
||||
echo "::error::Published install.sh.sshsig does not verify against the README's pinned key."
|
||||
echo "::error::Either README.md is pinning the wrong key or the pipeline signed with a different key."
|
||||
rm -f "$allowed_signers"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$allowed_signers"
|
||||
echo "✓ install.sh signature verifies against README's pinned key"
|
||||
|
||||
- name: Assert install.sh is the Pulse server installer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd smoke-workspace
|
||||
if ! grep -qE '^# Pulse Installer Script' install.sh; then
|
||||
echo "::error::install.sh banner is not the Pulse server installer"
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Pulse Unified Agent Installer' install.sh; then
|
||||
echo "::error::install.sh is the agent installer, not the server installer"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE '^[[:space:]]*--version\)' install.sh; then
|
||||
echo "::error::install.sh does not handle --version"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ install.sh is the server installer with --version support"
|
||||
|
||||
- name: Run install.sh end-to-end in a privileged systemd container
|
||||
env:
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
container_name="pulse-install-smoke-$$"
|
||||
|
||||
# Cleanup on exit no matter what.
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# jrei/systemd-debian:12 is a community systemd-in-Docker image used
|
||||
# for Ansible / Molecule testing — small, no Pulse-specific assumptions.
|
||||
# Keep the privileged test environment bound to an immutable index.
|
||||
# A floating image here would let a registry retag replace code in a
|
||||
# release-gating job that needs contents:write to read draft assets.
|
||||
# GHA ubuntu-24.04 runners use cgroup v2 unified hierarchy; without
|
||||
# --cgroupns=host the container gets its own cgroup namespace and
|
||||
# systemd PID 1 exits before it can mount the cgroup tree, causing
|
||||
# the container to disappear mid-boot. /run/lock must also be tmpfs
|
||||
# for systemd-tmpfiles. We drop --rm so a failed boot leaves logs
|
||||
# behind for diagnosis; the trap removes the container on exit.
|
||||
docker run -d \
|
||||
--name "${container_name}" \
|
||||
--privileged \
|
||||
--cgroupns=host \
|
||||
--tmpfs /tmp --tmpfs /run --tmpfs /run/lock \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
-v "$(pwd)/smoke-workspace:/smoke" \
|
||||
-p 7655:7655 \
|
||||
jrei/systemd-debian:12@sha256:61d70dc3e574337bd9df794674a60ae73113460fff16ab41a2d234b4a11dcd98
|
||||
|
||||
echo "Waiting for systemd to be ready inside the container..."
|
||||
for i in $(seq 1 30); do
|
||||
if ! docker inspect -f '{{.State.Running}}' "${container_name}" 2>/dev/null | grep -q true; then
|
||||
echo "::error::Container ${container_name} is no longer running."
|
||||
docker inspect -f 'ExitCode={{.State.ExitCode}} Error={{.State.Error}}' "${container_name}" || true
|
||||
docker logs "${container_name}" || true
|
||||
exit 1
|
||||
fi
|
||||
if docker exec "${container_name}" systemctl is-system-running --wait 2>/dev/null | grep -qE '^(running|degraded)$'; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
docker logs "${container_name}" || true
|
||||
docker exec "${container_name}" systemctl --no-pager status || true
|
||||
docker exec "${container_name}" journalctl --no-pager --lines=120 || true
|
||||
echo "::error::systemd did not become ready inside the container"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✓ systemd is up"
|
||||
|
||||
echo "Installing prerequisites inside container..."
|
||||
docker exec "${container_name}" bash -lc 'apt-get update -qq && apt-get install -y -qq curl ca-certificates jq sudo'
|
||||
|
||||
echo "Running install.sh --archive against the published tarball..."
|
||||
# docker exec without -t leaves stdin without a TTY, which install.sh's
|
||||
# safe_read helper detects and falls through to defaults on every prompt.
|
||||
# PULSE_INSTALL_ALLOW_DOCKER=1 opts the smoke harness past install.sh's
|
||||
# Docker-environment refusal — install.sh treats the test container as
|
||||
# a normal systemd host, which is the exact contract this gate exists
|
||||
# to validate.
|
||||
docker exec -e PULSE_INSTALL_ALLOW_DOCKER=1 "${container_name}" \
|
||||
bash -lc "cd /smoke && PULSE_INSTALL_ALLOW_DOCKER=1 bash install.sh --archive /smoke/${tarball} --disable-auto-updates"
|
||||
|
||||
echo "Waiting for pulse.service to become active..."
|
||||
for i in $(seq 1 60); do
|
||||
state=$(docker exec "${container_name}" systemctl is-active pulse 2>/dev/null || true)
|
||||
if [ "$state" = "active" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
docker exec "${container_name}" systemctl status pulse --no-pager || true
|
||||
docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true
|
||||
echo "::error::pulse.service did not become active within 2 minutes"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✓ pulse.service is active"
|
||||
|
||||
echo "Hitting /api/health (curl --retry handles the poll loop)..."
|
||||
if ! docker exec "${container_name}" curl -fsS --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors http://127.0.0.1:7655/api/health >/dev/null; then
|
||||
docker exec "${container_name}" systemctl status pulse --no-pager || true
|
||||
docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true
|
||||
echo "::error::/api/health did not respond within 60 seconds of service activation"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ /api/health responded 200"
|
||||
|
||||
echo "Confirming installed version matches ${VERSION} via /api/version..."
|
||||
# /api/health intentionally does not include version; /api/version is
|
||||
# the authoritative endpoint and is one of the canonical post-upgrade
|
||||
# checks documented in docs/UPGRADE_v6.md.
|
||||
version_payload=$(docker exec "${container_name}" curl -fsS http://127.0.0.1:7655/api/version)
|
||||
echo "Version payload: ${version_payload}"
|
||||
installed_version=$(echo "${version_payload}" | jq -r '.version // empty')
|
||||
if [ -z "${installed_version}" ]; then
|
||||
echo "::error::/api/version did not include a version field"
|
||||
exit 1
|
||||
fi
|
||||
# Normalize: VERSION input is "6.0.0-rc.6", installed_version may be "v6.0.0-rc.6".
|
||||
installed_version="${installed_version#v}"
|
||||
if [ "${installed_version}" != "${VERSION}" ]; then
|
||||
echo "::error::Installed version mismatch. Expected ${VERSION}, got ${installed_version}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Installed version matches ${VERSION}"
|
||||
|
||||
- name: Smoke result
|
||||
env:
|
||||
WORKFLOW_OUTPUT_1: ${{ steps.inputs.outputs.tag }}
|
||||
run: |
|
||||
echo "::notice::install.sh smoke passed for tag ${WORKFLOW_OUTPUT_1}"
|
||||
277
.github/workflows/install-sh-smoke.yml
vendored
277
.github/workflows/install-sh-smoke.yml
vendored
|
|
@ -92,274 +92,13 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
# Unpublished draft release metadata and assets are unavailable to a
|
||||
# read-scoped GITHUB_TOKEN even though this job only performs GETs.
|
||||
# Unpublished draft assets require write-level repository access.
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository (for README key extraction)
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve smoke inputs
|
||||
id: inputs
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_REPO: ${{ inputs.repository }}
|
||||
INPUT_ASSET_SOURCE: ${{ inputs.asset_source }}
|
||||
INPUT_RELEASE_ID: ${{ inputs.release_id }}
|
||||
DEFAULT_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# workflow_call and workflow_dispatch both require tag + version,
|
||||
# so these should always be present when this job runs.
|
||||
tag="${INPUT_TAG}"
|
||||
version="${INPUT_VERSION}"
|
||||
if [ -z "$tag" ] || [ -z "$version" ]; then
|
||||
echo "::error::install-sh-smoke requires both tag and version inputs"
|
||||
exit 1
|
||||
fi
|
||||
repo="${INPUT_REPO:-$DEFAULT_REPO}"
|
||||
asset_source="${INPUT_ASSET_SOURCE:-published}"
|
||||
release_id="${INPUT_RELEASE_ID:-}"
|
||||
case "$asset_source" in
|
||||
published) ;;
|
||||
staged)
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "::error::release_id is required when asset_source=staged"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::asset_source must be staged or published, got: $asset_source"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
python3 scripts/write_github_output.py tag "$tag"
|
||||
python3 scripts/write_github_output.py version "$version"
|
||||
python3 scripts/write_github_output.py repo "$repo"
|
||||
python3 scripts/write_github_output.py asset_source "$asset_source"
|
||||
python3 scripts/write_github_output.py release_id "$release_id"
|
||||
echo "Resolved: tag=$tag version=$version repo=$repo asset_source=$asset_source release_id=${release_id:-none}"
|
||||
|
||||
- name: Download install.sh + sshsig + linux-amd64 tarball
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
REPO: ${{ steps.inputs.outputs.repo }}
|
||||
ASSET_SOURCE: ${{ steps.inputs.outputs.asset_source }}
|
||||
RELEASE_ID: ${{ steps.inputs.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p smoke-workspace
|
||||
cd smoke-workspace
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
assets=(install.sh install.sh.sshsig "${tarball}" "${tarball}.sshsig")
|
||||
|
||||
if [ "$ASSET_SOURCE" = "staged" ]; then
|
||||
assets_json=$(mktemp)
|
||||
gh api --paginate "repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
| jq -s 'add' > "$assets_json"
|
||||
|
||||
release_state=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \
|
||||
--jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv')
|
||||
actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state")
|
||||
is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state")
|
||||
published_at=$(awk -F '\t' '{print $3}' <<<"$release_state")
|
||||
if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then
|
||||
echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for asset_name in "${assets[@]}"; do
|
||||
asset_id=$(jq -r --arg name "$asset_name" \
|
||||
'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \
|
||||
"$assets_json")
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset."
|
||||
exit 1
|
||||
fi
|
||||
gh api \
|
||||
-H 'Accept: application/octet-stream' \
|
||||
"repos/${REPO}/releases/assets/${asset_id}" > "$asset_name"
|
||||
done
|
||||
rm -f "$assets_json"
|
||||
else
|
||||
base="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
echo "Pulling from ${base}/"
|
||||
for asset_name in "${assets[@]}"; do
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o "$asset_name" "${base}/${asset_name}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Downloaded:"
|
||||
ls -la
|
||||
|
||||
- name: Verify install.sh signature with README's pinned key
|
||||
env:
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
readme_key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
|
||||
if [ -z "$readme_key" ]; then
|
||||
echo "::error::Could not extract pulse-installer key from README.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "README pins: $readme_key"
|
||||
|
||||
allowed_signers=$(mktemp)
|
||||
printf 'pulse-installer %s\n' "$readme_key" > "$allowed_signers"
|
||||
|
||||
cd smoke-workspace
|
||||
if ! ssh-keygen -Y verify \
|
||||
-f "$allowed_signers" \
|
||||
-I pulse-installer \
|
||||
-n pulse-install \
|
||||
-s install.sh.sshsig < install.sh; then
|
||||
echo "::error::Published install.sh.sshsig does not verify against the README's pinned key."
|
||||
echo "::error::Either README.md is pinning the wrong key or the pipeline signed with a different key."
|
||||
rm -f "$allowed_signers"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$allowed_signers"
|
||||
echo "✓ install.sh signature verifies against README's pinned key"
|
||||
|
||||
- name: Assert install.sh is the Pulse server installer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd smoke-workspace
|
||||
if ! grep -qE '^# Pulse Installer Script' install.sh; then
|
||||
echo "::error::install.sh banner is not the Pulse server installer"
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Pulse Unified Agent Installer' install.sh; then
|
||||
echo "::error::install.sh is the agent installer, not the server installer"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE '^[[:space:]]*--version\)' install.sh; then
|
||||
echo "::error::install.sh does not handle --version"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ install.sh is the server installer with --version support"
|
||||
|
||||
- name: Run install.sh end-to-end in a privileged systemd container
|
||||
env:
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
container_name="pulse-install-smoke-$$"
|
||||
|
||||
# Cleanup on exit no matter what.
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# jrei/systemd-debian:12 is a community systemd-in-Docker image used
|
||||
# for Ansible / Molecule testing — small, no Pulse-specific assumptions.
|
||||
# Keep the privileged test environment bound to an immutable index.
|
||||
# A floating image here would let a registry retag replace code in a
|
||||
# release-gating job that needs contents:write to read draft assets.
|
||||
# GHA ubuntu-24.04 runners use cgroup v2 unified hierarchy; without
|
||||
# --cgroupns=host the container gets its own cgroup namespace and
|
||||
# systemd PID 1 exits before it can mount the cgroup tree, causing
|
||||
# the container to disappear mid-boot. /run/lock must also be tmpfs
|
||||
# for systemd-tmpfiles. We drop --rm so a failed boot leaves logs
|
||||
# behind for diagnosis; the trap removes the container on exit.
|
||||
docker run -d \
|
||||
--name "${container_name}" \
|
||||
--privileged \
|
||||
--cgroupns=host \
|
||||
--tmpfs /tmp --tmpfs /run --tmpfs /run/lock \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
-v "$(pwd)/smoke-workspace:/smoke" \
|
||||
-p 7655:7655 \
|
||||
jrei/systemd-debian:12@sha256:61d70dc3e574337bd9df794674a60ae73113460fff16ab41a2d234b4a11dcd98
|
||||
|
||||
echo "Waiting for systemd to be ready inside the container..."
|
||||
for i in $(seq 1 30); do
|
||||
if ! docker inspect -f '{{.State.Running}}' "${container_name}" 2>/dev/null | grep -q true; then
|
||||
echo "::error::Container ${container_name} is no longer running."
|
||||
docker inspect -f 'ExitCode={{.State.ExitCode}} Error={{.State.Error}}' "${container_name}" || true
|
||||
docker logs "${container_name}" || true
|
||||
exit 1
|
||||
fi
|
||||
if docker exec "${container_name}" systemctl is-system-running --wait 2>/dev/null | grep -qE '^(running|degraded)$'; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
docker logs "${container_name}" || true
|
||||
docker exec "${container_name}" systemctl --no-pager status || true
|
||||
docker exec "${container_name}" journalctl --no-pager --lines=120 || true
|
||||
echo "::error::systemd did not become ready inside the container"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✓ systemd is up"
|
||||
|
||||
echo "Installing prerequisites inside container..."
|
||||
docker exec "${container_name}" bash -lc 'apt-get update -qq && apt-get install -y -qq curl ca-certificates jq sudo'
|
||||
|
||||
echo "Running install.sh --archive against the published tarball..."
|
||||
# docker exec without -t leaves stdin without a TTY, which install.sh's
|
||||
# safe_read helper detects and falls through to defaults on every prompt.
|
||||
# PULSE_INSTALL_ALLOW_DOCKER=1 opts the smoke harness past install.sh's
|
||||
# Docker-environment refusal — install.sh treats the test container as
|
||||
# a normal systemd host, which is the exact contract this gate exists
|
||||
# to validate.
|
||||
docker exec -e PULSE_INSTALL_ALLOW_DOCKER=1 "${container_name}" \
|
||||
bash -lc "cd /smoke && PULSE_INSTALL_ALLOW_DOCKER=1 bash install.sh --archive /smoke/${tarball} --disable-auto-updates"
|
||||
|
||||
echo "Waiting for pulse.service to become active..."
|
||||
for i in $(seq 1 60); do
|
||||
state=$(docker exec "${container_name}" systemctl is-active pulse 2>/dev/null || true)
|
||||
if [ "$state" = "active" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
docker exec "${container_name}" systemctl status pulse --no-pager || true
|
||||
docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true
|
||||
echo "::error::pulse.service did not become active within 2 minutes"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✓ pulse.service is active"
|
||||
|
||||
echo "Hitting /api/health (curl --retry handles the poll loop)..."
|
||||
if ! docker exec "${container_name}" curl -fsS --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors http://127.0.0.1:7655/api/health >/dev/null; then
|
||||
docker exec "${container_name}" systemctl status pulse --no-pager || true
|
||||
docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true
|
||||
echo "::error::/api/health did not respond within 60 seconds of service activation"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ /api/health responded 200"
|
||||
|
||||
echo "Confirming installed version matches ${VERSION} via /api/version..."
|
||||
# /api/health intentionally does not include version; /api/version is
|
||||
# the authoritative endpoint and is one of the canonical post-upgrade
|
||||
# checks documented in docs/UPGRADE_v6.md.
|
||||
version_payload=$(docker exec "${container_name}" curl -fsS http://127.0.0.1:7655/api/version)
|
||||
echo "Version payload: ${version_payload}"
|
||||
installed_version=$(echo "${version_payload}" | jq -r '.version // empty')
|
||||
if [ -z "${installed_version}" ]; then
|
||||
echo "::error::/api/version did not include a version field"
|
||||
exit 1
|
||||
fi
|
||||
# Normalize: VERSION input is "6.0.0-rc.6", installed_version may be "v6.0.0-rc.6".
|
||||
installed_version="${installed_version#v}"
|
||||
if [ "${installed_version}" != "${VERSION}" ]; then
|
||||
echo "::error::Installed version mismatch. Expected ${VERSION}, got ${installed_version}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Installed version matches ${VERSION}"
|
||||
|
||||
- name: Smoke result
|
||||
env:
|
||||
WORKFLOW_OUTPUT_1: ${{ steps.inputs.outputs.tag }}
|
||||
run: |
|
||||
echo "::notice::install.sh smoke passed for tag ${WORKFLOW_OUTPUT_1}"
|
||||
uses: ./.github/workflows/install-sh-smoke-body.yml
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
version: ${{ inputs.version }}
|
||||
repository: ${{ inputs.repository }}
|
||||
asset_source: ${{ inputs.asset_source }}
|
||||
release_id: ${{ inputs.release_id }}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
needs: resolve
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/install-sh-smoke.yml
|
||||
uses: ./.github/workflows/install-sh-smoke-body.yml
|
||||
with:
|
||||
tag: ${{ needs.resolve.outputs.tag }}
|
||||
version: ${{ needs.resolve.outputs.version }}
|
||||
|
|
|
|||
|
|
@ -3048,12 +3048,11 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) {
|
|||
// cannot return.
|
||||
|
||||
func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
||||
workflowPath := repoFile(".github", "workflows", "install-sh-smoke.yml")
|
||||
workflowPath := repoFile(".github", "workflows", "install-sh-smoke-body.yml")
|
||||
assertFileContainsAll(t, workflowPath,
|
||||
// Inputs and triggers.
|
||||
`name: install.sh Smoke (Release Assets)`,
|
||||
`name: install.sh Smoke Body (Caller Permissions)`,
|
||||
`workflow_call:`,
|
||||
`workflow_dispatch:`,
|
||||
`asset_source:`,
|
||||
`release_id:`,
|
||||
// Staged cuts use authenticated draft assets; manual verification can
|
||||
|
|
@ -3087,12 +3086,18 @@ func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
|||
|
||||
workflowBytes, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read install-sh-smoke workflow: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
smokeJob := workflowJobBlock(t, string(workflowBytes), "smoke")
|
||||
if !strings.Contains(smokeJob, "contents: write") {
|
||||
t.Fatal("install-sh-smoke.yml smoke job must grant contents: write to read unpublished draft release assets")
|
||||
if strings.Contains(string(workflowBytes), "permissions:") {
|
||||
t.Fatal("shared smoke body must inherit its caller budget, not request elevated permissions")
|
||||
}
|
||||
assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"),
|
||||
`workflow_dispatch:`,
|
||||
`workflow_call:`,
|
||||
`contents: write`,
|
||||
`uses: ./.github/workflows/install-sh-smoke-body.yml`,
|
||||
`release_id: ${{ inputs.release_id }}`,
|
||||
)
|
||||
}
|
||||
|
||||
func TestStableInstallContinuityReinstallsLatestReleaseReadOnly(t *testing.T) {
|
||||
|
|
@ -3106,7 +3111,7 @@ func TestStableInstallContinuityReinstallsLatestReleaseReadOnly(t *testing.T) {
|
|||
`"repos/${REPOSITORY}/releases/latest"`,
|
||||
`scripts/release_control/release_continuity.py release`,
|
||||
`version=${tag#v}`,
|
||||
`uses: ./.github/workflows/install-sh-smoke.yml`,
|
||||
`uses: ./.github/workflows/install-sh-smoke-body.yml`,
|
||||
`tag: ${{ needs.resolve.outputs.tag }}`,
|
||||
`version: ${{ needs.resolve.outputs.version }}`,
|
||||
`asset_source: published`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue