mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
ad1cfd33c3
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
315 lines
15 KiB
YAML
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."
|