Files
pulse-triage[bot] ad1cfd33c3 fix(ci): qualify grouped release action pin consumers
The grouped action upgrade leaves signing, network and publication consumer assertions on superseded pins. Align those contracts and check every consumer against reviewed immutable upstream manifests, retaining exact dispatch and release trust boundaries without claiming hosted execution.

Change-source: pulse-maintainer
2026-09-10 00:35:47 +01:00

315 lines
15 KiB
YAML

name: Release Helm Chart to GitHub Pages
run-name: Release Helm Chart ${{ inputs.chart_version }}
# Called only from release-convergence.yml while it holds the global customer-
# promotion lease. Chart lint, packaging, OCI publication, and the exact-
# candidate install/upgrade smoke complete before activation. This workflow
# recovers the digest-bound, attested OCI package and promotes the same package
# after activation. Actions artifacts are deliberately not a recovery boundary:
# they expire before an operator-owned convergence block necessarily clears.
on:
workflow_call:
inputs:
chart_version:
description: "Exact chart version (e.g., 6.3.0-rc.5)."
required: true
type: string
source_release_run_id:
description: "Exact create-release.yml run containing the qualified chart."
required: true
type: string
target_commitish:
description: "Exact activated release source commit."
required: true
type: string
chart_digest:
description: "OCI chart digest committed by release activation."
required: true
type: string
permissions:
actions: read
contents: write
packages: read
jobs:
release:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout release verification control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Require activated GitHub release and source run
id: identity
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.chart_version }}
SOURCE_RELEASE_RUN_ID: ${{ inputs.source_release_run_id }}
TARGET_COMMITISH: ${{ inputs.target_commitish }}
CHART_DIGEST: ${{ inputs.chart_digest }}
run: |
set -euo pipefail
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]] || \
[[ ! "${SOURCE_RELEASE_RUN_ID}" =~ ^[0-9]+$ ]] || \
[[ ! "${TARGET_COMMITISH}" =~ ^[0-9a-f]{40}$ ]] || \
[[ ! "${CHART_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Helm Pages requires an exact version, source run ID, commit SHA, and chart digest."
exit 1
fi
release_tag="v${VERSION}"
release_state="$(
gh api "repos/${{ github.repository }}/releases/tags/${release_tag}" \
--jq '[.tag_name, .target_commitish, (.draft | tostring), (.published_at // "")] | @tsv'
)"
actual_tag="$(awk -F '\t' '{print $1}' <<<"${release_state}")"
actual_commit="$(awk -F '\t' '{print $2}' <<<"${release_state}")"
is_draft="$(awk -F '\t' '{print $3}' <<<"${release_state}")"
published_at="$(awk -F '\t' '{print $4}' <<<"${release_state}")"
if [[ "${actual_tag}" != "${release_tag}" ]] || \
[[ "${actual_commit}" != "${TARGET_COMMITISH}" ]] || \
[[ "${is_draft}" != "false" ]] || \
[[ -z "${published_at}" ]]; then
echo "::error::Helm Pages refuses to advertise inactive or mismatched release ${release_tag}."
exit 1
fi
marker="$(mktemp)"
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
-o "${marker}" \
"https://github.com/${{ github.repository }}/releases/download/${release_tag}/release-activation.json"
jq -e \
--arg tag "${release_tag}" \
--arg target_commitish "${TARGET_COMMITISH}" \
--arg source_release_run_id "${SOURCE_RELEASE_RUN_ID}" \
--arg helm_chart_digest "${CHART_DIGEST}" \
'.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .source_release_run_id == $source_release_run_id and .helm_chart_digest == $helm_chart_digest' \
"${marker}" >/dev/null
rm -f "${marker}"
source_run="$(
gh api "repos/${{ github.repository }}/actions/runs/${SOURCE_RELEASE_RUN_ID}" \
--jq '[.path, .event, .head_sha] | @tsv'
)"
run_path="$(awk -F '\t' '{print $1}' <<<"${source_run}")"
run_event="$(awk -F '\t' '{print $2}' <<<"${source_run}")"
run_sha="$(awk -F '\t' '{print $3}' <<<"${source_run}")"
if [[ "${run_path}" != ".github/workflows/create-release.yml" ]] || \
[[ "${run_event}" != "workflow_dispatch" ]] || \
[[ "${run_sha}" != "${TARGET_COMMITISH}" ]]; then
echo "::error::Source release run ${SOURCE_RELEASE_RUN_ID} does not own the activated ${release_tag} chart."
exit 1
fi
validated_version="${VERSION}"
python3 scripts/write_github_output.py version "${validated_version}"
python3 scripts/write_github_output.py release_tag "${release_tag}"
- name: Set up Helm
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.15.2
- name: Recover immutable qualified chart
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.identity.outputs.version }}
CHART_PATH: dist/pulse-${{ steps.identity.outputs.version }}.tgz
RELEASE_TAG: ${{ steps.identity.outputs.release_tag }}
TARGET_COMMITISH: ${{ inputs.target_commitish }}
CHART_DIGEST: ${{ inputs.chart_digest }}
run: |
set -euo pipefail
mkdir -p dist
proof="$(./scripts/verify-release-helm-chart.sh \
"${RELEASE_TAG}" "${TARGET_COMMITISH}" \
"${GITHUB_REPOSITORY}" "${CHART_DIGEST}" "${CHART_PATH}")"
if [ "${proof}" != "chart_digest=${CHART_DIGEST}" ]; then
echo "::error::Activated Helm OCI provenance did not produce the committed digest."
exit 1
fi
- name: Verify recovered chart metadata
env:
VERSION: ${{ steps.identity.outputs.version }}
CHART_PATH: dist/pulse-${{ steps.identity.outputs.version }}.tgz
run: |
set -euo pipefail
python3 - <<'PY'
import os
import pathlib
import re
import tarfile
chart_path = pathlib.Path(os.environ["CHART_PATH"])
version = os.environ["VERSION"]
if not chart_path.is_file():
raise SystemExit(f"missing immutable qualified chart: {chart_path}")
with tarfile.open(chart_path, "r:gz") as archive:
for member in archive.getmembers():
parts = pathlib.PurePosixPath(member.name).parts
if member.name.startswith("/") or any(part in {"", ".", ".."} for part in parts):
raise SystemExit(f"unsafe chart member: {member.name}")
stream = archive.extractfile(archive.getmember("pulse/Chart.yaml"))
if stream is None:
raise SystemExit("qualified chart has no readable pulse/Chart.yaml")
metadata = stream.read().decode("utf-8")
def value(key: str) -> str:
match = re.search(rf"(?m)^{re.escape(key)}:\s*[\"']?([^\"'\s]+)", metadata)
return match.group(1) if match else ""
if value("name") != "pulse" or value("version") != version or value("appVersion") != version:
raise SystemExit("qualified chart metadata does not match the activated release")
PY
sha256sum "${CHART_PATH}"
helm show chart "${CHART_PATH}"
- name: Check out current Pages index
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: gh-pages
path: gh-pages
fetch-depth: 1
persist-credentials: true # required: authenticated git writes
- name: Publish chart release and merge Pages index
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.identity.outputs.version }}
TARGET_COMMITISH: ${{ inputs.target_commitish }}
run: |
set -euo pipefail
chart="pulse-${VERSION}.tgz"
chart_path="dist/${chart}"
chart_release="helm-chart-${VERSION}"
chart_release_url="https://github.com/${{ github.repository }}/releases/download/${chart_release}"
# A chart release is a prerelease exactly when its version is one;
# stable chart releases were being published as prereleases
# (helm-chart-6.4.0, 6.4.1 and every earlier stable chart), which
# the release steward reports as an incident. Never "latest": that
# pointer belongs to the Pulse release itself.
if [[ "${VERSION}" == *-* ]]; then
prerelease=true
else
prerelease=false
fi
local_digest="sha256:$(sha256sum "${chart_path}" | cut -d ' ' -f 1)"
if existing_state="$(gh release view "${chart_release}" --repo "${GITHUB_REPOSITORY}" \
--json isPrerelease,isDraft --jq '[.isPrerelease, .isDraft] | @tsv' 2>/dev/null)"; then
existing_prerelease="$(awk -F '\t' '{print $1}' <<<"${existing_state}")"
existing_draft="$(awk -F '\t' '{print $2}' <<<"${existing_state}")"
# A draft asset is visible to this authenticated workflow but not
# to Helm consumers. Do not turn an interrupted publication into
# a public index entry, or implicitly publish an operator's draft.
if [[ "${existing_draft}" != "false" ]]; then
echo "::error::Chart release ${chart_release} is not confirmed published; refusing to modify it or advertise it through Pages."
exit 1
fi
# Immutable releases refuse asset replacement, so a convergence
# retry must recognise the exact chart it already published
# rather than clobber it (v6.4.3-rc.1 retry, 2026-09-02). The
# asset digest is only on the REST payload.
existing_digest="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${chart_release}" \
--jq "[.assets[] | select(.name == \"${chart}\")] | first | .digest // \"\"")"
if [[ -z "${existing_digest}" ]]; then
gh release upload "${chart_release}" "${chart_path}" \
--repo "${GITHUB_REPOSITORY}"
elif [[ "${existing_digest}" == "${local_digest}" ]]; then
echo "Chart release ${chart_release} already holds ${chart} at ${local_digest}."
else
echo "::error::Chart release ${chart_release} holds ${chart} at ${existing_digest}, not the qualified ${local_digest}; refusing to replace a published chart."
exit 1
fi
if [[ "${existing_prerelease}" != "${prerelease}" ]]; then
gh release edit "${chart_release}" --prerelease="${prerelease}" --latest=false \
--repo "${GITHUB_REPOSITORY}"
fi
else
gh release create "${chart_release}" "${chart_path}" \
--repo "${GITHUB_REPOSITORY}" \
--target "${TARGET_COMMITISH}" \
--title "Helm chart ${VERSION}" \
--notes "Helm chart for Pulse ${VERSION}." \
--prerelease="${prerelease}" \
--latest=false
fi
git -C gh-pages config user.name "$GITHUB_ACTOR"
git -C gh-pages config user.email "$GITHUB_ACTOR@users.noreply.github.com"
index_work="$(mktemp -d)"
cleanup() { rm -rf "${index_work}"; }
trap cleanup EXIT
cp "${chart_path}" "${index_work}/${chart}"
if [[ -f gh-pages/index.yaml ]]; then
cp gh-pages/index.yaml "${index_work}/index.yaml"
helm repo index "${index_work}" \
--url "${chart_release_url}" \
--merge "${index_work}/index.yaml"
else
helm repo index "${index_work}" --url "${chart_release_url}"
fi
cp "${index_work}/index.yaml" gh-pages/index.yaml
if ! grep -q "version: ${VERSION}" gh-pages/index.yaml; then
echo "::error::Helm Pages index is missing version ${VERSION}."
exit 1
fi
git -C gh-pages add index.yaml
if git -C gh-pages diff --cached --quiet; then
echo "Helm Pages index already contains ${VERSION}."
else
git -C gh-pages commit -m "Update Helm chart index for ${VERSION}"
git -C gh-pages push origin HEAD:gh-pages
fi
- name: Verify public Pages chart
env:
VERSION: ${{ steps.identity.outputs.version }}
run: |
set -euo pipefail
public_repo="https://rcourtman.github.io/Pulse"
public_ready=false
public_work="$(mktemp -d)"
trap 'rm -rf "${public_work}"' EXIT
qualified_chart="dist/pulse-${VERSION}.tgz"
test -f "${qualified_chart}"
for attempt in $(seq 1 30); do
public_index="$(mktemp)"
if curl -fsSL --retry 2 --retry-delay 1 --retry-all-errors \
-o "${public_index}" "${public_repo}/index.yaml" && \
grep -q "version: ${VERSION}" "${public_index}"; then
helm repo remove pulse-public >/dev/null 2>&1 || true
helm repo add pulse-public "${public_repo}" --force-update
helm repo update pulse-public
# Metadata readability is not an exact-artifact receipt. Pull via
# the consumer index and compare with the OCI-qualified package,
# not the OCI manifest digest (which hashes a different object).
rm -f "${public_work}/pulse-${VERSION}.tgz"
if helm pull pulse-public/pulse --version "${VERSION}" --destination "${public_work}" && \
helm show chart "${public_work}/pulse-${VERSION}.tgz" >/dev/null; then
if ! cmp -s "${qualified_chart}" "${public_work}/pulse-${VERSION}.tgz"; then
echo "::error::Public Helm chart bytes differ from the qualified package; refusing a successful convergence receipt."
rm -f "${public_index}"
exit 1
fi
public_ready=true
rm -f "${public_index}"
break
fi
fi
rm -f "${public_index}"
echo "Public Helm repository has not exposed ${VERSION} yet (${attempt}/30)."
sleep 2
done
if [[ "${public_ready}" != "true" ]]; then
echo "::error::Public Helm repository did not expose chart ${VERSION}."
exit 1
fi
echo "[OK] Public Helm repository serves the exact qualified pulse ${VERSION} package."