Skip to content

fix(ci): emit CycloneDX SBOM so attestation fits the 16 MiB cap (#19) #40

fix(ci): emit CycloneDX SBOM so attestation fits the 16 MiB cap (#19)

fix(ci): emit CycloneDX SBOM so attestation fits the 16 MiB cap (#19) #40

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
# Cancel superseded runs on the same ref to save runner time.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Fast Go gates: format, vet, test (with coverage), build, lint.
# The Go toolchain is read from go.mod so CI can never drift from the
# module's `go` directive (CLAUDE.md no-drift rule, hardening item C3).
gates:
name: build / vet / test / lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: false
- name: gofmt -s (check only)
run: make fmt-check
- name: go vet
run: make vet
- name: go test (with coverage)
run: make test
- name: go build
run: make build
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# Pin to the version validated locally against the v2 config.
version: v2.12.2
# Derive the image-build matrix from the single source of truth for the
# supported Kubernetes window: kubeadm.SupportedMinors in version.go (ADR-3).
# Rolling the window there automatically rolls this CI matrix.
discover:
name: resolve supported version window
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
minors: ${{ steps.minors.outputs.minors }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Extract SupportedMinors -> JSON matrix
id: minors
run: |
set -euo pipefail
# SupportedMinors is a static string-literal slice; pull each
# "major.minor" token off its declaration line.
minors="$(grep 'SupportedMinors *=' internal/kubeadm/version.go \
| grep -oE '[0-9]+\.[0-9]+' \
| jq -R . | jq -cs .)"
if [ "$minors" = "[]" ]; then
echo "could not parse SupportedMinors from version.go" >&2
exit 1
fi
echo "Supported window: $minors"
echo "minors=$minors" >> "$GITHUB_OUTPUT"
# Product deliverable: the multi-stage Kairos image bundling the provider
# plus kubeadm/kubelet/kubectl/containerd/runc/CNI (all checksum-verified
# inside the Dockerfile). Built once per supported minor; each minor's patch
# is resolved to the latest upstream release at build time so pins never go
# stale as the window rolls.
image:
name: image build (k8s ${{ matrix.minor }})
needs: discover
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
minor: ${{ fromJSON(needs.discover.outputs.minors) }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Resolve Kubernetes patch + crictl versions
id: ver
run: |
set -euo pipefail
k8s="$(curl -fsSL "https://dl.k8s.io/release/stable-${{ matrix.minor }}.txt")"
case "$k8s" in
v${{ matrix.minor }}.*) : ;;
*) echo "unexpected patch ${k8s} for minor ${{ matrix.minor }}" >&2; exit 1 ;;
esac
# cri-tools publishes one release per minor: v<minor>.0.
crictl="v${{ matrix.minor }}.0"
echo "k8s=${k8s} crictl=${crictl}"
echo "k8s=${k8s}" >> "$GITHUB_OUTPUT"
echo "crictl=${crictl}" >> "$GITHUB_OUTPUT"
- name: Build Kairos image
run: |
make image \
VERSION=ci-${{ matrix.minor }} \
KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \
CRICTL_VERSION=${{ steps.ver.outputs.crictl }}
- name: Verify bundled binaries match the target minor
run: |
set -euo pipefail
img="kairos-kubeadm:ci-${{ matrix.minor }}"
kubeadm_v="$(docker run --rm --entrypoint /usr/bin/kubeadm "$img" version -o short)"
crictl_v="$(docker run --rm --entrypoint /usr/bin/crictl "$img" --version)"
echo "bundled kubeadm: ${kubeadm_v}"
echo "bundled crictl: ${crictl_v}"
case "$kubeadm_v" in
v${{ matrix.minor }}.*) ;;
*) echo "kubeadm minor mismatch: want v${{ matrix.minor }}.x, got ${kubeadm_v}" >&2; exit 1 ;;
esac
case "$crictl_v" in
*v${{ matrix.minor }}.*) ;;
*) echo "crictl minor mismatch: want v${{ matrix.minor }}.x, got ${crictl_v}" >&2; exit 1 ;;
esac
echo "OK: image bundles the k8s ${{ matrix.minor }} toolchain"
- name: Verify kubeadm exposes the upgrade-path surface (ADR-12)
run: |
set -euo pipefail
img="kairos-kubeadm:ci-${{ matrix.minor }}"
ka() { docker run --rm --entrypoint /usr/bin/kubeadm "$img" "$@"; }
need() { # <help-output> <flag> <label>
printf '%s\n' "$1" | grep -q -- "$2" \
|| { echo "MISSING $2 in '$3' (bundled kubeadm ${{ matrix.minor }})" >&2; exit 1; }
echo " ok: $3 supports $2"
}
# The provider drives only these kubeadm subcommands/flags for upgrades.
# Asserting them against the SHIPPED binary per minor catches an upstream
# removal/rename before it breaks an upgrade at runtime (ADR-12 U-N).
# `kubeadm upgrade apply <ver> --yes --certificate-renewal=true`
apply_help="$(ka upgrade apply --help)"
need "$apply_help" --yes "upgrade apply"
need "$apply_help" --certificate-renewal "upgrade apply"
# `kubeadm upgrade node` (follower / worker convergence) must exist.
ka upgrade node --help >/dev/null
echo " ok: upgrade node subcommand exists"
# `kubeadm init phase kubelet-start --config` (ADR-12-R1 API-free repair).
ks_help="$(ka init phase kubelet-start --help)"
need "$ks_help" --config "init phase kubelet-start"
# `kubeadm init phase upload-certs --config` (cert-key flows via config,
# never on argv -- the mint keystone, ADR-11 #3 / ADR-12 B3).
uc_help="$(ka init phase upload-certs --help)"
need "$uc_help" --config "init phase upload-certs"
echo "OK: kubeadm ${{ matrix.minor }} exposes the upgrade-path subcommands/flags"
# ADR-13 Tier-1 e2e suite: real kubeadm in a privileged systemd node container.
# Runs all scenarios in test/e2e/ (init-converge, reset, init-clobber-refusal,
# upgrade-skew-refusal, worker-join) against the real provider binary.
#
# SECURITY NOTE (privileged containers, ADR-13 E3 gate): the e2e node container
# must run --privileged because kubeadm/kubelet/containerd/etcd require real
# cgroup v2 control, mount namespaces, netns creation, and /sys/fs/cgroup write
# access -- unavailable without privilege inside a container. This is the same
# constraint kind (Kubernetes-in-Docker) has. The only image that runs privileged
# is OUR OWN built kairos-kubeadm-e2e-node image, derived from the kairos-kubeadm
# base we build in this same job from the checkout source. No third-party image
# is ever pulled. Ephemeral per-run kubeadm bootstrap tokens (bounded TTL, never
# persisted) are the only secrets; no long-lived credentials are embedded. Teardown
# is guaranteed: t.Cleanup registers docker rm -fv per container, and the failure
# step below sweeps leaked containers by label.
#
# Image strategy: option (a) -- rebuild kairos-kubeadm + e2e-node in this job
# using the same stable-<minor>.txt patch-resolution as the image job. This is
# self-contained (no artifact upload/download between jobs), fault-isolated
# per-minor-leg, and avoids docker/actions-artifact size limits. The ~4 min
# build overhead is well within the 30 min per-leg timeout.
#
# GitHub-hosted ubuntu-latest supports --privileged containers, /boot/config-*,
# and /lib/modules. The harness mounts those read-only so kubeadm's
# SystemVerification preflight can read the kernel config without relaxing any
# preflight check.
e2e:
name: e2e (k8s ${{ matrix.minor }})
needs: discover
# SECURITY (ADR-13 E3, security-architect R1): this job runs a --privileged
# node container (real kubeadm/kubelet/containerd) on attacker-influenceable
# PR code. The load-bearing controls are: (1) GitHub-HOSTED ephemeral runner
# only -- NEVER a self-hosted runner; (2) NO secrets and an explicit
# read-only token (below) -- NEVER add secrets or packages:write; (3) the
# `pull_request` trigger (NOT pull_request_target), so fork PRs get no secrets.
# These four conditions, not the container flags, are what contain a hostile PR.
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 50
strategy:
fail-fast: false
matrix:
minor: ${{ fromJSON(needs.discover.outputs.minors) }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Resolve Kubernetes patch + crictl versions
id: ver
run: |
set -euo pipefail
k8s="$(curl -fsSL "https://dl.k8s.io/release/stable-${{ matrix.minor }}.txt")"
case "$k8s" in
v${{ matrix.minor }}.*) : ;;
*) echo "unexpected patch ${k8s} for minor ${{ matrix.minor }}" >&2; exit 1 ;;
esac
crictl="v${{ matrix.minor }}.0"
echo "k8s=${k8s} crictl=${crictl}"
echo "k8s=${k8s}" >> "$GITHUB_OUTPUT"
echo "crictl=${crictl}" >> "$GITHUB_OUTPUT"
- name: Build kairos-kubeadm base image
run: |
make image \
VERSION=ci-${{ matrix.minor }} \
KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \
CRICTL_VERSION=${{ steps.ver.outputs.crictl }} \
IMAGE=kairos-kubeadm:${{ steps.ver.outputs.k8s }}
- name: Build e2e node image
run: |
make e2e-node-image \
KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \
BASE_IMAGE=kairos-kubeadm:${{ steps.ver.outputs.k8s }} \
E2E_NODE_IMAGE=kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }}
- name: Run e2e suite
run: |
E2E_NODE_IMAGE=kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }} \
E2E_KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \
go test -tags e2e -count=1 -timeout 45m -v ./test/e2e/...
- name: Prune e2e container volumes and images on failure
if: failure()
run: |
docker ps -aq --filter 'label=provider-kubernetes-e2e=1' \
| xargs -r docker rm -fv || true
docker image rm -f \
"kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }}" \
"kairos-kubeadm:${{ steps.ver.outputs.k8s }}" || true
docker volume prune -f || true