EPIC-6 Slice C-DB-1+C-DB-2. Active-hotstandby CNPG cluster-pair as a
companion to bp-cnpg: primary CNPG Cluster CR in region A, replica
Cluster CR in region B configured as a CNPG replica cluster
(replica.enabled=true + externalCluster), WAL streaming over a
Cilium ClusterMesh-shared Service. Per ADR-0001 §9 ClusterMesh is the
only canonical inter-region transport — never public TLS.
What ships:
platform/cnpg-pair/
├── chart/
│ ├── Chart.yaml # bp-cnpg-pair 0.1.0; no-upstream + smoke-render-mode=default-off
│ ├── values.yaml # default-OFF gate; placement schema constrains active-hotstandby ONLY
│ ├── templates/
│ │ ├── _helpers.tpl # fail-fast on empty image.tag; region pair validation
│ │ ├── primary-cluster.yaml # CNPG Cluster CR (region-pinned via openova.io/region affinity)
│ │ ├── replica-cluster.yaml # CNPG Cluster CR (replica.enabled=true; externalClusters[])
│ │ ├── service-replication.yaml # Cilium ClusterMesh global Service
│ │ ├── failover-readiness.yaml # probe Pod flips Ready when WAL lag < threshold
│ │ ├── networkpolicy.yaml # default-deny carve-outs for replication + probe
│ │ └── audit-config.yaml # NATS audit subjects + types this Blueprint emits
│ ├── blueprint.yaml # configSchema + placementSchema (active-hotstandby ONLY)
│ ├── README.md # 80-line deployment + failover semantics
│ └── tests/cnpg-pair-render.sh # 5-case render gate
└── DESIGN.md # topology, lag-threshold rationale, deferred C-DB-3 plan
Default-OFF gate per the brief: helm template with default values
renders ZERO resources; helm template with cnpgPair.enabled=true +
both regions + image.tag renders 8 resources (2 Cluster CRs, 1
Service, 1 Deployment, 3 NetworkPolicies, 1 audit-config ConfigMap).
Empty image.tag fails fast at template-render per Inviolable
Principle #4a; same primary/replica region fails fast (degenerate
pair). All 5 render gates pass locally; helm lint + YAML parse clean.
CI smoke-render gate fix (single-line behavior change in
blueprint-release.yaml): adds a `catalyst.openova.io/smoke-render-
mode: default-off` annotation opt-in so charts that legitimately
render zero at default values (this chart + future bp-*-pair
Blueprints) skip the `<5 lines` empty-render check. The chart's own
tests/cnpg-pair-render.sh covers the enabled-render path; without
the annotation the empty-render check still fires unchanged.
Seam-map additions (return diff for 01-canonical-seams.md Platform
table):
- service.cilium.io/global=true ClusterMesh global Service annotation
(first chart in the repo to use it; pattern reused by Continuum
K-Cont-2 for HTTPRoute weight=0 cross-region drains)
- bp-*-pair active-hotstandby cluster-pair pattern (primary+replica
Cluster CRs colocated in one Blueprint, region-pinned via
openova.io/region node-affinity)
- audit-config ConfigMap co-located with the emitting Blueprint
(label-selector discovery for K-Cont-2 + U-DR-1; future
bp-*-pair Blueprints follow this convention)
- smoke-render-mode=default-off Chart.yaml annotation opt-in for
the blueprint-release smoke gate
C-DB-2 (publish): existing blueprint-release.yaml workflow auto-
detects `platform/*/chart/**` paths — no allowlist edit required.
First push triggers `ghcr.io/openova-io/bp-cnpg-pair:0.1.0` build.
C-DB-3 (1M-row acceptance test) DEFERRED — full plan documented in
DESIGN.md "Deferred — C-DB-3 acceptance test plan" section so the
future implementer's brief is self-contained.
Tests:
- bash platform/cnpg-pair/chart/tests/cnpg-pair-render.sh ✓ 5/5 PASS
- helm lint platform/cnpg-pair/chart ✓ clean
- helm template ... | python3 yaml.safe_load_all ✓ 8 docs parse clean
- smoke-gate logic simulated locally ✓ default-off annotation honored
Pre-existing CI failures untouched:
- TestPinIssue rate-limit flake — not affected by chart-only slice
- TestBootstrapKit/gitea version drift — only iterates over a fixed
10-chart bootstrap list (no cnpg-pair entry)
Out of scope per brief (all deferred to dedicated slices):
- K-Cont-2 reconciler logic
- K-Cont-3 lease witness
- K-Cont-4 Cloudflare Worker
- C-DB-3 1M-row acceptance test
- Application controller changes
- U-DR-1 UI
Co-authored-by: hatiyildiz <hati.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
512 lines
26 KiB
YAML
512 lines
26 KiB
YAML
# Blueprint release — path-matrix CI fan-out per BLUEPRINT-AUTHORING.md §11.
|
|
#
|
|
# Pushes to `main` that touch any platform/<name>/chart/** or
|
|
# products/<name>/chart/** directory trigger a build of that single Blueprint
|
|
# folder; output is a signed OCI artifact at
|
|
# ghcr.io/openova-io/bp-<name>:<semver> where <semver> comes from the
|
|
# Chart.yaml `version` field.
|
|
#
|
|
# Tag-based releases (refs/tags/<name>/v<X.Y.Z>) trigger the same build with
|
|
# version pinned to the tag, plus cosign signing + Syft SBOM generation.
|
|
#
|
|
# This workflow is the canonical CI for all 12 bootstrap-kit charts (the
|
|
# original 11 plus bp-powerdns added at #167) plus every other Catalyst-
|
|
# curated wrapper chart. Application-Blueprint charts (cnpg, valkey, etc.)
|
|
# follow the same shape once authored.
|
|
#
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Hollow-chart guard (issue #181)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Every umbrella Blueprint chart MUST declare its upstream chart(s) under
|
|
# `dependencies:` in Chart.yaml so `helm dependency build` pulls the
|
|
# upstream payload into the OCI artifact at publish time. Earlier in this
|
|
# cycle, bp-cert-manager:1.0.0 shipped as a "hollow chart" — only an
|
|
# overlay (ClusterIssuer template) with no upstream cert-manager subchart
|
|
# bytes — and Phase 1 broke on every Sovereign because cert-manager
|
|
# itself was never installed. See docs/BLUEPRINT-AUTHORING.md
|
|
# §"Umbrella shape".
|
|
#
|
|
# This workflow now structurally verifies the upstream payload is present
|
|
# at every stage of the publish pipeline:
|
|
#
|
|
# 1. After `helm dependency build` — assert the working-tree
|
|
# chart/charts/<dep>-<ver>.tgz (or unpacked chart/charts/<dep>/) exists
|
|
# for every entry in Chart.yaml `dependencies:`.
|
|
# 2. After `helm package` — `tar -tzf` the produced .tgz and assert
|
|
# every declared dep is inside the package's charts/ directory.
|
|
# 3. After `helm push` — `helm pull` the artifact back from GHCR and
|
|
# re-verify the deps survived the round-trip.
|
|
# 4. `helm template` smoke render — chart MUST render with default
|
|
# values; rendered manifests are uploaded as a workflow artifact for
|
|
# forensics.
|
|
#
|
|
# Any one of those steps failing fails the whole build — hollow charts
|
|
# can never reach a Sovereign.
|
|
|
|
name: Blueprint Release
|
|
|
|
on:
|
|
push:
|
|
paths:
|
|
- 'platform/*/chart/**'
|
|
- 'products/*/chart/**'
|
|
- 'platform/*/blueprint.yaml'
|
|
- 'products/*/blueprint.yaml'
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
blueprint:
|
|
description: 'Blueprint name (e.g. cilium, openbao, catalyst). Empty = build all changed.'
|
|
required: false
|
|
type: string
|
|
tree:
|
|
description: 'Source tree: platform (leaf) or products (umbrella).'
|
|
required: false
|
|
type: choice
|
|
default: platform
|
|
options:
|
|
- platform
|
|
- products
|
|
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write # for cosign keyless signing
|
|
|
|
jobs:
|
|
detect:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
matrix: ${{ steps.changed.outputs.matrix }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 2
|
|
|
|
- name: Detect changed Blueprints
|
|
id: changed
|
|
run: |
|
|
set -e
|
|
if [ -n "${{ inputs.blueprint }}" ]; then
|
|
tree="${{ inputs.tree }}"
|
|
tree="${tree:-platform}"
|
|
echo "matrix={\"include\":[{\"path\":\"${tree}/${{ inputs.blueprint }}\"}]}" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# Compare against previous commit; emit a matrix of changed paths.
|
|
changed=$(git diff --name-only HEAD~1 HEAD | grep -E '^(platform|products)/[^/]+/(chart/|blueprint\.yaml)' | awk -F/ '{print $1"/"$2}' | sort -u)
|
|
echo "Changed blueprints:"
|
|
echo "$changed"
|
|
if [ -z "$changed" ]; then
|
|
echo "matrix={\"include\":[]}" >> "$GITHUB_OUTPUT"
|
|
else
|
|
include=$(echo "$changed" | jq -R -s -c 'split("\n") | map(select(length > 0)) | map({path: .})')
|
|
echo "matrix={\"include\":$include}" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
build:
|
|
needs: detect
|
|
if: ${{ needs.detect.outputs.matrix != '{"include":[]}' }}
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Helm
|
|
uses: azure/setup-helm@v4
|
|
with:
|
|
version: v3.18.4
|
|
|
|
- name: Install Cosign
|
|
uses: sigstore/cosign-installer@v3
|
|
with:
|
|
cosign-release: v2.4.1
|
|
|
|
- name: Install Syft
|
|
uses: anchore/sbom-action/download-syft@v0
|
|
|
|
- name: Install yq (declared-deps parser)
|
|
run: |
|
|
# We need a streaming YAML parser to read Chart.yaml's dependencies:
|
|
# block reliably; awk/grep on YAML is fragile and would let a
|
|
# subtly malformed Chart.yaml slip past the hollow-chart guard.
|
|
sudo wget -qO /usr/local/bin/yq \
|
|
https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
|
|
sudo chmod +x /usr/local/bin/yq
|
|
yq --version
|
|
|
|
- name: Login to GHCR
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Read chart metadata
|
|
id: chart
|
|
run: |
|
|
set -e
|
|
path="${{ matrix.path }}"
|
|
chart_yaml="$path/chart/Chart.yaml"
|
|
if [ ! -f "$chart_yaml" ]; then
|
|
echo "No chart at $path/chart/ — skipping"
|
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# Authoritative chart name comes from Chart.yaml `name:` field
|
|
# (e.g. `bp-cilium`, `bp-catalyst-platform`). Folder basename and
|
|
# chart name are not always identical — the umbrella at
|
|
# products/catalyst/chart packages as bp-catalyst-platform.
|
|
chart_name=$(awk '/^name:/{print $2; exit}' "$chart_yaml" | tr -d '"')
|
|
version=$(awk '/^version:/{print $2; exit}' "$chart_yaml" | tr -d '"')
|
|
echo "name=$chart_name" >> "$GITHUB_OUTPUT"
|
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Helm registry login (for OCI dependencies)
|
|
if: steps.chart.outputs.skip != 'true'
|
|
run: |
|
|
# `helm dependency build` resolves `oci://ghcr.io/openova-io/bp-*`
|
|
# dependencies — needs an authenticated helm registry login,
|
|
# not just a docker login.
|
|
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
|
|
--username "${{ github.actor }}" --password-stdin
|
|
|
|
- name: Helm dependency build
|
|
if: steps.chart.outputs.skip != 'true'
|
|
run: helm dependency build "${{ matrix.path }}/chart"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GUARD 1 — hollow-chart check on the working tree
|
|
# ──────────────────────────────────────────────────────────────
|
|
# After `helm dependency build`, every entry in Chart.yaml's
|
|
# `dependencies:` block MUST have a corresponding artifact under
|
|
# chart/charts/. Helm accepts either form:
|
|
# chart/charts/<dep-name>-<dep-version>.tgz (packed)
|
|
# chart/charts/<dep-name>/Chart.yaml (unpacked vendored)
|
|
# Either is fine; ZERO of them is a hollow chart and we fail loudly.
|
|
- name: "Verify upstream subcharts present in working tree (post-dependency-build)"
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: verify_workdir
|
|
run: |
|
|
set -euo pipefail
|
|
chart_dir="${{ matrix.path }}/chart"
|
|
chart_yaml="$chart_dir/Chart.yaml"
|
|
# Some Blueprints legitimately ship NO upstream subchart — their
|
|
# entire payload is Catalyst-authored CRs (e.g.
|
|
# bp-crossplane-claims, which carries only XRDs + Compositions
|
|
# for the compose.openova.io/v1alpha1 family). Those charts opt
|
|
# out of the dependencies-required rule by setting the
|
|
# annotation:
|
|
# annotations:
|
|
# catalyst.openova.io/no-upstream: "true"
|
|
# in their Chart.yaml. The hollow-chart rule remains in force
|
|
# for every other umbrella chart — the original failure
|
|
# (bp-cert-manager:1.0.0 silently shipping without cert-manager)
|
|
# cannot recur unless an author explicitly adds the annotation.
|
|
no_upstream=$(yq '.annotations["catalyst.openova.io/no-upstream"] // ""' "$chart_yaml")
|
|
dep_count=$(yq '.dependencies | length // 0' "$chart_yaml")
|
|
echo "Declared dependencies: $dep_count"
|
|
echo "no-upstream annotation: '$no_upstream'"
|
|
if [ "$dep_count" -eq 0 ]; then
|
|
if [ "$no_upstream" = "true" ]; then
|
|
echo "Chart marked catalyst.openova.io/no-upstream=true — skipping upstream-subchart presence check."
|
|
exit 0
|
|
fi
|
|
echo "::error title=Hollow chart::Chart $chart_yaml declares NO dependencies. Every Blueprint umbrella chart at platform/<name>/chart/ MUST declare its upstream chart under \`dependencies:\` per docs/BLUEPRINT-AUTHORING.md §11.1 Umbrella shape. See issue #181. (To opt out for charts that legitimately ship only Catalyst-authored CRs, set annotations.catalyst.openova.io/no-upstream: \"true\".)"
|
|
exit 1
|
|
fi
|
|
missing=0
|
|
for i in $(seq 0 $((dep_count - 1))); do
|
|
dep_name=$(yq ".dependencies[$i].name" "$chart_yaml")
|
|
dep_ver=$(yq ".dependencies[$i].version" "$chart_yaml")
|
|
dep_repo=$(yq ".dependencies[$i].repository" "$chart_yaml")
|
|
packed="$chart_dir/charts/${dep_name}-${dep_ver}.tgz"
|
|
unpacked="$chart_dir/charts/${dep_name}/Chart.yaml"
|
|
if [ -f "$packed" ]; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $packed"
|
|
elif [ -f "$unpacked" ]; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $unpacked (unpacked)"
|
|
else
|
|
echo "::error title=Subchart not pulled::Declared dependency '${dep_name}' (version='${dep_ver}', repo='${dep_repo}') was not pulled by \`helm dependency build\`. Expected $packed OR $unpacked. Investigate the dependency repository / Chart.lock; do NOT publish a hollow chart."
|
|
missing=$((missing + 1))
|
|
fi
|
|
done
|
|
if [ "$missing" -gt 0 ]; then
|
|
exit 1
|
|
fi
|
|
echo "All $dep_count declared upstream subcharts present in working tree."
|
|
|
|
- name: Helm package
|
|
if: steps.chart.outputs.skip != 'true'
|
|
run: |
|
|
mkdir -p /tmp/charts
|
|
helm package "${{ matrix.path }}/chart" --destination /tmp/charts
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GUARD 2 — hollow-chart check inside the packaged tgz
|
|
# ──────────────────────────────────────────────────────────────
|
|
# `helm package` builds the OCI payload from the working tree, but
|
|
# we don't trust transitive bugs / .helmignore mishaps to leave
|
|
# chart/charts/ behind. Crack the produced .tgz and assert every
|
|
# declared dep is inside <chart_name>/charts/ in the package.
|
|
- name: "Verify upstream subcharts present in packaged tgz (post-helm-package)"
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: verify_package
|
|
run: |
|
|
set -euo pipefail
|
|
chart_yaml="${{ matrix.path }}/chart/Chart.yaml"
|
|
name="${{ steps.chart.outputs.name }}"
|
|
version="${{ steps.chart.outputs.version }}"
|
|
tgz="/tmp/charts/${name}-${version}.tgz"
|
|
if [ ! -f "$tgz" ]; then
|
|
echo "::error title=Package missing::Expected $tgz from helm package."
|
|
exit 1
|
|
fi
|
|
echo "Inspecting $tgz"
|
|
listing="/tmp/${name}-${version}.listing.txt"
|
|
tar -tzf "$tgz" > "$listing"
|
|
echo " total entries: $(wc -l < "$listing")"
|
|
dep_count=$(yq '.dependencies | length // 0' "$chart_yaml")
|
|
missing=0
|
|
for i in $(seq 0 $((dep_count - 1))); do
|
|
dep_name=$(yq ".dependencies[$i].name" "$chart_yaml")
|
|
dep_ver=$(yq ".dependencies[$i].version" "$chart_yaml")
|
|
packed_in_tgz="${name}/charts/${dep_name}-${dep_ver}.tgz"
|
|
unpacked_in_tgz="${name}/charts/${dep_name}/Chart.yaml"
|
|
if grep -qx "$packed_in_tgz" "$listing"; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $packed_in_tgz"
|
|
elif grep -qx "$unpacked_in_tgz" "$listing"; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $unpacked_in_tgz (unpacked)"
|
|
else
|
|
echo "::error title=Hollow package::Packaged tgz $tgz does NOT contain upstream subchart '${dep_name}' (version='${dep_ver}'). Refusing to publish a hollow chart."
|
|
missing=$((missing + 1))
|
|
fi
|
|
done
|
|
if [ "$missing" -gt 0 ]; then
|
|
echo "── package listing (first 200 lines) ──"
|
|
head -200 "$listing"
|
|
exit 1
|
|
fi
|
|
echo "All $dep_count declared upstream subcharts present inside packaged tgz."
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# SMOKE — `helm template` must render with default values
|
|
# ──────────────────────────────────────────────────────────────
|
|
# A chart can be structurally complete but render-broken (bad
|
|
# template, missing required value, schema violation). Render the
|
|
# packaged chart with defaults; on render failure the build dies
|
|
# and the rendered output (if any) ships as a workflow artifact
|
|
# for forensics.
|
|
#
|
|
# Empty-render rule: a working umbrella with an upstream subchart
|
|
# should produce many resources, so `<5 lines` is suspicious AND
|
|
# blocks publish. EXCEPTION: charts that are both `no-upstream:
|
|
# true` AND default-OFF (e.g. bp-cnpg-pair, products/continuum)
|
|
# legitimately render zero resources at default values — they
|
|
# ship a `cnpgPair.enabled: true` (or equivalent) flip-on path
|
|
# that overlays activate per-Sovereign. Those charts opt into the
|
|
# exception via the `catalyst.openova.io/smoke-render-mode:
|
|
# default-off` annotation; their unit-tests under chart/tests/*.sh
|
|
# cover the enabled-render path. Without the annotation the
|
|
# `<5 lines` rule still fires.
|
|
- name: "Helm template smoke render (default values)"
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: smoke
|
|
run: |
|
|
set -euo pipefail
|
|
name="${{ steps.chart.outputs.name }}"
|
|
version="${{ steps.chart.outputs.version }}"
|
|
chart_yaml="${{ matrix.path }}/chart/Chart.yaml"
|
|
tgz="/tmp/charts/${name}-${version}.tgz"
|
|
mkdir -p /tmp/render
|
|
render_out="/tmp/render/${name}-${version}.default.yaml"
|
|
# `helm template` against the packaged tgz exercises the same
|
|
# bytes Flux will install at runtime — including subchart
|
|
# rendering. A leaf umbrella with the upstream subchart
|
|
# present should produce upstream resources here.
|
|
if ! helm template "smoke-${name}" "$tgz" \
|
|
--namespace "smoke-${name}" \
|
|
> "$render_out" 2> /tmp/render/stderr.log; then
|
|
echo "::error title=helm template failed::Default-values render of $tgz failed; see uploaded artifact and stderr below."
|
|
cat /tmp/render/stderr.log >&2
|
|
exit 1
|
|
fi
|
|
lines=$(wc -l < "$render_out")
|
|
echo "Rendered $lines lines to $render_out"
|
|
smoke_mode=$(yq '.annotations["catalyst.openova.io/smoke-render-mode"] // ""' "$chart_yaml")
|
|
if [ "$lines" -lt 5 ]; then
|
|
if [ "$smoke_mode" = "default-off" ]; then
|
|
echo "Chart marked catalyst.openova.io/smoke-render-mode=default-off — short default render is expected; chart/tests/*.sh covers the enabled-render path."
|
|
else
|
|
echo "::error title=Empty render::Rendered output is suspiciously short ($lines lines). A working umbrella with an upstream subchart should produce many more resources. (For charts that are intentionally default-off, set annotations.catalyst.openova.io/smoke-render-mode: \"default-off\" in Chart.yaml.)"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
- name: "Upload smoke render as workflow artifact"
|
|
if: ${{ always() && steps.chart.outputs.skip != 'true' && steps.smoke.conclusion != 'skipped' }}
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: blueprint-smoke-render-${{ steps.chart.outputs.name }}-${{ steps.chart.outputs.version }}
|
|
path: /tmp/render/
|
|
if-no-files-found: warn
|
|
retention-days: 30
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GATE — Per-chart integration tests under chart/tests/*.sh
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Every chart under platform/<name>/chart/tests/ that ships a *.sh
|
|
# script gets executed here against the working-tree chart. Tests
|
|
# SHOULD be self-contained `helm template` assertions (no kind
|
|
# cluster); kind-based tests live in a separate workflow that we
|
|
# don't gate publish on.
|
|
#
|
|
# Canonical example: tests/observability-toggle.sh — verifies the
|
|
# docs/BLUEPRINT-AUTHORING.md §11.2 rule (observability toggles
|
|
# default false). A chart authoring regression that re-introduces
|
|
# a hardcoded `serviceMonitor.enabled: true` fails this gate and
|
|
# the publish job dies BEFORE the OCI artifact is pushed (issue
|
|
# #182).
|
|
- name: "Run chart integration tests (chart/tests/*.sh)"
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: chart_tests
|
|
run: |
|
|
set -euo pipefail
|
|
chart_dir="${{ matrix.path }}/chart"
|
|
tests_dir="$chart_dir/tests"
|
|
if [ ! -d "$tests_dir" ]; then
|
|
echo "No tests/ directory at $tests_dir — skipping chart tests."
|
|
exit 0
|
|
fi
|
|
shopt -s nullglob
|
|
scripts=( "$tests_dir"/*.sh )
|
|
if [ "${#scripts[@]}" -eq 0 ]; then
|
|
echo "tests/ directory present at $tests_dir but no *.sh scripts — skipping."
|
|
exit 0
|
|
fi
|
|
echo "Running ${#scripts[@]} chart-test script(s) under $tests_dir:"
|
|
for s in "${scripts[@]}"; do
|
|
echo " → $s"
|
|
done
|
|
for s in "${scripts[@]}"; do
|
|
echo "── $(basename "$s") ──"
|
|
bash "$s" "$chart_dir"
|
|
done
|
|
echo "All chart-test scripts passed."
|
|
|
|
- name: Helm push to GHCR
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: push
|
|
run: |
|
|
set -e
|
|
name="${{ steps.chart.outputs.name }}"
|
|
version="${{ steps.chart.outputs.version }}"
|
|
# `helm package` writes <chart_name>-<version>.tgz; the chart name
|
|
# already carries the `bp-` prefix per BLUEPRINT-AUTHORING.md §3.
|
|
file="/tmp/charts/${name}-${version}.tgz"
|
|
digest=$(helm push "$file" oci://ghcr.io/openova-io 2>&1 | tee /dev/stderr | awk '/Digest:/{print $2}')
|
|
echo "ref=ghcr.io/openova-io/${name}:${version}" >> "$GITHUB_OUTPUT"
|
|
echo "digest=$digest" >> "$GITHUB_OUTPUT"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GUARD 3 — hollow-chart check after round-trip through GHCR
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Pull the just-pushed artifact back from GHCR and re-verify deps
|
|
# survived. This catches any registry-side stripping, manifest
|
|
# rewriting, or path mangling — and proves end consumers (Flux
|
|
# helm-controller on every Sovereign) will see the upstream
|
|
# subchart bytes.
|
|
- name: "Verify upstream subcharts present in pulled OCI artifact (post-helm-push)"
|
|
if: steps.chart.outputs.skip != 'true'
|
|
id: verify_pull
|
|
run: |
|
|
set -euo pipefail
|
|
name="${{ steps.chart.outputs.name }}"
|
|
version="${{ steps.chart.outputs.version }}"
|
|
# `chart_yaml` is referenced relative to $GITHUB_WORKSPACE — DO NOT
|
|
# cd elsewhere before reading it. We pin `helm pull` output via
|
|
# --destination so the read of the source-tree Chart.yaml stays
|
|
# rooted at the checked-out repo root.
|
|
chart_yaml="${GITHUB_WORKSPACE}/${{ matrix.path }}/chart/Chart.yaml"
|
|
mkdir -p /tmp/pulled
|
|
helm pull "oci://ghcr.io/openova-io/${name}" --version "${version}" \
|
|
--destination /tmp/pulled
|
|
pulled_tgz="/tmp/pulled/${name}-${version}.tgz"
|
|
if [ ! -f "$pulled_tgz" ]; then
|
|
echo "::error title=Pull failed::helm pull did not produce $pulled_tgz; cannot verify GHCR contents."
|
|
ls -la /tmp/pulled
|
|
exit 1
|
|
fi
|
|
listing="/tmp/pulled/${name}-${version}.pulled.listing.txt"
|
|
tar -tzf "$pulled_tgz" > "$listing"
|
|
echo " pulled entries: $(wc -l < "$listing")"
|
|
dep_count=$(yq '.dependencies | length // 0' "$chart_yaml")
|
|
missing=0
|
|
for i in $(seq 0 $((dep_count - 1))); do
|
|
dep_name=$(yq ".dependencies[$i].name" "$chart_yaml")
|
|
dep_ver=$(yq ".dependencies[$i].version" "$chart_yaml")
|
|
packed_in_tgz="${name}/charts/${dep_name}-${dep_ver}.tgz"
|
|
unpacked_in_tgz="${name}/charts/${dep_name}/Chart.yaml"
|
|
if grep -qx "$packed_in_tgz" "$listing"; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $packed_in_tgz (in GHCR artifact)"
|
|
elif grep -qx "$unpacked_in_tgz" "$listing"; then
|
|
echo " ✓ ${dep_name} ${dep_ver} → $unpacked_in_tgz (in GHCR artifact, unpacked)"
|
|
else
|
|
echo "::error title=Hollow OCI artifact::GHCR-pulled $pulled_tgz does NOT contain upstream subchart '${dep_name}' (version='${dep_ver}'). The artifact at ghcr.io/openova-io/${name}:${version} is hollow — Flux will install it but the upstream payload will be missing on every Sovereign. Refusing to leave this published."
|
|
missing=$((missing + 1))
|
|
fi
|
|
done
|
|
if [ "$missing" -gt 0 ]; then
|
|
echo "── pulled-artifact listing (first 200 lines) ──"
|
|
head -200 "$listing"
|
|
exit 1
|
|
fi
|
|
echo "All $dep_count declared upstream subcharts present in pulled GHCR artifact."
|
|
|
|
- name: Cosign keyless sign
|
|
if: steps.chart.outputs.skip != 'true'
|
|
env:
|
|
COSIGN_EXPERIMENTAL: "true"
|
|
run: |
|
|
cosign sign --yes "${{ steps.push.outputs.ref }}@${{ steps.push.outputs.digest }}"
|
|
|
|
- name: Generate SBOM via Syft
|
|
if: steps.chart.outputs.skip != 'true'
|
|
run: |
|
|
# Scan the local .tgz file rather than the published OCI artifact so
|
|
# we don't need containerd permissions on the runner. The .tgz
|
|
# carries everything we'd want to inventory anyway (values.yaml +
|
|
# Chart.yaml + templates + blueprint.yaml + the upstream-chart
|
|
# reference metadata).
|
|
mkdir -p /tmp/sbom
|
|
name="${{ steps.chart.outputs.name }}"
|
|
version="${{ steps.chart.outputs.version }}"
|
|
# Chart name already includes `bp-` prefix (read from Chart.yaml).
|
|
syft "file:/tmp/charts/${name}-${version}.tgz" -o spdx-json="/tmp/sbom/sbom.spdx.json"
|
|
|
|
- name: Attest SBOM via cosign
|
|
if: steps.chart.outputs.skip != 'true'
|
|
env:
|
|
COSIGN_EXPERIMENTAL: "true"
|
|
run: |
|
|
cosign attest --yes --predicate /tmp/sbom/sbom.spdx.json --type spdxjson \
|
|
"${{ steps.push.outputs.ref }}@${{ steps.push.outputs.digest }}"
|
|
|
|
- name: Summary
|
|
if: steps.chart.outputs.skip != 'true'
|
|
run: |
|
|
echo "✓ Blueprint published" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **Name:** ${{ steps.chart.outputs.name }}" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **Version:** ${{ steps.chart.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **OCI ref:** \`${{ steps.push.outputs.ref }}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **Digest:** \`${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **Cosigned:** ✓ (keyless via GitHub OIDC)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **SBOM attested:** ✓ (SPDX-JSON)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- **Subchart guards:** ✓ working tree, ✓ packaged tgz, ✓ pulled OCI artifact, ✓ helm template smoke" >> "$GITHUB_STEP_SUMMARY"
|