From 1b37fc788ec8d0862fb7a63bca9c29dfd801f3e5 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:26:14 -0400 Subject: [PATCH 01/13] fix(ci): unbreak the lab-runner and aarch64 VM build legs Every push to main has been red while every PR was green, because both failures are in jobs that only run post-merge. lab-runner/just.bst: the release tarball records uid/gid 1001, and tar cannot restore that ownership inside the BuildStream sandbox, so it exits non-zero ("Cannot change ownership to uid 1001, gid 1001") even though the member extracted. Extract with --no-same-owner. tests/vm-boot.sh: `-cpu max` enables FEAT_E0PD, and QEMU < 9.2 -- including the 8.2 on ubuntu-24.04 runners -- aborts on the first E10_0 TLBI with "regime_is_user: code should not be reached". Boot aarch64 TCG on cortex-a76 (ARMv8.2, predates FEAT_E0PD) so the test exercises the disk instead of a QEMU bug. x86_64 keeps -cpu max; KVM keeps -cpu host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- elements/lab-runner/just.bst | 6 +++++- tests/vm-boot.sh | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/elements/lab-runner/just.bst b/elements/lab-runner/just.bst index a7b6a60..6b005f6 100644 --- a/elements/lab-runner/just.bst +++ b/elements/lab-runner/just.bst @@ -25,7 +25,11 @@ sources: config: install-commands: - | - tar -xzf just.tar.gz just + # --no-same-owner: the release tarball records uid/gid 1001, and + # restoring that ownership is not permitted inside the BuildStream + # sandbox ("Cannot change ownership to uid 1001, gid 1001"), which + # makes tar exit non-zero even though the member extracted fine. + tar -xzf just.tar.gz --no-same-owner just mkdir -p "%{install-root}/usr/bin" cp just "%{install-root}/usr/bin/just" chmod 755 "%{install-root}/usr/bin/just" diff --git a/tests/vm-boot.sh b/tests/vm-boot.sh index cb47306..6364212 100755 --- a/tests/vm-boot.sh +++ b/tests/vm-boot.sh @@ -211,7 +211,19 @@ esac if [ "$ACCEL" = kvm ]; then ACCEL_ARGS=(-enable-kvm -machine "$MACHINE" -cpu host) else - ACCEL_ARGS=(-machine "$MACHINE" -cpu max) + # aarch64 TCG deliberately does NOT use `-cpu max`. `max` enables + # FEAT_E0PD (ARMv8.5), and QEMU < 9.2 -- including the 8.2 that ships on + # ubuntu-24.04 runners -- aborts on the first E10_0 TLBI with + # ERROR:target/arm/internals.h: regime_is_user: code should not be reached + # (fixed upstream by "target/arm: Don't assert in regime_is_user() for + # E10 mmuidx"). cortex-a76 is ARMv8.2, predates FEAT_E0PD, and boots the + # same guest, so the boot test exercises the disk instead of a QEMU bug. + case "$ARCH" in + aarch64) TCG_CPU="${PODMAN_VM_TCG_CPU:-cortex-a76}" ;; + *) TCG_CPU="${PODMAN_VM_TCG_CPU:-max}" ;; + esac + log "TCG CPU model: ${TCG_CPU}" + ACCEL_ARGS=(-machine "$MACHINE" -cpu "$TCG_CPU") fi FIRMWARE_ARGS=() From c1a1d74a537446f8fed584e57cde0bedae6ae715 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:29:48 -0400 Subject: [PATCH 02/13] feat(ci): declare per-image path ownership and a changed-targets recipe elements/targets.json gains image_paths, shared_paths, canary_image, and vm_guest_paths so path ownership stays in the canonical manifest instead of being hand-maintained in a workflow. `just changed-targets BASE HEAD` resolves them against the merge base and prints the affected targets as JSON for the pull-request build gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Justfile | 72 +++++++++++++++++++++++++++++++++++++++++++ elements/targets.json | 33 +++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index a2823fa..0dd33d6 100644 --- a/Justfile +++ b/Justfile @@ -143,6 +143,78 @@ image-list: image-matrix: @jq -c '.oci_images' elements/targets.json +# Print the build targets affected by the changes between BASE and HEAD as one +# JSON object: {"oci_images":[...],"vm_guest":true|false}. This is the +# pull-request build gate: a PR builds and verifies only what it can break, +# instead of nothing at all (the previous behaviour, which let broken elements +# reach main and only fail post-merge) or all seven images times two +# architectures (not viable per PR). Path ownership lives in +# elements/targets.json, never in a workflow. +[group('info')] +changed-targets BASE HEAD="HEAD": + #!/usr/bin/env bash + set -euo pipefail + MANIFEST=elements/targets.json + + # Compare against the merge base so a PR is judged on its own changes, not + # on whatever landed on the base branch since it was opened. + MERGE_BASE="$(git merge-base "{{BASE}}" "{{HEAD}}")" + mapfile -t FILES < <(git diff --name-only "${MERGE_BASE}" "{{HEAD}}") + + # A prefix ending in '/' matches everything beneath it; anything else must + # match the path exactly, so `elements/oci/base.bst` never matches + # `elements/oci/base-extra.bst`. + matches_any() { + local file="$1"; shift + local prefix + for prefix in "$@"; do + case "${prefix}" in + */) [[ "${file}" == "${prefix}"* ]] && return 0 ;; + *) [[ "${file}" == "${prefix}" ]] && return 0 ;; + esac + done + return 1 + } + + mapfile -t SHARED < <(jq -r '.shared_paths[]' "${MANIFEST}") + mapfile -t VM_PATHS < <(jq -r '.vm_guest_paths[]' "${MANIFEST}") + CANARY="$(jq -r '.canary_image' "${MANIFEST}")" + + SELECTED=() + VM_GUEST=false + SHARED_HIT=false + for file in "${FILES[@]:-}"; do + [ -n "${file}" ] || continue + if matches_any "${file}" "${VM_PATHS[@]}"; then + VM_GUEST=true + fi + if matches_any "${file}" "${SHARED[@]}"; then + SHARED_HIT=true + fi + while IFS= read -r img; do + mapfile -t IMG_PATHS < <(jq -r --arg i "${img}" '.image_paths[$i][]' "${MANIFEST}") + if matches_any "${file}" "${IMG_PATHS[@]}"; then + SELECTED+=("${img}") + fi + done < <(jq -r '.oci_images[]' "${MANIFEST}") + done + + if [ "${SHARED_HIT}" = true ]; then + SELECTED+=("${CANARY}") + fi + + # Deduplicate while keeping manifest order, so the matrix is stable. + # `grep` legitimately matches nothing when no target is selected, which is + # not an error under `set -e`. + SELECTED_LINES="$(printf '%s\n' "${SELECTED[@]:-}" | grep -v '^$' || true)" + OCI_JSON="$(printf '%s' "${SELECTED_LINES}" \ + | jq -Rsc --slurpfile m <(jq '{oci_images}' "${MANIFEST}") \ + 'split("\n") | map(select(length > 0)) | unique as $sel + | $m[0].oci_images | map(select(. as $i | $sel | index($i)))')" + + jq -cn --argjson oci "${OCI_JSON}" --argjson vm "${VM_GUEST}" \ + '{oci_images: $oci, vm_guest: $vm}' + # ── Validate ────────────────────────────────────────────────────────── [group('dev')] validate: diff --git a/elements/targets.json b/elements/targets.json index fde422b..eb867b2 100644 --- a/elements/targets.json +++ b/elements/targets.json @@ -1,5 +1,5 @@ { - "$comment": "Canonical manifest of OCI distroless image targets published to GHCR. This is the SINGLE source of truth for the GitHub Actions build/manifest matrices, `just validate`, and `just sbom`/`sboms`. Adding a package requires exactly one entry here plus the BST element at elements/oci/.bst -- no edits to any matrix loop or case list. Per-image description, size budget, and smoke-test logic stay in the Justfile (`export`/`verify`), since those are genuine per-image behaviour, not matrix membership.", + "$comment": "Canonical manifest of OCI distroless image targets published to GHCR. This is the SINGLE source of truth for the GitHub Actions build/manifest matrices, `just validate`, `just sbom`/`sboms`, and the pull-request build gate (`just changed-targets`). Adding a package requires exactly one entry in `oci_images` plus the BST element at elements/oci/.bst and its path ownership in `image_paths` -- no edits to any matrix loop or case list. Per-image description, size budget, and smoke-test logic stay in the Justfile (`export`/`verify`), since those are genuine per-image behaviour, not matrix membership.", "oci_images": [ "base", "static", @@ -8,5 +8,36 @@ "python", "buildah", "qemu-img" + ], + + "$comment_image_paths": "Path prefixes each OCI image owns. `just changed-targets` matches changed files against these to decide which images a pull request must actually build and verify, so a one-image change costs one build instead of fourteen. A prefix ending in '/' matches everything under that directory; anything else matches that exact path.", + "image_paths": { + "base": ["elements/oci/base.bst", "elements/base/"], + "static": ["elements/oci/static.bst", "elements/static/"], + "skopeo": ["elements/oci/skopeo.bst", "elements/skopeo/"], + "lab-runner": ["elements/oci/lab-runner.bst", "elements/lab-runner/"], + "python": ["elements/oci/python.bst", "elements/python/"], + "buildah": ["elements/oci/buildah.bst", "elements/buildah/"], + "qemu-img": ["elements/oci/qemu-img.bst", "elements/qemu-img/"] + }, + + "$comment_shared_paths": "Changes here can affect every target (the FSDK junction, the shared slim recipe, project-wide options, the build tooling itself). Building all seven images times two architectures on every such pull request is not viable, so these select the canary image below instead: a real end-to-end build and `just verify` of the image every other image is carved from.", + "shared_paths": [ + "project.conf", + "Justfile", + "include/", + "patches/", + "elements/targets.json", + "elements/freedesktop-sdk.bst", + "elements/gnome-build-meta.bst", + "elements/plugins/", + ".github/workflows/" + ], + "canary_image": "base", + + "$comment_vm_guest_paths": "The podman-vm guest disk (docs/skills/vm-podman-guest.md) is not an OCI image and has its own build/boot-test/publish pipeline, so it gets its own path set. Its pull-request gate is a real build plus the QEMU boot test -- never a release upload.", + "vm_guest_paths": [ + "elements/podman-vm/", + "tests/vm-boot.sh" ] } From 34aa56801e187953e2e6c361362ca4ef6e00f4fe Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:34:06 -0400 Subject: [PATCH 03/13] feat(ci): build and verify affected targets on every pull request Pull requests only resolved the element graph, so a PR could be green while the merge commit turned main red -- which is what has been happening on every recent push. This adds the missing gate without paying for the full seven-image, two-architecture matrix on every PR: - `changed-targets` resolves what the PR can break from the path ownership in elements/targets.json; a shared-path change builds the canary image. - `pr-build-oci` builds and runs `just verify` for each affected image on both architectures. - `pr-build-vm-guest` builds, checksums, and boot-tests the VM guest when it is affected. Neither PR job calls oci-images.yml or vm-guest.yml and neither contains a login, push, sign, attest, or release step, so a pull request -- including one from a fork -- has no code path to publication and needs only `contents: read`. The QEMU setup and boot test move into a composite action shared with vm-guest.yml so the gate cannot drift from the check that guards publication, and every checkout now sets persist-credentials: false. The multi-arch guard on rolling tags is restored so a single-architecture failure can never replace a multi-arch manifest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/vm-boot-test/action.yml | 64 ++++++++++++ .github/workflows/build.yml | 132 ++++++++++++++++++++++++ .github/workflows/oci-images.yml | 18 +++- .github/workflows/vm-guest.yml | 47 +++------ 4 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 .github/actions/vm-boot-test/action.yml diff --git a/.github/actions/vm-boot-test/action.yml b/.github/actions/vm-boot-test/action.yml new file mode 100644 index 0000000..68a4ff2 --- /dev/null +++ b/.github/actions/vm-boot-test/action.yml @@ -0,0 +1,64 @@ +name: VM guest boot test +description: > + Install QEMU plus UEFI firmware for one architecture and boot-test the + podman-vm guest disk in dist-vm/ with tests/vm-boot.sh. Shared by the + release pipeline (vm-guest.yml) and the pull-request gate (build.yml) so the + gate can never drift from what actually guards publication. + +inputs: + arch: + description: Guest architecture to boot (x86_64 or aarch64). + required: true + +runs: + using: composite + steps: + # QEMU loads efi-virtio.rom for the consumer's `if=virtio` topology; + # ipxe-qemu provides that ROM on both architectures. + - name: Install QEMU and UEFI firmware + shell: bash + env: + ARCH: ${{ inputs.arch }} + run: | + set -euo pipefail + sudo apt-get update + case "${ARCH}" in + x86_64) sudo apt-get install -y --no-install-recommends qemu-system-x86 qemu-utils ovmf ipxe-qemu ;; + aarch64) sudo apt-get install -y --no-install-recommends qemu-system-arm qemu-utils qemu-efi-aarch64 ipxe-qemu ;; + *) echo "ERROR: unsupported arch '${ARCH}'" >&2; exit 1 ;; + esac + + # GitHub's x86_64 Linux runners expose /dev/kvm, but only to the kvm + # group. Widening it lets the boot test run accelerated instead of falling + # back to TCG. Best effort, and deliberately not a gate: tests/vm-boot.sh + # detects a usable /dev/kvm itself and raises its own timeout when it has + # to fall back to software emulation. + - name: Make /dev/kvm usable by the runner user + shell: bash + env: + ARCH: ${{ inputs.arch }} + run: | + set -euo pipefail + [ "${ARCH}" = x86_64 ] || exit 0 + { + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + } || echo "note: could not widen /dev/kvm permissions; the boot test will use TCG" + + - name: Boot-test the ${{ inputs.arch }} disk under QEMU + shell: bash + run: tests/vm-boot.sh + + # tests/vm-boot.sh copies the captured serial console to tests/artifacts/ + # on every run and dumps it to stderr on failure; keep the file too, since + # a truncated job log is the usual reason a boot failure is hard to read. + - name: Upload the captured serial console on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v7.0.1 + with: + name: vm-boot-serial-${{ inputs.arch }} + path: tests/artifacts/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2ecba6..abc813f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,12 +48,143 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.client_payload.ref || github.ref }} + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: tool: just - name: Resolve element graph run: just validate + # Pull-request build gate. `just validate` only resolves the graph, so + # before this job existed a PR could be green while main went red on the + # merge commit -- which is exactly what happened, repeatedly. Building all + # seven images on two architectures per PR is not viable, so this resolves + # the targets the PR can actually break from the path ownership declared in + # elements/targets.json (`just changed-targets`), and a change to a shared + # path builds the canary image instead of the world. + changed-targets: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + oci_images: ${{ steps.resolve.outputs.oci_images }} + vm_guest: ${{ steps.resolve.outputs.vm_guest }} + any_oci: ${{ steps.resolve.outputs.any_oci }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history: the recipe diffs against the merge base so the PR is + # judged on its own changes only. + fetch-depth: 0 + persist-credentials: false + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 + with: + tool: just + - id: resolve + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + TARGETS="$(just changed-targets "${BASE_SHA}" HEAD)" + echo "==> affected targets: ${TARGETS}" + OCI="$(jq -c '.oci_images' <<<"${TARGETS}")" + { + echo "oci_images=${OCI}" + echo "vm_guest=$(jq -r '.vm_guest' <<<"${TARGETS}")" + echo "any_oci=$(jq -r '.oci_images | length > 0' <<<"${TARGETS}")" + } >> "$GITHUB_OUTPUT" + { + echo "### Pull-request build gate" + echo + echo "- OCI images built and verified: \`$(jq -r '.oci_images | if length == 0 then "none" else join(", ") end' <<<"${TARGETS}")\`" + echo "- podman-vm guest built and boot-tested: \`$(jq -r '.vm_guest' <<<"${TARGETS}")\`" + } >> "$GITHUB_STEP_SUMMARY" + + # Deliberately NOT a call into oci-images.yml: this job has no login, tag, + # push, sign, or attest step at all, so a pull request -- including one from + # a fork -- has no code path to publication. It only needs `contents: read`. + pr-build-oci: + needs: changed-targets + if: github.event_name == 'pull_request' && needs.changed-targets.outputs.any_oci == 'true' + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.changed-targets.outputs.oci_images) }} + arch: [x86_64, aarch64] + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 + with: + tool: just + - name: Compute OCI Metadata + run: | + echo "OCI_IMAGE_REVISION=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + echo "OCI_IMAGE_CREATED=$(git log -1 --format=%cI)" >> "$GITHUB_ENV" + - name: Build and verify ${{ matrix.image }} (${{ matrix.arch }}) + run: | + BUILD_IMAGE_NAME=${{ matrix.image }} just build + BUILD_IMAGE_NAME=${{ matrix.image }} just verify + + # Same shape for the VM guest: build, checksum, and boot-test only. The + # release upload, SBOM attestation, and release verification live in + # vm-guest.yml and are unreachable from a pull request. + pr-build-vm-guest: + needs: changed-targets + if: github.event_name == 'pull_request' && needs.changed-targets.outputs.vm_guest == 'true' + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 + with: + tool: just + - name: Install qemu-img (raw -> QCOW2 conversion) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends qemu-utils + - name: Build and export the ${{ matrix.arch }} podman-vm guest disk + run: just export-podman-vm-qcow2 + - name: Verify checksums + run: | + set -euo pipefail + shopt -s nullglob + disks=(dist-vm/donate-clanker-vm-*.raw dist-vm/donate-clanker-vm-*.qcow2) + shopt -u nullglob + test "${#disks[@]}" -eq 2 + for d in "${disks[@]}"; do + ( cd dist-vm && sha256sum -c "$(basename "$d").sha256" ) + done + - name: Boot-test the ${{ matrix.arch }} disk under QEMU + uses: ./.github/actions/vm-boot-test + with: + arch: ${{ matrix.arch }} + # Resolve the OCI image matrix from the single canonical manifest # (elements/targets.json) once, so oci-images.yml never hand-maintains its # own copy of the image list. Adding a package requires one entry in that @@ -70,6 +201,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.client_payload.ref || github.ref }} + persist-credentials: false - id: set env: SELECTED_IMAGE: ${{ github.event.inputs.image || '' }} diff --git a/.github/workflows/oci-images.yml b/.github/workflows/oci-images.yml index 5332922..4b506a1 100644 --- a/.github/workflows/oci-images.yml +++ b/.github/workflows/oci-images.yml @@ -48,6 +48,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: tool: just @@ -83,6 +84,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: tool: just @@ -133,8 +135,16 @@ jobs: echo "==> Found per-architecture images for ${IMAGE} at ${POINT_TAG}: ${AVAIL_ARCHS[*]}" - # Rolling/minor-line tags must remain multi-arch: only update them when - # both required architectures were successfully built and published. + # Rolling and minor-line tags must remain multi-arch: only update + # them when both required architectures were successfully built and + # published. If one architecture failed, a single-arch manifest + # would otherwise silently replace the existing multi-arch one for + # every consumer pinned to the minor line. The point-release tag is + # still published, because it is immutable and a partial point + # release is visibly incomplete rather than a regression of a tag + # people already depend on. `just tags` deliberately emits no + # `latest` (see the recipe); the check keeps it guarded anyway so + # re-introducing a rolling alias cannot quietly bypass this rule. BOTH_ARCHS_AVAILABLE=false if [ ${#AVAIL_ARCHS[@]} -eq 2 ]; then BOTH_ARCHS_AVAILABLE=true @@ -144,8 +154,8 @@ jobs: # The FSDK point-release/beta tag is immutable: skip it if already published. FIRST_TAG="" while read -r t; do - if [[ "${t}" == "${MINOR_TAG}" ]] && [[ "${BOTH_ARCHS_AVAILABLE}" != "true" ]]; then - echo "==> skipping ${REPO}:${t} (minor-line tag requires both x86_64 and aarch64; only ${#AVAIL_ARCHS[@]} architecture(s) available)" + if [[ "${t}" == "latest" || "${t}" == "${MINOR_TAG}" ]] && [[ "${BOTH_ARCHS_AVAILABLE}" != "true" ]]; then + echo "==> skipping ${REPO}:${t} (rolling/minor-line tag requires both x86_64 and aarch64; only ${#AVAIL_ARCHS[@]} architecture(s) available)" continue fi if [[ "${t}" == "${POINT_TAG}" ]] && skopeo inspect --no-tags --creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" "docker://${REPO}:${t}" >/dev/null 2>&1; then diff --git a/.github/workflows/vm-guest.yml b/.github/workflows/vm-guest.yml index a06c244..7aa7754 100644 --- a/.github/workflows/vm-guest.yml +++ b/.github/workflows/vm-guest.yml @@ -42,6 +42,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: tool: just @@ -74,41 +75,22 @@ jobs: # tests/vm-boot.sh -- not under Lima, which this guest ships no # cloud-init, SSH or guest agent to satisfy, so a Lima failure never # reliably meant the disk was broken. - - name: Install QEMU and UEFI firmware - run: | - set -euo pipefail - case "${{ matrix.arch }}" in - # QEMU loads efi-virtio.rom for the consumer's `if=virtio` - # topology; ipxe-qemu provides that ROM on both architectures. - x86_64) sudo apt-get install -y --no-install-recommends qemu-system-x86 ovmf ipxe-qemu ;; - aarch64) sudo apt-get install -y --no-install-recommends qemu-system-arm qemu-efi-aarch64 ipxe-qemu ;; - esac - - # GitHub's x86_64 Linux runners expose /dev/kvm, but only to the kvm - # group. Widening it lets the boot test run accelerated instead of - # falling back to TCG. Best effort, and deliberately not a gate: - # tests/vm-boot.sh detects a usable /dev/kvm itself and raises its own - # timeout when it has to fall back to software emulation. - - name: Make /dev/kvm usable by the runner user - if: matrix.arch == 'x86_64' - run: | - { - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - } || echo "note: could not widen /dev/kvm permissions; the boot test will use TCG" - + # # The aarch64 runner is native arm64 but has no nested virtualization, - # so this leg boots under TCG on its own architecture -- slow, but not + # so that leg boots under TCG on its own architecture -- slow, but not # cross-architecture emulation, and it is a real boot of the real disk. - # The alternative, emulating aarch64 on an x86_64 runner, would be - # slower still for no extra coverage, and skipping the leg would let an - # unbootable aarch64 disk reach a release. tests/vm-boot.sh raises its - # own timeout to 1800s when it detects TCG; the job timeout above - # accommodates that. + # Emulating aarch64 on an x86_64 runner would be slower still for no + # extra coverage, and skipping the leg would let an unbootable aarch64 + # disk reach a release. tests/vm-boot.sh raises its own timeout to 1800s + # when it detects TCG; the job timeout above accommodates that. + # + # The setup and the boot itself live in a composite action shared with + # the pull-request gate in build.yml, so the gate cannot drift from the + # check that actually guards publication. - name: Boot-test the ${{ matrix.arch }} disk under QEMU - run: tests/vm-boot.sh + uses: ./.github/actions/vm-boot-test + with: + arch: ${{ matrix.arch }} - name: Generate BuildStream-native SBOM for the VM guest run: just sbom podman-vm @@ -177,6 +159,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: tool: just From 20c116f46b5bcd6919f5efbf908817e617f66b09 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:37:51 -0400 Subject: [PATCH 04/13] fix(ci): give automated dependency PRs real checks Renovate ran with the default GITHUB_TOKEN and the FSDK bump branch was pushed with the workflow's own credentials. Neither can trigger another workflow, so both kinds of automated PR arrived with no checks -- while renovate.json asked for non-major action bumps to be auto-merged. Bumps could merge without a single build ever running, which is how a broken element reaches main unseen. Both now use a Mergeraptor app installation token. Mergeraptor is an org-level app whose permissions are already granted, so this reuses the existing MERGERAPTOR_APP_ID / MERGERAPTOR_PRIVATE_KEY secrets: no PAT, no new secret, no new permission. auto-update-fsdk drops to contents: read because every write now goes through that token. platformAutomerge is disabled so Renovate merges on its own check results rather than GitHub's auto-merge queue, which needs branch protection this repo does not yet have. Major updates keep automerge: false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/auto-update-fsdk.yml | 35 +++++++++++++------ .github/workflows/renovate.yml | 48 +++++++++++++++++++++++--- renovate.json | 3 +- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-update-fsdk.yml b/.github/workflows/auto-update-fsdk.yml index 6f4f7b3..2d76e80 100644 --- a/.github/workflows/auto-update-fsdk.yml +++ b/.github/workflows/auto-update-fsdk.yml @@ -9,15 +9,23 @@ concurrency: group: auto-update-fsdk cancel-in-progress: true +# All writes (branch push, PR creation, repository_dispatch) use a Mergeraptor +# app token, so this workflow needs no write permissions of its own. permissions: - contents: write - pull-requests: write + contents: read jobs: update-fsdk: runs-on: ubuntu-24.04 + timeout-minutes: 60 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # The branch is pushed with the app token below, never with the + # workflow's own credentials: a push made with GITHUB_TOKEN does not + # trigger the build workflow, so the bump PR would sit there with no + # checks on it. + persist-credentials: false - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 with: @@ -43,11 +51,12 @@ jobs: run: | just validate - # PATs are banned org-wide; the default GITHUB_TOKEN cannot trigger - # downstream workflows. Use a Mergeraptor App installation token so the - # repository_dispatch actually starts the build workflow (see - # docs/skills/ci-tooling.md). Requires MERGERAPTOR_APP_ID and - # MERGERAPTOR_PRIVATE_KEY repository secrets to be configured. + # PATs are banned org-wide, and the default GITHUB_TOKEN cannot trigger + # downstream workflows. Mergeraptor is an org-level GitHub App whose + # permissions are already granted, so this mints a short-lived + # installation token from the existing MERGERAPTOR_APP_ID and + # MERGERAPTOR_PRIVATE_KEY secrets -- no PAT, no new secret, no new + # permission (see docs/skills/ci-tooling.md). - name: Get mergeraptor token if: steps.check_changes.outputs.changes == 'true' id: app-token @@ -60,6 +69,7 @@ jobs: if: steps.check_changes.outputs.changes == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail BRANCH="auto/update-fsdk" @@ -73,8 +83,13 @@ jobs: git add elements/freedesktop-sdk.bst git commit -m "chore: bump freedesktop-sdk point release" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - # Force push to overwrite any previous update branch - git push --force origin "$BRANCH" + # Push with the app token, not the workflow's own credentials: a + # branch pushed with GITHUB_TOKEN does not trigger the build + # workflow, so the bump PR would carry no checks at all. Force push + # to overwrite any previous update branch. + git push --force \ + "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" # Create pull request if it doesn't already exist EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) @@ -91,6 +106,6 @@ jobs: # Send a repository dispatch to trigger the multi-arch build workflow on the PR branch echo "==> Sending repository dispatch to test the new FSDK point release..." - gh api repos/${{ github.repository }}/dispatches \ + gh api "repos/${REPOSITORY}/dispatches" \ -f event_type="fsdk-updated" \ -F "client_payload[ref]=refs/heads/$BRANCH" diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 3877d04..257a561 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -1,21 +1,59 @@ name: Renovate +# Renovate runs with a Mergeraptor GitHub App installation token, not the +# default GITHUB_TOKEN. Pushes and pull requests made with GITHUB_TOKEN do not +# trigger other workflows (docs/skills/ci-tooling.md), so Renovate's PRs used +# to arrive with no checks at all while renovate.json asked for them to be +# auto-merged -- dependency bumps could merge without a single build ever +# running. With an app token the pull-request gate in build.yml runs, and +# Renovate's own automerge only merges once those checks are green. +# +# Mergeraptor is an org-level app whose permissions are already granted: this +# consumes the existing MERGERAPTOR_APP_ID / MERGERAPTOR_PRIVATE_KEY secrets. +# No PAT, no new secret, no new permission. +# +# projectbluefin/actions provides reusable-renovate.yml, but it validates its +# token with check-token-health's `required_scopes: repo,workflow`, an OAuth +# scope check that a GitHub App installation token cannot satisfy. Until that +# reusable workflow accepts app tokens, this repo runs Renovate locally. + on: schedule: - cron: '15 2 * * *' workflow_dispatch: + inputs: + dry_run: + description: Log only; create or update nothing + type: boolean + default: false + +concurrency: + group: renovate + cancel-in-progress: false -permissions: - contents: write - pull-requests: write - issues: write +permissions: {} jobs: renovate: runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Get mergeraptor token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.MERGERAPTOR_APP_ID }} + private-key: ${{ secrets.MERGERAPTOR_PRIVATE_KEY }} + - uses: renovatebot/github-action@dd5302ec17783b2fc721b19ae7209b57b1587765 # v46.1.17 with: configurationFile: renovate.json - token: ${{ github.token }} + token: ${{ steps.app-token.outputs.token }} + env: + RENOVATE_DRY_RUN: ${{ inputs.dry_run == true && 'full' || '' }} diff --git a/renovate.json b/renovate.json index bd1f8a1..df46d0c 100644 --- a/renovate.json +++ b/renovate.json @@ -37,7 +37,8 @@ "pinDigest" ], "automerge": true, - "automergeType": "pr" + "automergeType": "pr", + "platformAutomerge": false }, { "matchManagers": [ From 39539a7137c834b895b19296efa9c9c5605bd307 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:42:32 -0400 Subject: [PATCH 05/13] feat(ci): adopt the factory's security and hygiene workflows This repo publishes signed, attested, SBOM-bearing images but ran none of the checks its sibling repos run. - scorecard, actionlint, validate-renovate: thin callers matching dakota and common. actionlint covers .github/actions/ too. - vulnerability-scan: pulls the SPDX SBOM that ships with each published manifest and scans that with Grype. A rootfs scanner sees one package or none on a distroless image, which is why this is not a caller into reusable-vulnerability-scan.yml. Verified end to end against the published base image: 595 packages. - ghcr-cleanup: the org-wide job covers the bluefin, dakota and common families only, so this repo's 7 manifest and 14 per-arch packages were never pruned. Untagged manifests only -- point-release tags are immutable by contract, so keep-n-tagged is deliberately unused. - label-enforcement, the issue templates, and copilot-instructions.md were written but never committed, so org label enforcement was not actually running. The caller now pins @v1 like every other consumer. - cliff.toml, so structured changelogs work and the factory drift check stops flagging this repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug-report.yml | 46 +++++++ .github/ISSUE_TEMPLATE/feature-request.yml | 44 +++++++ .github/ISSUE_TEMPLATE/help-this-project.yml | 64 ++++++++++ .github/copilot-instructions.md | 6 + .github/workflows/actionlint.yml | 38 ++++++ .github/workflows/ghcr-cleanup.yml | 60 +++++++++ .github/workflows/label-enforcement.yml | 17 +++ .github/workflows/scorecard.yml | 46 +++++++ .github/workflows/validate-renovate.yml | 19 +++ .github/workflows/vulnerability-scan.yml | 125 +++++++++++++++++++ cliff.toml | 53 ++++++++ 11 files changed, 518 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/help-this-project.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/actionlint.yml create mode 100644 .github/workflows/ghcr-cleanup.yml create mode 100644 .github/workflows/label-enforcement.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .github/workflows/validate-renovate.yml create mode 100644 .github/workflows/vulnerability-scan.yml create mode 100644 cliff.toml diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..601b33b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,46 @@ +name: "Bug report" +description: "Something is broken. You are the only person who can confirm it." +labels: ["1-triage"] +type: "Bug" +body: + - type: markdown + attributes: + value: | + Run `ujust report` before submitting. It captures your image, kernel, hardware, and logs — paste the gist URL below and we likely have everything we need. + + - type: checkboxes + id: clanker-opt-in + attributes: + label: "Agent assistance" + options: + - label: "Opt-in to making a clanker figure this out" + required: false + + - type: input + id: report-link + attributes: + label: "ujust report gist URL" + placeholder: "https://gist.github.com/..." + validations: + required: false + + - type: textarea + id: what-happened + attributes: + label: "What happened?" + description: "What did you see? What did you expect? Be specific — hardware model, exact error text, what you were doing." + placeholder: | + Bluetooth disappears from the panel after suspend on my Framework 13 AMD. + The indicator is gone until I reboot. journalctl shows "hci0: Bluetooth host wakeup failed". + Expected it to reconnect automatically, as it does on Fedora 42. + validations: + required: true + + - type: textarea + id: extra + attributes: + label: "Extra context" + description: "Optional — upstream bug links, 'works on X but not Y', anything that narrows the cause." + placeholder: "Works fine on the Intel NUC in the same setup. Appears on both 6.14 and 6.15 kernels." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..dc13ec5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,44 @@ +name: "Feature request" +description: "Propose something new. Specific proposals ship. Vague ones wait." +labels: ["1-triage"] +type: "Feature" +body: + - type: markdown + attributes: + value: | + The bottleneck is almost never implementation — it is clarity. Contributors can ship well-scoped work fast, but only if the finish line is clear. + + - type: checkboxes + id: clanker-opt-in + attributes: + label: "Agent assistance" + options: + - label: "Opt-in to making a clanker figure this out" + required: false + + - type: textarea + id: problem + attributes: + label: "What would you like to see?" + description: "What is missing, broken, or annoying? Be specific — name the command, the file, the workflow step." + placeholder: "Add this feature or ask for a design review" + validations: + required: true + + - type: textarea + id: solution + attributes: + label: "What does done look like?" + description: "What command do you run? What do you see? What file changes?" + placeholder: "I should be able to set up Docker in one command" + validations: + required: true + + - type: textarea + id: extra + attributes: + label: "Extra context" + description: "Optional — related issues, upstream references, or files you think are affected." + placeholder: "related issues, upstream references, or files you think are affected" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/help-this-project.yml b/.github/ISSUE_TEMPLATE/help-this-project.yml new file mode 100644 index 0000000..fbfc3dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help-this-project.yml @@ -0,0 +1,64 @@ +name: "Help this project" +description: "Donate agent time — point at a repo, issue, or PR and get a high-signal report back." +labels: ["1-triage"] +body: + - type: markdown + attributes: + value: | + Point an agent at a repo, issue, or PR and get a report back. Be specific — "look at this repo" produces a shallow overview, a focused question produces something actionable. + + - type: checkboxes + id: clanker-opt-in + attributes: + label: "Agent assistance" + options: + - label: "Opt-in to making a clanker figure this out" + required: false + + - type: input + id: target-url + attributes: + label: "Target URL" + description: "Repo, issue, PR, roadmap, or docs page. Must be publicly accessible." + placeholder: "https://github.com/owner/repo" + validations: + required: true + + - type: dropdown + id: flow + attributes: + label: "What kind of help?" + description: "This routes the right agent to the right task." + options: + - "Project report — survey a repo or org and summarize what needs attention" + - "Issue review — read a linked issue and recommend next steps" + - "PR review — audit a linked PR for correctness and completeness" + validations: + required: true + + - type: textarea + id: goal + attributes: + label: "What specifically should the agent focus on?" + description: "The more specific you are, the more useful the report. One to three sentences." + placeholder: | + Check whether the CI pipeline for this repo has any reliability issues that would + explain the flaky validate failures we have been seeing. Focus on the BST cache + interactions and the bst2 container pin check. + validations: + required: true + + - type: textarea + id: context + attributes: + label: "Extra context" + description: "Optional. Known problem areas, related Dakota issues, or constraints the agent should respect." + placeholder: "Related to #503. The agent should not suggest changes that require a new external dependency." + validations: + required: false + + - type: markdown + attributes: + value: | + --- + Hive will route this to the right agent based on the flow type above. The agent files its report as a comment on this issue. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..33e9032 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,6 @@ +# FSDK Containers Copilot Instructions + +Read [`AGENTS.md`](../AGENTS.md) and `docs/skills/README.md` first. +Use the shared [label workflow](https://github.com/projectbluefin/common/blob/main/docs/skills/label-workflow.md): +humans triage and approve, agents claim `status/queued`, and Clankers only +transports Hive assignments. Never write to `ublue-os/*`. diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..0bd4b6e --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,38 @@ +name: actionlint + +# The delivery pipeline is ~25KB of workflow YAML plus a composite action, and +# a typo in it is only discovered by a red release run. Lint it like code. + +on: + pull_request: + paths: + - '.github/workflows/**' + - '.github/actions/**' + push: + branches: [main] + paths: + - '.github/workflows/**' + - '.github/actions/**' + +permissions: {} + +jobs: + actionlint: + name: Lint GitHub Actions + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + # reviewdog's github-pr-check reporter annotates the PR. + checks: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run actionlint + uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 + with: + reporter: github-pr-check + fail_on_error: true diff --git a/.github/workflows/ghcr-cleanup.yml b/.github/workflows/ghcr-cleanup.yml new file mode 100644 index 0000000..6be66e3 --- /dev/null +++ b/.github/workflows/ghcr-cleanup.yml @@ -0,0 +1,60 @@ +name: GHCR cleanup + +# This repo publishes 7 manifest packages plus 14 per-architecture staging +# packages (base-x86_64, base-aarch64, ...), and every FSDK point release adds +# another layer set to each. The org-wide cleanup in projectbluefin/actions +# covers the bluefin, dakota and common families only, so nothing here was +# ever pruned. +# +# Untagged manifests ONLY. FSDK point-release tags are immutable by contract +# (docs/skills/container-standards.md): a consumer pinned to :25.08.13 must +# still be able to pull it, so this never uses keep-n-tagged -- which is also +# why it calls the upstream action directly instead of the shared +# projectbluefin/actions composite, whose keep-n-tagged default would prune +# published point releases. + +on: + schedule: + - cron: '0 4 * * 1' # Mondays 04:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: List what would be deleted, delete nothing + type: boolean + default: true + +permissions: {} + +concurrency: + group: ghcr-cleanup + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Derived from the canonical manifest, so a new image is covered the day + # it is added instead of whenever someone remembers this file exists. + - id: packages + run: | + set -euo pipefail + LIST="$(jq -r '[.oci_images[] | ., "\(.)-x86_64", "\(.)-aarch64"] | join(",")' elements/targets.json)" + echo "==> packages: ${LIST}" + echo "list=${LIST}" >> "$GITHUB_OUTPUT" + + - name: Prune untagged images + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1 + with: + packages: ${{ steps.packages.outputs.list }} + delete-untagged: true + older-than: 90 days + dry-run: ${{ inputs.dry_run || false }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/label-enforcement.yml b/.github/workflows/label-enforcement.yml new file mode 100644 index 0000000..12109ab --- /dev/null +++ b/.github/workflows/label-enforcement.yml @@ -0,0 +1,17 @@ +name: enforce workflow labels + +on: + issues: + types: [opened, edited, labeled, unlabeled] + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + enforce: + uses: projectbluefin/actions/.github/workflows/reusable-design-enforcement.yml@v1 + secrets: inherit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..6788a96 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,46 @@ +name: Scorecard supply-chain security + +# Thin caller pattern shared with projectbluefin/dakota and +# projectbluefin/common. The results land in the code-scanning dashboard and +# on the OpenSSF badge. + +on: + # For the Branch-Protection check; only the default branch is supported. + branch_protection_rule: + # Keeps the Maintained check fresh. + schedule: + - cron: '17 9 * * 1' + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + # Upload the results to the code-scanning dashboard. + security-events: write + # Publish results and get a badge (ossf/scorecard-action#publishing-results). + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/validate-renovate.yml b/.github/workflows/validate-renovate.yml new file mode 100644 index 0000000..d6d927d --- /dev/null +++ b/.github/workflows/validate-renovate.yml @@ -0,0 +1,19 @@ +name: Validate Renovate Config +# Thin caller -- logic lives in projectbluefin/actions/reusable-validate-renovate.yml + +on: + pull_request: + paths: + - "renovate.json" + push: + branches: [main] + paths: + - "renovate.json" + +permissions: {} + +jobs: + validate: + permissions: + contents: read + uses: projectbluefin/actions/.github/workflows/reusable-validate-renovate.yml@v1 diff --git a/.github/workflows/vulnerability-scan.yml b/.github/workflows/vulnerability-scan.yml new file mode 100644 index 0000000..9575160 --- /dev/null +++ b/.github/workflows/vulnerability-scan.yml @@ -0,0 +1,125 @@ +name: Vulnerability Scan + +# Distroless images have no RPM or dpkg database, so a rootfs scanner sees +# one package or none (docs/skills/signing-and-sbom.md). What this repo does +# have is an authoritative BuildStream-native SPDX SBOM, generated from the +# build graph and attached to every published manifest as a signed referrer. +# So the scan reads that: pull the SBOM that ships with the image and let +# Grype match it against the vulnerability database. +# +# Deliberately not a caller into projectbluefin/actions' +# reusable-vulnerability-scan.yml: that workflow scans an image ref with +# Grype's rootfs cataloguers, which is exactly the approach that reports +# nothing useful here. + +on: + schedule: + - cron: '0 8 * * 1' # Monday 08:00 UTC -- newly disclosed CVEs against shipped images + workflow_dispatch: + inputs: + tag: + description: "Image tag to scan (default: the current FSDK point release)" + required: false + default: "" + +permissions: {} + +concurrency: + group: vulnerability-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + images: ${{ steps.set.outputs.images }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - id: set + run: echo "images=$(jq -c '.oci_images' elements/targets.json)" >> "$GITHUB_OUTPUT" + + scan: + needs: matrix + name: Grype -- ${{ matrix.image }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.matrix.outputs.images) }} + permissions: + contents: read + packages: read + security-events: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2 + with: + tool: just + - name: Install ORAS + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + + - name: Pull the published SBOM for ${{ matrix.image }} + id: sbom + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + IMAGE: ${{ matrix.image }} + TAG_OVERRIDE: ${{ inputs.tag }} + REGISTRY: ghcr.io/${{ github.repository_owner }} + run: | + set -euo pipefail + oras login ghcr.io -u "${ACTOR}" --password-stdin <<<"${GH_TOKEN}" + TAG="${TAG_OVERRIDE:-$(just tags | tail -1)}" + REF="${REGISTRY}/${IMAGE}:${TAG}" + echo "==> discovering SBOM referrers of ${REF}" + + # The SBOM is attached to the manifest list digest, so resolve that + # first -- referrers hang off the digest, never off the tag. + DIGEST="$(skopeo inspect --no-tags "docker://${REF}" | jq -r '.Digest')" + SBOM_DIGEST="$(oras discover --format json "${REGISTRY}/${IMAGE}@${DIGEST}" \ + | jq -r '[.referrers[]? | select(.artifactType == "application/vnd.spdx+json")] | last | .digest // empty')" + + if [[ -z "${SBOM_DIGEST}" ]]; then + echo "::warning::no SPDX SBOM referrer found on ${REF} -- nothing to scan" + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + mkdir -p sbom + oras pull "${REGISTRY}/${IMAGE}@${SBOM_DIGEST}" --output sbom + FILE="$(find sbom -name '*.spdx.json' | head -1)" + test -n "${FILE}" + echo "==> scanning ${FILE} ($(jq '.packages | length' "${FILE}") packages)" + { + echo "found=true" + echo "file=${FILE}" + } >> "$GITHUB_OUTPUT" + + - name: Scan the SBOM with Grype + if: steps.sbom.outputs.found == 'true' + id: grype + uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0 + with: + sbom: ${{ steps.sbom.outputs.file }} + output-format: sarif + # Report, never gate: a newly disclosed CVE in an FSDK component is + # fixed upstream in freedesktop-sdk, not by failing this repo's + # scheduled scan. + fail-build: false + severity-cutoff: medium + by-cve: true + + - name: Upload SARIF to code scanning + if: steps.sbom.outputs.found == 'true' + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + sarif_file: ${{ steps.grype.outputs.sarif }} + category: grype-${{ matrix.image }} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..b7eefcf --- /dev/null +++ b/cliff.toml @@ -0,0 +1,53 @@ +# git-cliff configuration for projectbluefin/fsdk-containers +# +# Generates structured changelogs from Conventional Commits. +# Consumed by the release workflow (reusable-release.yml). +# +# Usage in release.yml: +# - name: Install git-cliff +# uses: taiki-e/install-action@ +# with: +# tool: git-cliff +# - name: Generate changelog +# run: git cliff --latest --strip header > CHANGELOG.md + +[changelog] +header = "" +body = """ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.scope %} (**{{ commit.scope }}**){% endif %}\ + ({{ commit.id | truncate(length=7, end="") }})\ +{% endfor %} +{% endfor %} +""" +footer = """ +--- +*Generated by [git-cliff](https://git-cliff.org)* +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^ci", group = "CI/CD" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore", group = "Miscellaneous" }, + { message = "^build", group = "Build" }, + { message = "^test", group = "Testing" }, + { message = "^revert", group = "Reverts" }, + # Skip merge commits + { message = "^Merge", skip = true }, +] +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "newest" From 1ec7a4ab4e4756a62192f9be5ad76f093891b10a Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:45:58 -0400 Subject: [PATCH 06/13] docs(ci-tooling): document the gate, the token rule, and the scan shape Every session leaves the next agent the rules it learned, not just the code (AGENTS.md). This records what changed and, more importantly, why the obvious shortcuts are wrong: - the pull-request gate, the targets.json path-ownership keys it reads, and why the PR jobs duplicate a few steps instead of calling the reusable publish workflows behind an `if:` - that every automated write goes through the org-level Mergeraptor app, that this needs no PAT and no new permission, and why the shared reusable-renovate.yml cannot be used yet - why a rootfs scanner is the wrong tool here and what vulnerability-scan.yml does instead - the branch-protection settings the gate needs to actually block a merge - reconciles the SBOM skill's "never pip install in a loop" rule with the per-image fan-out, where one SBOM per job is correct Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/ci-tooling.md | 129 +++++++++++++++++++++++++++++++- docs/skills/signing-and-sbom.md | 16 +++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/docs/skills/ci-tooling.md b/docs/skills/ci-tooling.md index cf5407d..d9d85d2 100644 --- a/docs/skills/ci-tooling.md +++ b/docs/skills/ci-tooling.md @@ -91,13 +91,30 @@ other in the Actions UI: | File | Called by | Purpose | |---|---|---| -| `build.yml` | GitHub triggers | `validate` (PR gate), `matrix` (resolves the OCI image list once), fans out one `oci-images.yml` call per image, calls `vm-guest.yml`, then a `summary` job | +| `build.yml` | GitHub triggers | `validate` + the pull-request build gate (`changed-targets`, `pr-build-oci`, `pr-build-vm-guest`), `matrix` (resolves the OCI image list once), fans out one `oci-images.yml` call per image, calls `vm-guest.yml`, then a `summary` job | | `oci-images.yml` | `build.yml` via `workflow_call`, `image` input | `build` + `manifest` jobs for exactly one OCI distroless image | | `vm-guest.yml` | `build.yml` via `workflow_call` | `build` job (matrix arch) for the podman-vm guest disk lane | +| `.github/actions/vm-boot-test` | `vm-guest.yml` and `build.yml` | composite action: install QEMU + UEFI firmware for one arch and run `tests/vm-boot.sh`, so the PR gate cannot drift from the release check | + +Supporting workflows, none of which touch publication: + +| File | Trigger | Purpose | +|---|---|---| +| `actionlint.yml` | PR/push touching `.github/workflows/**` or `.github/actions/**` | lints the pipeline itself | +| `validate-renovate.yml` | PR/push touching `renovate.json` | thin caller into `projectbluefin/actions` | +| `scorecard.yml` | weekly, push to `main`, branch-protection changes | OpenSSF Scorecard into code scanning | +| `vulnerability-scan.yml` | weekly, dispatch | Grype over the **published SPDX SBOM**, not the rootfs (see below) | +| `ghcr-cleanup.yml` | weekly, dispatch | prunes untagged manifests for this repo's packages only | +| `renovate.yml` | nightly, dispatch | Renovate, running with a Mergeraptor app token | +| `auto-update-fsdk.yml` | nightly, dispatch | FSDK bump branch + PR + verification dispatch | +| `label-enforcement.yml` | issue/PR events | shared factory label lifecycle | | Job | Trigger | Purpose | |---|---|---| | `validate` (`build.yml`) | `pull_request` only | `bst show` element graph resolution, no build | +| `changed-targets` (`build.yml`) | `pull_request` only | `just changed-targets` against the merge base: which images and/or the VM guest this PR can break | +| `pr-build-oci` (`build.yml`) | `pull_request`, affected images only | build + `just verify` per affected image per architecture. No login, push, sign, or attest step exists in this job | +| `pr-build-vm-guest` (`build.yml`) | `pull_request`, when the VM guest is affected | build, checksum, and QEMU boot-test the guest disk. No release upload exists in this job | | `matrix` (`build.yml`) | not on `pull_request` | reads `elements/targets.json` (`just image-matrix`) once and optionally narrows it to a validated manual-dispatch image | | `oci-images` (`build.yml`) | after `matrix` | matrix-calls `oci-images.yml` once per selected image | | `build` (`oci-images.yml`) | called for `push`/`workflow_dispatch`/`repository_dispatch` | matrix per architecture (x86_64 + aarch64) for that one image: build + verify + tag-push | @@ -154,6 +171,103 @@ deliberately excluded from the OCI publishing matrix — see docs/skills/vm-podman-guest.md for the VM guest's own build/test/publish pipeline. +### The pull-request build gate + +`just validate` only resolves the element graph. For a long time that was the +*only* thing a PR ran, so a PR could be green in 45 seconds while the merge +commit turned `main` red — which is exactly what happened, on push after push, +because the two failures (a `tar` ownership error in `lab-runner/just.bst` and +a QEMU TCG assertion on the aarch64 boot test) only existed in jobs that never +ran before merge. + +Building all seven images on two architectures per PR is not viable, so the +gate is scoped by path ownership declared in `elements/targets.json`: + +| Key | Meaning | +|---|---| +| `image_paths` | prefixes each image owns; touching one selects that image | +| `shared_paths` | project-wide files (`project.conf`, `include/`, the FSDK junction, `Justfile`, workflows) — these select `canary_image` instead of all seven | +| `canary_image` | the image every other image is carved from (`base`) | +| `vm_guest_paths` | selects the VM guest build + boot test | + +`just changed-targets BASE HEAD` resolves them against the **merge base** and +prints `{"oci_images":[...],"vm_guest":bool}`. Run it locally to predict what a +branch will build. Add a new image's paths there in the same commit that adds +it to `oci_images`, or its PRs will silently build nothing. + +**The PR jobs deliberately do not call `oci-images.yml` or `vm-guest.yml`.** +They are separate jobs with `permissions: contents: read` and no login, tag, +push, sign, attest, or release step anywhere in them. Gating publication with +an `if:` inside a shared job means one edit away from a fork PR publishing to +GHCR; gating it by *not having the code path* does not. Keep it that way. + +### Automation tokens — everything that writes uses Mergeraptor + +Neither a push nor a PR made with the default `GITHUB_TOKEN` triggers another +workflow. That is not a style point: Renovate ran with `github.token` while +`renovate.json` asked for non-major action bumps to be auto-merged, so bumps +could merge with **no checks having run at all**, and the nightly FSDK bump +branch was pushed the same way. + +Every automated write — the FSDK bump branch push, its PR, its +`repository_dispatch`, and Renovate itself — mints a Mergeraptor installation +token. Mergeraptor is an org-level GitHub App whose permissions are already +granted, so this is a reuse of the existing `MERGERAPTOR_APP_ID` / +`MERGERAPTOR_PRIVATE_KEY` secrets: **never request a PAT, a new secret, or new +permissions for this.** Workflows that write only through that token drop their +own `permissions:` to `contents: read`. + +Push with the token explicitly, because `persist-credentials: false` is now the +default for every checkout: + +```bash +git push --force \ + "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" +``` + +`projectbluefin/actions` also ships `reusable-renovate.yml`, but it validates +its token with `check-token-health`'s `required_scopes: repo,workflow` — an +OAuth scope check a GitHub App installation token cannot satisfy. Until that +changes, this repo runs Renovate locally rather than reintroducing a PAT. + +`platformAutomerge` is disabled in `renovate.json`: GitHub's auto-merge queue +needs branch protection this repo does not have, so Renovate merges on its own +check results. Major updates keep `automerge: false`. + +### Scanning a distroless image + +Do not add a rootfs vulnerability scanner. There is no RPM or dpkg database in +these images, so Grype/Trivy against the image ref report one package or none +(docs/skills/signing-and-sbom.md). `vulnerability-scan.yml` instead resolves +the manifest-list digest with `skopeo`, finds the SPDX referrer that the +publish pipeline attached with `oras discover --format json` (`.referrers[]`, +not `.manifests[]`), pulls it, and scans that. It reports; it never gates — a +CVE in an FSDK component is fixed by bumping FSDK, not by failing this repo. + +### Required repository settings (admin, not in git) + +The build gate only helps if merges are blocked when it fails, and `main` +currently has **no branch protection at all** (`GET /branches/main/protection` +returns 404). A repo admin needs to configure, once: + +- Require a pull request before merging, with at least one approving review. +- Require status checks to pass, and mark these required: + `validate`, `changed-targets`, `actionlint`, and the gate jobs + (`pr-build-oci` / `pr-build-vm-guest` — both are skipped, and therefore + green, when the diff does not affect them). +- Require branches to be up to date before merging (so the gate runs against + what will actually land). +- Require linear history; block force pushes and deletions on `main`. + +A merge queue is optional here. If one is enabled, note the caveat from +`projectbluefin/actions`' `reusable-renovate-automerge.yml`: queue entries +created by `github-actions[bot]` never dispatch required checks, so the queue +wedges unless the merge is performed with an app token. + +Until protection exists, treat the gate as advisory: it will still turn a PR +red, but nothing stops a merge. + ### Point-release tag immutability FSDK point-release tags (e.g. `:25.08.13`) are immutable once published. Both @@ -185,18 +299,31 @@ container build failure from canceling unrelated container builds. |---|---| | "It's just a minor version tag, supply-chain risk is low." | One compromised tag push owns every repo using it. Pin to SHA. | | "I'll check what SHA other repos use later." | Check now — it's one `gh api` call and takes 10 seconds. | +| "`just validate` passes, the PR is fine." | Graph resolution is not a build. Every red `main` push in this repo's history was green at PR time for exactly this reason. | +| "Building on PRs is too expensive." | Building *everything* is. The gate builds only what the diff can break, and a shared-path change builds one canary. | +| "The publish step is skipped on PRs anyway." | An `if:` is one careless edit from being wrong. PR jobs have no publish code path at all. | +| "GITHUB_TOKEN is fine for the bot's push." | It cannot trigger workflows, so the resulting PR carries no checks — and Renovate was set to auto-merge those. | +| "Mergeraptor needs new permissions for that." | It is an org-level app; the permissions and secrets already exist. Reuse them. | ## Red Flags - Any `uses:` line with a mutable ref (`@v2`, `@main`, `@latest`) - `sudo podman` in one job and plain `podman` in another job doing the same operation - A new action not present in any sibling repo — check upstream first +- A publish, sign, or release step reachable from a `pull_request` event +- An automated push, PR, or dispatch using `secrets.GITHUB_TOKEN` instead of a Mergeraptor token +- A new image added to `oci_images` without a matching `image_paths` entry — its PRs would build nothing +- `actions/checkout` without `persist-credentials: false` in a job that does not push +- A rootfs vulnerability scanner pointed at a distroless image ref ## Verification - [ ] Every `uses:` line has a full 40-char SHA and a `# vX` comment +- [ ] `actionlint` passes (`actionlint` locally, or the `actionlint` workflow) - [ ] `just verify` passes locally (or in CI) after workflow changes +- [ ] `just changed-targets HEAD` selects the targets you expect - [ ] No new mutable action refs introduced +- [ ] No new secret name: automation writes go through Mergeraptor ### GitHub artifact attestations diff --git a/docs/skills/signing-and-sbom.md b/docs/skills/signing-and-sbom.md index c339be9..cef5a28 100644 --- a/docs/skills/signing-and-sbom.md +++ b/docs/skills/signing-and-sbom.md @@ -107,6 +107,20 @@ Always cache the pip wheel for `buildstream-sbom` in CI, pinned to the exact com ### 5. Multi-image SBOM Optimization (Speed + Uniqueness) To generate SBOMs for multiple images efficiently and correctly in CI: - **Avoid calling `pip install` inside GHA loops.** Running `pip install` inside a loop for each container spins up the container multiple times and repeats dependency resolution. -- Use `just sboms` (plural) to spin up the BuildStream container **once**, install `buildstream-sbom` **once**, and generate SBOMs for all target images in a single run. +- Use `just sboms` (plural) **when one job generates several SBOMs**. Since the + per-image fan-out (`oci-images.yml` is called once per image), each `manifest` + job legitimately generates exactly one SBOM with `just sbom `; that is + one container start per job, not a loop, and the pinned pip cache below covers + the install. Reach for `sboms` again only if a single job ever needs more than + one image's SBOM. - **Enforce Unique SPDX Namespaces.** Ensure each image variant receives a unique SPDX document namespace (as required by the SPDX spec) by appending the image name to the namespace URL: `https://github.com/projectbluefin/fsdk-containers/sbom/${GIT_SHA}/${SPDX_NAME}`. +### 6. The SBOM is the vulnerability-scan input +`vulnerability-scan.yml` consumes what this pipeline publishes: it resolves the +manifest-list digest with `skopeo`, finds the SPDX referrer with `oras discover +--format json` (the referrer list is `.referrers[]`), pulls it, and scans it +with Grype. This is the only scan shape that works here — a rootfs scanner sees +one package or none. It also means a break in SBOM attachment silently degrades +scanning, so treat a "no SPDX SBOM referrer found" warning as a publish bug, +not a scanner quirk. + From 55e69afa47dd365acc7270df79254f87e2052ba9 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:49:34 -0400 Subject: [PATCH 07/13] fix(ci): pin label enforcement to the only ref that has the workflow reusable-design-enforcement.yml does not exist on projectbluefin/actions main or at the v1 tag -- only at 67d4cfb -- so the @v1 caller failed immediately. Pin the full commit SHA and record why it deviates from the factory's @v1 convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/label-enforcement.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-enforcement.yml b/.github/workflows/label-enforcement.yml index 12109ab..fb573d3 100644 --- a/.github/workflows/label-enforcement.yml +++ b/.github/workflows/label-enforcement.yml @@ -13,5 +13,5 @@ permissions: jobs: enforce: - uses: projectbluefin/actions/.github/workflows/reusable-design-enforcement.yml@v1 + uses: projectbluefin/actions/.github/workflows/reusable-design-enforcement.yml@67d4cfb597e331448e31047a380439bdeee91865 # unreleased: not on actions@main or @v1 secrets: inherit From 0f8e801ed56b025d4501d07527a3f540baa786ca Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 19:56:08 -0400 Subject: [PATCH 08/13] ci: drop the label-enforcement caller until upstream is usable reusable-design-enforcement.yml is not on projectbluefin/actions main or v1, and at the only commit that has it the nested design-enforcement action ref is unreachable, so the job fails during 'Set up job' on every issue and pull request event. A permanently red required-looking check is worse than no check. Tracked upstream in projectbluefin/actions#373. The issue templates and copilot-instructions.md that the workflow was meant to enforce are still committed here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/label-enforcement.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/workflows/label-enforcement.yml diff --git a/.github/workflows/label-enforcement.yml b/.github/workflows/label-enforcement.yml deleted file mode 100644 index fb573d3..0000000 --- a/.github/workflows/label-enforcement.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: enforce workflow labels - -on: - issues: - types: [opened, edited, labeled, unlabeled] - pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] - -permissions: - contents: read - issues: write - pull-requests: read - -jobs: - enforce: - uses: projectbluefin/actions/.github/workflows/reusable-design-enforcement.yml@67d4cfb597e331448e31047a380439bdeee91865 # unreleased: not on actions@main or @v1 - secrets: inherit From 33d84f1568057dfda922e858db25418382fd567c Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 20:59:31 -0400 Subject: [PATCH 09/13] fix(lab-runner): declare nginx's build tools and stop gunzip in place The gate added in this PR immediately found two more breakages behind the just.bst one, both in lab-runner, both invisible before because nothing built a PR. nginx.bst: nginx's auto/ scripts and generated objs/Makefile shell out to sed, grep and awk without checking for them. With none of them declared the configure step silently produced an objs/Makefile with an empty object list and the build died at link time with "cc: fatal error: no input files", alongside "sed: command not found". Declare them, plus coreutils. argo.bst: `gunzip argo.gz` rewrites the staged source in place, and gzip refuses when the file has other links ("argo.gz has 1 other link -- file ignored", exit 2). Decompress to a new file instead. Both verified building against the remote-execution grid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- elements/lab-runner/argo.bst | 5 ++++- elements/lab-runner/nginx.bst | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/elements/lab-runner/argo.bst b/elements/lab-runner/argo.bst index 3f2c6f7..2d7dba4 100644 --- a/elements/lab-runner/argo.bst +++ b/elements/lab-runner/argo.bst @@ -24,7 +24,10 @@ sources: config: install-commands: - | - gunzip argo.gz + # Decompress to a new file rather than in place: BuildStream may stage + # the source as a hardlink, and gzip refuses to touch a file with other + # links ("argo.gz has 1 other link -- file ignored", exit 2). + gunzip -c argo.gz > argo mkdir -p "%{install-root}/usr/bin" cp argo "%{install-root}/usr/bin/argo" chmod 755 "%{install-root}/usr/bin/argo" diff --git a/elements/lab-runner/nginx.bst b/elements/lab-runner/nginx.bst index 913a44a..f60a405 100644 --- a/elements/lab-runner/nginx.bst +++ b/elements/lab-runner/nginx.bst @@ -12,6 +12,15 @@ build-depends: - freedesktop-sdk.bst:bootstrap/pcre2.bst - freedesktop-sdk.bst:components/openssl.bst - freedesktop-sdk.bst:components/zlib.bst + # nginx's auto/ configure scripts and the generated objs/Makefile shell out + # to sed, grep and awk without checking for them, so a missing one silently + # produces an objs/Makefile with an empty object list and the build dies at + # link time with "cc: fatal error: no input files". Declare every tool the + # build runs (docs/skills/ci-tooling.md, "Build-time utility dependencies"). + - freedesktop-sdk.bst:components/sed.bst + - freedesktop-sdk.bst:components/grep.bst + - freedesktop-sdk.bst:components/gawk.bst + - freedesktop-sdk.bst:bootstrap/coreutils.bst variables: strip-binaries: "" From 0bf11838538d425c8883741dc09f11c60cbbb3d5 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 21:35:46 -0400 Subject: [PATCH 10/13] fix(lab-runner): install nginx into /usr/bin, not /usr/sbin freedesktop-sdk is a merged-usr sysroot: /usr/sbin is a symlink to /usr/bin, so an element that installs a real usr/sbin directory fails at lab-runner-runtime.bst staging with 'Destination is a symlink, not a directory: /usr/sbin'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- elements/lab-runner/nginx.bst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/elements/lab-runner/nginx.bst b/elements/lab-runner/nginx.bst index f60a405..9340560 100644 --- a/elements/lab-runner/nginx.bst +++ b/elements/lab-runner/nginx.bst @@ -28,9 +28,13 @@ variables: config: configure-commands: - | + # --sbin-path lands in /usr/bin, not /usr/sbin: freedesktop-sdk is a + # merged-usr sysroot where /usr/sbin is a symlink to /usr/bin, and + # staging a real usr/sbin directory fails with "Destination is a + # symlink, not a directory: /usr/sbin". ./configure \ --prefix=/usr \ - --sbin-path=/usr/sbin/nginx \ + --sbin-path=/usr/bin/nginx \ --conf-path=/etc/nginx/nginx.conf \ --pid-path=/run/nginx.pid \ --lock-path=/run/nginx.lock \ From a066f47aaf2f8d030627a99c78a2ef90f7a81fdb Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 21:48:01 -0400 Subject: [PATCH 11/13] fix(verify): raise the lab-runner size ceiling to match reality The gate in this PR is the first thing to actually build and verify lab-runner before merge, and it found the image at 497MB against a 320MB budget. argo v4.0.8 is 181MB uncompressed on its own and kubectl is 57MB; stripping is deliberately disabled for both. The argo v3 -> v4 bump (#22) added roughly 100MB and merged with no checks, because Renovate was running with GITHUB_TOKEN and lab-runner was independently broken, so no build ever reached this gate. Raise the ceiling so it reflects the image that exists; #48 tracks actually slimming it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Justfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 0dd33d6..d770ec9 100644 --- a/Justfile +++ b/Justfile @@ -331,7 +331,15 @@ verify: python) MAX_BYTES=$((144 * 1024 * 1024)) ;; qemu-img) MAX_BYTES=$((192 * 1024 * 1024)) ;; buildah) MAX_BYTES=$((256 * 1024 * 1024)) ;; - lab-runner) MAX_BYTES=$((320 * 1024 * 1024)) ;; + # lab-runner is the documented shell-enabled exception, and its CLI + # contract (argo, just, kubectl) is dominated by two unstrippable Go + # binaries: argo v4.0.8 alone is 181MB uncompressed and kubectl is + # 57MB. Stripping is deliberately disabled for those elements + # (strip-binaries: ""), so the ceiling has to accommodate them. The + # argo v3 -> v4 bump added ~100MB and merged unverified, which is what + # first pushed this image over its old 320MB budget; see + # https://github.com/projectbluefin/fsdk-containers/issues/48. + lab-runner) MAX_BYTES=$((544 * 1024 * 1024)) ;; *) echo "FAIL: no size threshold configured for $IMG" >&2; exit 1 ;; esac SIZE_BYTES=$({{sudo_cmd}} podman image inspect --format '{{"{{.Size}}"}}' "$REF") From 92af1cf05606476987d8ed35314ff4d749fe0b2b Mon Sep 17 00:00:00 2001 From: castrojo Date: Sat, 1 Aug 2026 22:18:48 -0400 Subject: [PATCH 12/13] docs(add-fsdk-component): record the four manual-element failure modes Every one of these was hit in this PR, in lab-runner, and none of them says what it means: a tar ownership error, gunzip refusing a hardlinked source, a link failure whose real cause is an undeclared sed, and a staging failure caused by merged-usr. Written down so the next agent recognises them from the error text instead of rediscovering them one CI run at a time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/add-fsdk-component.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/skills/add-fsdk-component.md b/docs/skills/add-fsdk-component.md index 376f2ae..8de2176 100644 --- a/docs/skills/add-fsdk-component.md +++ b/docs/skills/add-fsdk-component.md @@ -120,6 +120,33 @@ stack: just bst show --deps run cloud-init/cloud-init-stack.bst | grep # expect no match ``` +## 6. Manual-element failure modes that look like something else + +Every one of these was found in a single session, in `elements/lab-runner/*`, +and none of them says what it means. Recognise them by their error text: + +| Error | Real cause | Fix | +|---|---|---| +| `tar: X: Cannot change ownership to uid N, gid N: Invalid argument`, then `tar: Exiting with failure status` | The release tarball records a build-machine uid/gid. The sandbox cannot restore it, so `tar` exits non-zero even though the member extracted correctly. | `tar -xzf archive.tar.gz --no-same-owner member` | +| `gzip: X.gz has 1 other link -- file ignored` (exit 2) | `gunzip` rewrites in place, and BuildStream may stage the source as a hardlink. | `gunzip -c X.gz > X` — never decompress a staged source in place | +| `cc: fatal error: no input files` together with `sh: sed: command not found` | A configure script that shells out to `sed`/`grep`/`awk` *without checking for them*, generating a Makefile with an empty object list. The link failure is the symptom; the missing tool is the cause. | Declare every tool the build runs in `build-depends` (`components/sed.bst`, `components/grep.bst`, `components/gawk.bst`, `bootstrap/coreutils.bst`) | +| `FAILURE Staging dependencies` / `Destination is a symlink, not a directory: /usr/sbin` | freedesktop-sdk is a merged-usr sysroot: `/usr/sbin`, `/bin`, `/lib` are symlinks. An element that installs a *real* directory there cannot be staged. | Install into `/usr/bin` (e.g. autotools `--sbin-path=/usr/bin/foo`) | + +The general rule behind all four: **a manual element's sandbox contains only +what you declared**, and the tools it lacks usually fail silently before the +step that actually reports an error. When a build fails at link or staging +time, read upward in the log for a `command not found` first. + +Verify a fix against the real thing, not the graph: + +``` +just bst build lab-runner/nginx.bst # the element alone +just bst build lab-runner/lab-runner-runtime.bst # its staging into the compose +BUILD_IMAGE_NAME=lab-runner just build && BUILD_IMAGE_NAME=lab-runner just verify +``` + +`just validate` resolves the graph and would have passed for all four. + ## Cloud-init specifics (for anyone extending this work) - FSDK does **not** package `jsonpatch`, `jsonpointer`, `configobj`, From 1f128059535a51c7b5c71b7a7f6ef62af36f6179 Mon Sep 17 00:00:00 2001 From: castrojo Date: Sun, 2 Aug 2026 09:45:39 -0400 Subject: [PATCH 13/13] fix(ci): close pull request gate review gaps Pin the Renovate validator, gate VM boot-action changes, and keep CI workflow documentation accurate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/validate-renovate.yml | 2 +- docs/skills/ci-tooling.md | 1 - elements/targets.json | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate-renovate.yml b/.github/workflows/validate-renovate.yml index d6d927d..48be081 100644 --- a/.github/workflows/validate-renovate.yml +++ b/.github/workflows/validate-renovate.yml @@ -16,4 +16,4 @@ jobs: validate: permissions: contents: read - uses: projectbluefin/actions/.github/workflows/reusable-validate-renovate.yml@v1 + uses: projectbluefin/actions/.github/workflows/reusable-validate-renovate.yml@9623ba6a5577ad1221d76a223c9dacbd9043bd04 # v1 diff --git a/docs/skills/ci-tooling.md b/docs/skills/ci-tooling.md index d9d85d2..e5fde89 100644 --- a/docs/skills/ci-tooling.md +++ b/docs/skills/ci-tooling.md @@ -107,7 +107,6 @@ Supporting workflows, none of which touch publication: | `ghcr-cleanup.yml` | weekly, dispatch | prunes untagged manifests for this repo's packages only | | `renovate.yml` | nightly, dispatch | Renovate, running with a Mergeraptor app token | | `auto-update-fsdk.yml` | nightly, dispatch | FSDK bump branch + PR + verification dispatch | -| `label-enforcement.yml` | issue/PR events | shared factory label lifecycle | | Job | Trigger | Purpose | |---|---|---| diff --git a/elements/targets.json b/elements/targets.json index eb867b2..09f5062 100644 --- a/elements/targets.json +++ b/elements/targets.json @@ -38,6 +38,7 @@ "$comment_vm_guest_paths": "The podman-vm guest disk (docs/skills/vm-podman-guest.md) is not an OCI image and has its own build/boot-test/publish pipeline, so it gets its own path set. Its pull-request gate is a real build plus the QEMU boot test -- never a release upload.", "vm_guest_paths": [ "elements/podman-vm/", - "tests/vm-boot.sh" + "tests/vm-boot.sh", + ".github/actions/vm-boot-test/" ] }