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/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/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/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/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/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/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/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/.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..48be081 --- /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@9623ba6a5577ad1221d76a223c9dacbd9043bd04 # v1 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 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/Justfile b/Justfile index a2823fa..d770ec9 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: @@ -259,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") 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" 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`, diff --git a/docs/skills/ci-tooling.md b/docs/skills/ci-tooling.md index cf5407d..e5fde89 100644 --- a/docs/skills/ci-tooling.md +++ b/docs/skills/ci-tooling.md @@ -91,13 +91,29 @@ 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 | | 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 +170,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 +298,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. + 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/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/elements/lab-runner/nginx.bst b/elements/lab-runner/nginx.bst index 913a44a..9340560 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: "" @@ -19,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 \ diff --git a/elements/targets.json b/elements/targets.json index fde422b..09f5062 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,37 @@ "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", + ".github/actions/vm-boot-test/" ] } 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": [ 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=()