# # .gitea/workflows/gitea-main-ci.yaml # # Builds the diskless fleet release: a fleet-generic UKI + erofs/dm-verity # root, packaged as a FROM-scratch OCI artifact image (see CLAUDE.md and # docs/diskless-fleet-design-v2.pdf). Packer kickstart-installs a throwaway # Rocky 9 build VM (qemu), builds the --no-hostonly initramfs, then the # shell-local post-processors extract artifacts, build the UKI (ukify), and # build+push the OCI release image. # # Signed when the SB_KEY/SB_CERT secrets are set; otherwise an UNSIGNED (lab) # build where build-oci.sh emits its loud unsigned-UKI warning. Signing runs # shell-local on the runner (not in the build VM); the key is staged to # RUNNER_TEMP and shredded after. See docs/secure-boot-keys.md. # # Required Gitea repo secrets: # REGISTRY_USERNAME - registry user for `oci_image` (oci_push=true) # REGISTRY_TOKEN - registry password/token # SSH_AUTHORIZED_KEY - public key for the sudo admin user; empty => no admin # user + root left unlocked (lab). Set it to lock root. # SB_KEY, SB_CERT - Secure Boot signing: the `db` PRIVATE key (PEM) + its # cert (PEM). Empty => unsigned lab build. PK/KEK are NOT # here (offline/HSM). Generate per docs/secure-boot-keys.md. # # Runner: needs KVM (qemu accelerator=kvm) and the build-host tooling installed # below (libguestfs, erofs-utils, cryptsetup, jq, ukify, buildah). # name: Build Diskless UKI run-name: ${{ gitea.actor }} builds the diskless fleet UKI (${{ gitea.ref_name }}) on: push: branches: - "**" # every branch — feature branches get a branch-name+sha tag tags: - "v*" # tag push -> clean semver tag (v1.2.3 -> 1.2.3) # Skip this ~17-min VM build when a push touches ONLY things that are NOT # rootfs/UKI build inputs. The image is built from diskless.pkr.hcl + http/ # (ks.cfg) + files/** + scripts/** — those are deliberately NOT listed here, # so they always trigger. Everything below is runtime/serving infra, docs, or # dev tooling that never lands in the rootfs. A commit that also touches a # real build input still builds (paths-ignore only skips when EVERY changed # path matches). paths-ignore: # Serving stack — served at runtime, built by its own image workflows / # the kiosk-app submodule; none of it is baked into the rootfs. - "serving/**" - "serving/kiosk-app" # the submodule gitlink path (pointer bumps) # Docs / notes / dev tooling — not build inputs. - "docs/**" - "*.md" # root README.md, CLAUDE.md, etc. - ".devcontainer/**" - ".gitignore" - ".gitattributes" - "LICENSE" # Sibling image workflows build their OWN images, not the UKI. (Add new # ones here; do NOT add gitea-main-ci.yaml — its edits must still build.) - ".gitea/workflows/ipxe-image.yaml" - ".gitea/workflows/dnsmasq-image.yaml" - ".gitea/workflows/spdk-image.yaml" - ".gitea/workflows/fleet-manager-image.yaml" # One build per ref at a time; a newer push to the same branch cancels the # in-flight build (each build is a ~17-min VM run, so don't stack them). concurrency: group: uki-build-${{ gitea.ref }} cancel-in-progress: true env: PRODUCT_VERSION: "1.15.4" # Packer version PACKER_LOG: "1" OCI_IMAGE: "git.bitasylum.org/audiobuzz/fleet-boot" NIC_DRIVERS: "ice mlx5_core" # NVMe/TCP discovery address baked into the (immutable) UKI cmdline # (rd.nvmf.discover). For the open-network test this is the lab serving host's # IP so the initramfs needs no DNS resolution. Production: switch to a # per-environment DNS name (e.g. nvme.fleet.lab) resolved at boot so one UKI # works across environments — the serving stack's dnsmasq resolves it. NVMET_TRADDR: "192.168.50.10" # URL the fullscreen Chromium kiosk loads at boot. Defaults to the kiosk SPA # (serving/kiosk-app) reverse-proxied by the boot HTTP server at /kiosk/; # boot.fleet.lab resolves to the serving host via the stack's dnsmasq. Empty # disables the kiosk. KIOSK_URL: "http://boot.fleet.lab/kiosk/" # Plymouth boot splash. "false" => verbose console boot (rhgb quiet NOT baked; # plymouth.enable=0 instead) — kept OFF in CI while the network-manager port is # being bench-verified, so the boot chain stays debuggable. Flip to "true" to # ship the graphical splash once boot is trusted. (Plymouth is in the image # either way; this only flips the cmdline.) BOOT_SPLASH: "true" # DISA STIG hardening (OpenSCAP + SSG, diskless/UKI/kiosk tailoring). Report-only # in CI: the scan + deviations report are published as artifacts; the build is # never failed on score. Set "false" to skip hardening (boot/secure-path bench). ENABLE_STIG: "true" # =========================================================================== # TEMPORARY: 802.1X + MACsec are DISABLED for this build (rd.dot1x=0) so the # UKI / rootfs / NVMe-oF boot path can be validated on an OPEN switch port. # The resulting UKI has NO link authentication or encryption — test networks # only. Set back to "false" to restore the full secure boot chain. # =========================================================================== DISABLE_DOT1X: "true" jobs: uki-build: name: Diskless UKI Build runs-on: ubuntu-latest container: image: git.bitasylum.org/audiobuzz/ubuntu-act-runner-qemu:main options: --device /dev/kvm steps: - name: Checkout uses: actions/checkout@v6 - name: Derive image version id: vars # This becomes the OCI tag (build-oci.sh tags ${oci_image}:${image_version}) # and the artifact dir name. Two cases: # - tag push (refs/tags/v1.2.3) -> "1.2.3" (release) # - branch push (refs/heads/) -> "-" (dev image) # The branch name is sanitized to the OCI tag charset # ([A-Za-z0-9._-], <=128, no leading . or -): e.g. feature/foo -> feature-foo, # giving feature-foo-ab12cd3. So every branch yields a uniquely tagged, # pushed image you can boot/test without clobbering main or releases. run: | short_sha="$(echo "${GITHUB_SHA}" | cut -c1-7)" if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then version="${GITHUB_REF#refs/tags/}" version="${version#v}" else branch="${GITHUB_REF#refs/heads/}" # invalid chars -> '-'; trim leading '.'/'-'; cap length headroom for -sha7 safe_branch="$(printf '%s' "$branch" \ | sed -e 's#[^A-Za-z0-9._-]#-#g' -e 's#^[.-]*##' | cut -c1-100)" [ -n "$safe_branch" ] || safe_branch="branch" version="${safe_branch}-${short_sha}" fi # Lowercase the whole thing. image_version is BOTH the OCI tag AND the # in-artifact UKI filename (fleet-.efi) + verity.json + subnqn. # A registry may lowercase the pushed TAG while the artifact keeps the # original case, so a mixed-case branch (e.g. SPDK-permanence) yields a # capital fleet-SPDK-…​.efi the lowercase boot URL can't fetch (iPXE # "no such file"). Forcing lowercase keeps tag, filename, verity.json, # and the dnsmasq boot URL all in agreement. version="$(printf '%s' "$version" | tr '[:upper:]' '[:lower:]')" echo "image_version=${version}" >> "$GITHUB_OUTPUT" echo "Building image_version=${version}" - name: Install build-host tooling # Tools the shell-local post-processors run on the RUNNER (not the VM): # libguestfs (virt-*), erofs-utils (mkfs.erofs), cryptsetup # (veritysetup), jq, systemd-ukify (ukify), sbsigntool (sbsign — used by # ukify to sign the UKI when SB_KEY/SB_CERT are set), buildah (OCI), plus # qemu + shellcheck for the lint gate. # NB: the Debian/Ubuntu package is sbsigntool (singular); on Fedora/RHEL # it's sbsigntools (plural). # # libguestfs builds a supermin appliance on first use, which needs a # kernel + modules present on the runner (incl. xfs, to read the build # VM's xfs root). The runner container ships none, so install one and # make /boot readable (Ubuntu ships vmlinuz mode 0600). run: | sudo apt-get update # systemd-boot-efi ships the UKI EFI stub # (/usr/lib/systemd/boot/efi/linuxx64.efi.stub) that ukify wraps the # kernel+initrd+cmdline around. It's only a *recommends* of # systemd-ukify, so --no-install-recommends would otherwise skip it. sudo apt-get install -y --no-install-recommends \ qemu-system-x86 qemu-utils libguestfs-tools linux-image-generic \ erofs-utils cryptsetup-bin jq systemd-ukify systemd-boot-efi \ sbsigntool buildah shellcheck attr sudo chmod 0644 /boot/vmlinuz-* 2>/dev/null || true - name: Setup Packer uses: hashicorp/setup-packer@main with: version: ${{ env.PRODUCT_VERSION }} - name: Shell lint gate # Matches CLAUDE.md conventions; dracut hooks + post-processor scripts. run: | bash -n files/dracut/95dot1x-macsec/*.sh scripts/*.sh # SC1091 (sourced /lib/dracut-lib.sh) and SC2154 (dracut-injected # $moddir) are dracut-module false positives. They're also in # .shellcheckrc, but excluded explicitly here so the gate doesn't # depend on the runner's shellcheck finding that rc file. shellcheck --exclude=SC1091,SC2154 files/dracut/95dot1x-macsec/*.sh scripts/*.sh - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.26.4' - name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi) # Built HERE on the runner, NOT inside the Packer VM, so the Rocky build # image never carries the Go toolchain or the go-tpm dep tree. Packer # file-provisions the resulting static binaries into the rootfs. Runs # BEFORE packer validate/build so the file provisioners' sources exist. # Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid. # fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is # stdlib-only (GOPROXY=off). # shell: bash is explicit — the Gitea/act runner defaults run steps to sh # (dash), where `set -o pipefail` is an illegal option. `set -e` is # load-bearing: without it a failed go build/test in the first ( … ) group # is masked by the trailing `file` success and a broken binary reaches Packer. shell: bash run: | set -euo pipefail ( cd files/enrol \ && CGO_ENABLED=0 GOTOOLCHAIN=local \ go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-enrol . \ && CGO_ENABLED=0 go test ./... ) ( cd files/hostapi \ && CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \ go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . ) file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi - name: TPM enrolment ceremony test (go-tpm software simulator) # Exercises the real key-gen/sign/NV path against a software TPM (CGO in a # separate module, so the simulator never enters the shipped enrol build). run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) - name: packer init run: packer init . - name: packer validate run: packer validate . - name: Verify libguestfs appliance (fast gate) # Fail in ~1 min if supermin can't build/boot the appliance, instead of # after the ~17-min VM build when extract-artifacts.sh first runs virt-ls. env: LIBGUESTFS_BACKEND: direct run: libguestfs-test-tool - name: Registry login # Enables oci_push=true in the build step below. run: | buildah login \ --username "${{ secrets.PACKAGE_USER }}" \ --password "${{ secrets.PACKAGE_TOKEN }}" \ git.bitasylum.org - name: Materialize Secure Boot signing material # sb_key/sb_cert are consumed by ukify as FILE PATHS (not values), and the # signing post-processor runs shell-local on THIS runner (not in the build # VM), so write the secrets to runner-temp files and pass their paths. The # `db` PRIVATE key is the only signing key here; PK/KEK stay offline (see # docs/secure-boot-keys.md). If SB_KEY is unset, this no-ops and the build # falls back to an UNSIGNED lab UKI (the loud warning stays). if: ${{ secrets.SB_KEY != '' }} env: SB_KEY: ${{ secrets.SB_KEY }} # db private key (PEM) — secret SB_CERT: ${{ secrets.SB_CERT }} # db cert (PEM) — public, kept as a secret for convenience run: | umask 077 d="$RUNNER_TEMP/sb"; mkdir -p "$d" printf '%s\n' "$SB_KEY" > "$d/db.key" printf '%s\n' "$SB_CERT" > "$d/db.crt" echo "SB_KEY_FILE=$d/db.key" >> "$GITHUB_ENV" echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV" echo "Secure Boot signing material staged (signed UKI build)." - name: Materialize Secure Boot enrollment certs # PUBLIC PK/KEK/db .auth files baked into the image for fleet-sb-enroll. # They're binary, so stored base64 in repo secrets; decode into # files/secureboot/keys/ (gitignored) before the build picks them up. If # the secrets are unset this no-ops and the helper ships without keys # (reports "no baked keys" until supplied). NOT signing keys — see # docs/secure-boot-keys.md; PK/KEK private keys never come near CI. if: ${{ secrets.SB_DB_AUTH_B64 != '' }} env: SB_PK_AUTH_B64: ${{ secrets.SB_PK_AUTH_B64 }} SB_KEK_AUTH_B64: ${{ secrets.SB_KEK_AUTH_B64 }} SB_DB_AUTH_B64: ${{ secrets.SB_DB_AUTH_B64 }} run: | d=files/secureboot/keys; mkdir -p "$d" printf '%s' "$SB_PK_AUTH_B64" | base64 -d > "$d/PK.auth" printf '%s' "$SB_KEK_AUTH_B64" | base64 -d > "$d/KEK.auth" printf '%s' "$SB_DB_AUTH_B64" | base64 -d > "$d/db.auth" echo "Staged PK/KEK/db .auth for in-image enrollment:"; ls -l "$d"/*.auth - name: packer build (UKI + OCI release image) # Signed when SB_KEY/SB_CERT secrets are set (paths exported above); # UNSIGNED lab build (loud warning) when they're empty. sb_key/sb_cert are # PATHS, never values — see the materialize step and docs/secure-boot-keys.md. run: | packer build -color=false -on-error=abort \ -var image_version="${{ steps.vars.outputs.image_version }}" \ -var nic_drivers="${{ env.NIC_DRIVERS }}" \ -var oci_image="${{ env.OCI_IMAGE }}" \ -var oci_push=true \ -var disable_dot1x=${{ env.DISABLE_DOT1X }} \ -var nvmet_traddr="${{ env.NVMET_TRADDR }}" \ -var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \ -var sb_key="${SB_KEY_FILE:-}" \ -var sb_cert="${SB_CERT_FILE:-}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \ -var boot_splash=${{ env.BOOT_SPLASH }} \ -var enable_stig=${{ env.ENABLE_STIG }} \ . - name: Shred Secure Boot signing key # Remove the private key from the runner FS even if the build failed. # (RUNNER_TEMP is also wiped per-job on ephemeral runners — belt + braces.) if: always() run: shred -u "$RUNNER_TEMP/sb/db.key" 2>/dev/null || rm -f "$RUNNER_TEMP/sb/db.key" 2>/dev/null || true - name: STIG deviations report (report-only) # The build downloaded the post-remediation scan (stig-arf.xml + # stig-report.html) into artifacts//. Turn the ARF into a # readable deviations report. Never fails the build (report-only). if: ${{ env.ENABLE_STIG == 'true' }} run: | dir="artifacts/${{ steps.vars.outputs.image_version }}" if [ -s "$dir/stig-arf.xml" ]; then bash scripts/stig-deviations.sh "$dir/stig-arf.xml" "$dir/stig-deviations.md" || true echo "----- STIG deviations (summary) -----" sed -n '1,40p' "$dir/stig-deviations.md" || true else echo "no STIG ARF found (enable_stig off or scan failed) — skipping report" fi - name: Publish STIG report artifacts if: ${{ env.ENABLE_STIG == 'true' }} uses: actions/upload-artifact@v3 with: name: stig-report-${{ steps.vars.outputs.image_version }} path: | artifacts/${{ steps.vars.outputs.image_version }}/stig-report.html artifacts/${{ steps.vars.outputs.image_version }}/stig-arf.xml artifacts/${{ steps.vars.outputs.image_version }}/stig-deviations.md if-no-files-found: warn