docs: document the bundled-images attestation predicate-type (ADR-16 … #6
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Cut a release by pushing a semver tag, e.g.: | |
| # git tag -s v0.1.0 -m "v0.1.0" && git push origin v0.1.0 | |
| # | |
| # Produces, per tag: | |
| # - one Kairos image per supported Kubernetes minor, pushed to | |
| # ghcr.io/<owner>/provider-kubernetes:<tag>-k8s<minor> (e.g. v0.1.0-k8s1.34). | |
| # The newest supported minor is additionally tagged <tag> and latest. | |
| # - a GitHub Release with the provider binary + sha256 checksum. | |
| # - SLSA build-provenance + CycloneDX SBOM attestations for every image (by digest) | |
| # and for the binary, signed keyless via the workflow's OIDC identity (ADR-15). | |
| # Verify with `gh attestation verify` -- see the GitHub Release notes / README. | |
| # | |
| # NOTE: the ghcr package inherits the repository's visibility. For EXTERNAL | |
| # testers to pull, make the package public in the org's package settings. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| # Least privilege at the top; each job widens to exactly what it needs | |
| # (images: push + attest; binary: release + attest). See ADR-15. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| # Derive the image matrix from the single source of truth (kubeadm.SupportedMinors), | |
| # exactly as CI does, plus the newest minor for the default/latest aliases. | |
| discover: | |
| name: resolve supported version window | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| minors: ${{ steps.minors.outputs.minors }} | |
| latest_minor: ${{ steps.minors.outputs.latest_minor }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| - name: Extract SupportedMinors -> JSON matrix | |
| id: minors | |
| run: | | |
| set -euo pipefail | |
| 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 | |
| # Newest minor, order-independent (do not rely on slice ordering). | |
| latest="$(echo "$minors" | jq -r 'sort_by(split(".")|map(tonumber))|.[-1]')" | |
| echo "window: $minors ; newest: $latest" | |
| echo "minors=$minors" >> "$GITHUB_OUTPUT" | |
| echo "latest_minor=$latest" >> "$GITHUB_OUTPUT" | |
| # Build and push one image per supported minor. | |
| images: | |
| name: image ${{ github.ref_name }}-k8s${{ matrix.minor }} | |
| needs: discover | |
| runs-on: ubuntu-latest | |
| # The Hadron (musl) image builds containerd AND kubelet static from source, | |
| # which adds several minutes per leg over the old binary-download path. | |
| timeout-minutes: 60 | |
| permissions: | |
| packages: write # push images to ghcr | |
| id-token: write # keyless OIDC identity for attestation signing | |
| attestations: write # write provenance + SBOM attestations | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| minor: ${{ fromJSON(needs.discover.outputs.minors) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 | |
| - name: Log in to ghcr.io | |
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve Kubernetes patch + commit + 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 | |
| # The Hadron (musl) image builds kubelet static from source, pinning the | |
| # clone to an immutable commit SHA and asserting the resolved tag points | |
| # at it. Resolve the SHA and forward it or the from-source build fails. | |
| # Prefer the peeled tag (^{} -> the commit the annotated tag wraps); | |
| # fall back to the bare ref. | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}^{}" | cut -f1)" | |
| if [ -z "$kcommit" ]; then | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}" | cut -f1)" | |
| fi | |
| case "$kcommit" in | |
| ?*) : ;; | |
| *) echo "could not resolve commit SHA for ${k8s}" >&2; exit 1 ;; | |
| esac | |
| echo "k8s=${k8s} kcommit=${kcommit}" | |
| echo "k8s=${k8s}" >> "$GITHUB_OUTPUT" | |
| echo "kcommit=${kcommit}" >> "$GITHUB_OUTPUT" | |
| echo "crictl=v${{ matrix.minor }}.0" >> "$GITHUB_OUTPUT" | |
| - name: Build, tag and push image | |
| id: build | |
| env: | |
| TAG: ${{ github.ref_name }} | |
| MINOR: ${{ matrix.minor }} | |
| LATEST_MINOR: ${{ needs.discover.outputs.latest_minor }} | |
| run: | | |
| set -euo pipefail | |
| # Image repo is lowercased ghcr.io/<owner>/<repo> (ghcr requires lowercase). | |
| repo="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" | |
| minortag="${repo}:${TAG}-k8s${MINOR}" | |
| make image \ | |
| VERSION="${TAG}" \ | |
| KUBERNETES_VERSION="${{ steps.ver.outputs.k8s }}" \ | |
| KUBERNETES_COMMIT="${{ steps.ver.outputs.kcommit }}" \ | |
| CRICTL_VERSION="${{ steps.ver.outputs.crictl }}" \ | |
| IMAGE="${minortag}" | |
| # Verify the bundled toolchain matches before publishing. | |
| got="$(docker run --rm --entrypoint /usr/bin/kubeadm "${minortag}" version -o short)" | |
| case "$got" in | |
| v${MINOR}.*) echo "verified kubeadm ${got}" ;; | |
| *) echo "kubeadm mismatch: want v${MINOR}.x got ${got}" >&2; exit 1 ;; | |
| esac | |
| docker push "${minortag}" | |
| # Capture the immutable digest of what we just pushed. Attestation MUST | |
| # target the digest, never a tag (ADR-15). RepoDigests is populated after | |
| # the push; pin to this repo's entry. | |
| digest="$(docker inspect --format='{{range .RepoDigests}}{{println .}}{{end}}' "${minortag}" \ | |
| | grep "^${repo}@" | head -1 | sed 's/.*@//')" | |
| case "$digest" in | |
| sha256:*) echo "pushed digest ${digest}" ;; | |
| *) echo "failed to resolve pushed digest for ${minortag}" >&2; exit 1 ;; | |
| esac | |
| echo "image=${repo}" >> "$GITHUB_OUTPUT" | |
| echo "digest=${digest}" >> "$GITHUB_OUTPUT" | |
| # The newest supported minor also gets the plain <tag> and :latest aliases. | |
| # These are the SAME digest (docker tag of the same image), so the single | |
| # digest attestation below covers all three tags. | |
| if [ "${MINOR}" = "${LATEST_MINOR}" ]; then | |
| for alias in "${repo}:${TAG}" "${repo}:latest"; do | |
| docker tag "${minortag}" "${alias}" | |
| docker push "${alias}" | |
| done | |
| fi | |
| # CycloneDX, not SPDX: a full OS-image SPDX SBOM is ~23 MiB and exceeds the | |
| # actions/attest-sbom 16 MiB predicate cap; the equivalent CycloneDX SBOM is | |
| # ~3 MiB. CycloneDX is the compact format for image-scale SBOMs. | |
| - name: Generate image SBOM (CycloneDX) | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| image: ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }} | |
| format: cyclonedx-json | |
| output-file: image.cdx.json | |
| upload-artifact: false | |
| upload-release-assets: false # we attest the SBOM; do not auto-attach it to the Release | |
| - name: Attest image build provenance | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-name: ${{ steps.build.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: true | |
| - name: Attest image SBOM | |
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | |
| with: | |
| subject-name: ${{ steps.build.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: image.cdx.json | |
| push-to-registry: true | |
| # ADR-16 P5: attest the pre-bundled control-plane images by DIGEST, including | |
| # whether each was upstream-signature-verified at build. We attest the | |
| # baked-in images.lock DIRECTLY as a custom in-toto predicate (not an SBOM): | |
| # images.lock IS the authoritative ref->digest->verified/verifyReason record, | |
| # and a generic predicate avoids actions/attest-sbom's SBOM-format detector | |
| # (which rejects a compliant hand-rolled CycloneDX) -- more robust and | |
| # future-proof. `gh attestation verify --predicate-type <type>` selects it. | |
| - name: Extract bundled control-plane image lockfile | |
| run: | | |
| set -euo pipefail | |
| docker run --rm --entrypoint cat \ | |
| "${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }}" \ | |
| /opt/provider-kubernetes/images/images.lock > images.lock | |
| test -s images.lock | |
| - name: Attest bundled control-plane images | |
| uses: actions/attest@ce27ba3b4a9a139d9a20a4a07d69fabb52f1e5bc # v2 | |
| with: | |
| subject-name: ${{ steps.build.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| predicate-type: https://kairos.io/attestations/bundled-control-plane-images/v1 | |
| predicate-path: images.lock | |
| push-to-registry: true | |
| # R-4: prove the attestation is real before a tester relies on it. A broken | |
| # signing step fails the release here, not in the field. We verify provenance | |
| # (the load-bearing integrity check); the SBOM attestation's existence is | |
| # already guaranteed by the Attest-image-SBOM step above failing otherwise, so | |
| # we do not add a predicate-type filter here (avoids coupling to the SBOM | |
| # format/spec version). | |
| - name: Self-verify image attestation | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| ref="${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }}" | |
| echo "verifying provenance for oci://${ref}" | |
| gh attestation verify "oci://${ref}" --repo "${{ github.repository }}" | |
| # Build the provider binary and publish a GitHub Release with a checksum. | |
| binary: | |
| name: release binary | |
| needs: [discover, images] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write # create the GitHub Release | |
| id-token: write # keyless OIDC identity for attestation signing | |
| attestations: write # write provenance + SBOM attestations | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 | |
| with: | |
| go-version-file: go.mod | |
| - name: Build binary | |
| run: make build VERSION=${{ github.ref_name }} | |
| - name: Package + checksum | |
| run: | | |
| set -euo pipefail | |
| cd bin | |
| tar -czf "agent-provider-kubernetes_${{ github.ref_name }}_linux_amd64.tar.gz" agent-provider-kubernetes | |
| sha256sum agent-provider-kubernetes_*.tar.gz > checksums.txt | |
| cat checksums.txt | |
| # CycloneDX for consistency with the image SBOM (one SBOM format across all | |
| # published artifacts; well under the attest-sbom 16 MiB cap). | |
| - name: Generate binary SBOM (CycloneDX) | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| file: bin/agent-provider-kubernetes | |
| format: cyclonedx-json | |
| output-file: binary.cdx.json | |
| upload-artifact: false | |
| upload-release-assets: false # we attest the SBOM; do not auto-attach it to the Release | |
| - name: Attest binary build provenance | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-path: bin/agent-provider-kubernetes_*.tar.gz | |
| - name: Attest binary SBOM | |
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | |
| with: | |
| subject-path: bin/agent-provider-kubernetes_*.tar.gz | |
| sbom-path: binary.cdx.json | |
| # R-4: verify provenance before publishing the Release (the SBOM attestation's | |
| # existence is guaranteed by the Attest-binary-SBOM step failing otherwise). | |
| - name: Self-verify binary attestation | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| tarball="$(ls bin/agent-provider-kubernetes_*.tar.gz)" | |
| echo "verifying provenance for ${tarball}" | |
| gh attestation verify "${tarball}" --repo "${{ github.repository }}" | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ github.ref_name }} | |
| MINORS: ${{ needs.discover.outputs.minors }} | |
| LATEST_MINOR: ${{ needs.discover.outputs.latest_minor }} | |
| REPO_SLUG: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| repo="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" | |
| # Image list derived from the supported window (single source of truth); | |
| # no hardcoded minors here, so the notes never drift as the window rolls. | |
| imglist="" | |
| for m in $(echo "${MINORS}" | jq -r '.[]'); do | |
| imglist="${imglist}${repo}:${TAG}-k8s${m}"$'\n' | |
| done | |
| verify="$(printf 'Verifying (every image + the binary carry keyless SLSA provenance + CycloneDX SBOM attestations):\n```\n# image (resolve the digest first, then verify by digest)\ngh attestation verify oci://%s:%s --repo %s\n# binary\ngh attestation verify agent-provider-kubernetes_%s_linux_amd64.tar.gz --repo %s\n```\n' \ | |
| "${repo}" "${TAG}" "${REPO_SLUG}" "${TAG}" "${REPO_SLUG}")" | |
| notes="$(printf 'Kairos images (one per supported Kubernetes minor):\n```\n%s```\nThe newest supported minor (%s) is also published as `%s:%s` and `%s:latest`.\n\n%s\nProvider binary (linux/amd64) attached below. This is a development release; not yet field-ready.\n' \ | |
| "${imglist}" "${LATEST_MINOR}" "${repo}" "${TAG}" "${repo}" "${verify}")" | |
| # Idempotent: update an existing release (e.g. a re-run), else create it. | |
| if gh release view "${TAG}" >/dev/null 2>&1; then | |
| gh release upload "${TAG}" bin/agent-provider-kubernetes_*.tar.gz bin/checksums.txt --clobber | |
| else | |
| gh release create "${TAG}" \ | |
| bin/agent-provider-kubernetes_*.tar.gz bin/checksums.txt \ | |
| --title "${TAG}" --notes "${notes}" --prerelease | |
| fi |