Reusable Workflows#
The repo provides these reusable workflows:
| Workflow | Purpose |
|---|---|
.github/workflows/reusable-build.yml | Full Fedora bootc image build pipeline (Path 1) |
.github/workflows/reusable-release.yml | Image stable-release orchestration and Conventional Commits GitHub Release creation |
.github/workflows/reusable-execute-release.yml | Stable promotion gate: resolve digest → testsuite e2e → re-verify cosign → promote tag |
.github/workflows/server-installer-test.yml | Bluefin Server raw installer → systemd-sysinstall → installed target QEMU smoke test |
Permissions hardening: default reusable workflows to permissions: {} at the workflow level, then grant the minimum required scopes per job. Do not rely on workflow-level packages: write/contents: write unless every job in the file truly needs that access.
Contents#
- reusable-build.yml — calling from a consuming repo
server-installer-test.yml— exported installer smoke test- How action refs work inside the reusable workflow
- Tag generation and manifest scope
- Digest output shape
- JSON array inputs
- SBOM artifact shape
- reusable-release.yml
- reusable-execute-release.yml — stable promotion gate
reusable-build.yml — calling from a consuming repo#
jobs:
build:
uses: projectbluefin/actions/.github/workflows/reusable-build.yml@v1
secrets: inherit
with:
brand_name: bluefin
stream_name: stable
image_flavors: '["main", "nvidia-open"]'
architecture: '["x86_64"]'
server-installer-test.yml — exported installer smoke test#
This workflow owns only the GitHub-hosted runner setup: artifact download, QEMU/KVM packages, and failure-artifact upload. The consuming repository owns installer semantics through a test-installer-artifact Just recipe. Keep local build orchestration in a separate wrapper such as show-me-the-future; CI tests artifacts from the existing build job rather than rebuilding them.
The caller uploads one artifact rooted like its dist/ directory:
bluefin-server-installer-*.raw.zst
bluefin-server-pxe-vmlinuz-*
bluefin-server-pxe-initrd-*.cpio.gz
Then call the workflow after the producing job:
installer-test:
needs: build
uses: projectbluefin/actions/.github/workflows/server-installer-test.yml@v1
permissions:
actions: read
contents: read
with:
artifact-name: server-installer-test
The reusable workflow checks out the caller at the triggering revision and runs just test-installer-artifact. It deliberately does not use Lima: Linux GitHub-hosted runners already expose KVM, and QEMU is the runtime under test.
GitHub-hosted Ubuntu runners create /dev/kvm before workflow udev rules are installed, and retriggering the device does not reliably update its ownership. On the ephemeral runner, assign the device to $USER before invoking caller-owned, rootless QEMU commands; do not rely on a late udev rule.
Set XDG_CACHE_HOME to a non-hidden workspace directory before invoking the caller recipe. upload-artifact does not reliably expand ~ in path globs, and its hidden-file filtering can silently discard ~/.cache evidence.
Upload only serial.log from the retained work directory. The directory also contains multi-gigabyte raw disk images; uploading the directory wastes storage and can consume the job timeout while reporting a failure.
How action refs work inside the reusable workflow#
When a consuming repo calls the workflow:
github.repository= the caller's repo (e.g.projectbluefin/bluefin)actions/checkoutchecks out the caller's code intoGITHUB_WORKSPACEjustcommands run against the caller's Justfile — this is intentional
Self-repository syntax#
For implementation-time calls owned by this repository, use the self-repository prefix:
uses: $/bootc-build/setup-runner
uses: $/.github/workflows/reusable-release-gate.yml
$/ resolves the action or reusable workflow from the repository and commit that owns the running workflow. It prevents a reusable workflow called by a consumer from accidentally resolving ./ paths against the consumer's checked-out workspace, and avoids stale self-repository SHA pins. GitHub-hosted runners must provide runner version 2.336.0 or newer for this syntax.
Use $/ only for same-repository implementation references. Keep third-party actions and cross-repository calls fully qualified and pinned as required. Keep consumer-facing examples such as projectbluefin/actions/.github/workflows/reusable-build.yml@v1 unchanged: those are the public interface. Checkouts remain required whenever caller source, Justfiles, artifacts, or git operations are used.
Do not migrate references in comments, documentation examples, or shell commands that inspect consumer-facing projectbluefin/actions/...@v1 calls. A focused regression test (tests/test_self_repository_references.py) scans active implementation YAML under .github/, actions/, and bootc-build/; run it after changing composition refs. Validate both the focused test and YAML/actionlint checks; the repository's pinned actionlint 1.7.7 needs the scoped .github/actionlint.yaml compatibility ignores until native $/ parsing support lands.
Commit-bound E2E evidence: reusable-release-gate.yml reads the latest status whose context matches e2e_status_context from the source commit named by head_sha. A producer workflow triggered through workflow_run must publish that status explicitly on github.event.workflow_run.head_sha; the producer run's own head_sha belongs to the default-branch workflow definition and does not identify the tested source commit.
Pin GitHub-hosted Linux jobs to explicit runner labels (ubuntu-24.04 / ubuntu-24.04-arm) instead of ubuntu-latest, and set timeout-minutes on every lightweight helper job (preflight, check, collect-digests, release/validation/report jobs). The build matrix itself gets the longer explicit timeout because it can otherwise hold a runner indefinitely when podman or registry operations hang.
Tag generation and manifest scope#
reusable-build.yml intentionally keeps tag generation in the caller repo by running just generate-build-tags instead of bootc-build/generate-tags. That is part of the Path 1 Justfile contract, alongside image_name, generate-default-tag, build-ghcr, and tag-images.
bootc-build/generate-tags exists for Path 2 / à la carte pipelines that want the shared default tag policy without adopting the full reusable workflow contract.
bootc-build/create-manifest is also a Path 2 building block today. The reusable workflow builds and pushes per-architecture images and emits digests, but it does not assemble or push a multi-arch manifest index; callers that need a manifest job should add an explicit follow-on create-manifest step in their own workflow.
publish_stream_tag and the e2e gate#
publish_stream_tag (string, default "true") decides whether the mutable stream pointer (:testing, :stable, :stable-daily, :latest) is pushed at build time. Consumers that gate the stream tag behind a post-build e2e job pass "false"; a separate promote-to-testing-style job then does skopeo copy @digest → :testing only after the suites pass.
The alias tag list already contains the stream tag. just generate-build-tags emits <stream> <stream>-<version> <stream>-<date> — the bare stream tag is alias_tags[0], not a separate value. So gating cannot be implemented by only choosing a different push_default: the stream tag must be filtered out of the tag list itself. Any future edit to compute-push-tags must keep that filter, and the guard in tests/bats/test_compute_push_tags.bats must be updated in the same change.
Compare string inputs explicitly. publish_stream_tag is type: string, so if: inputs.publish_stream_tag is truthy even for "false". Always compare against the literal (== 'true' in an if:, [[ "$X" == "true" ]] in bash) and fail closed on any value that is not exactly true or false.
Digest output shape (multi-arch safe)#
The digests output is a nested JSON map: { "image-name": { "platform": "digest" } }. Platform keys use OCI names (amd64, arm64), mapped from runner architecture names (x86_64, aarch64) during artifact writing. Single-arch builds produce one platform key per image; multi-arch builds produce one per architecture.
This shape is directly compatible with create-manifest's digests-json input — callers can iterate the outer map and pass each inner object to create-manifest without reshaping.
The digest artifact files use pipe-delimited format (image_name|oci_platform|digest) so that the collect-digests job can build the nested structure without key collisions across architectures.
JSON array inputs#
Any input consumed via fromJson() must be valid JSON. That means string items inside the array must use double quotes.
Always use single outer quotes with double-quoted inner strings:
# ✅ correct
image_flavors: '["main", "nvidia-open"]'
architecture: '["x86_64", "aarch64"]'
install-tools: '["just", "cosign", "oras", "syft"]'
Wrong:
# ❌ wrong — invalid JSON, fromJson() will fail
architecture: "['x86_64']"
The reusable workflow's architecture input is the concrete pattern to follow because the matrix parses it with fromJson(inputs.architecture). Use architecture: '["x86_64"]' or architecture: '["x86_64", "aarch64"]', never single-quoted strings inside the JSON array.
SBOM artifact shape#
The workflow stages SBOMs as IMAGE_NAME.sbom.json (flat rename from sbom_out/IMAGE_NAME/sbom.json) before upload. The generate-release.yml workflow expects this *.sbom.json glob shape.
SBOM generation and upload should run for every non-PR build, including the testing stream. Weekly promotions retag testing digests directly to production tags, so skipping SBOM on testing leaves promoted images without signed SBOM referrers.
Promotion gate E2E contract#
reusable-release-gate.yml requires producer-published, commit-bound evidence
rather than inferring provenance from a workflow_run record. It accepts only
statuses created by the GitHub Actions integration (app ID 15368), so a user-
authored status with the same context cannot authorize promotion. The caller
must:
- pass the exact promotion source SHA as
head_sha - configure a non-empty
e2e_imageand keeprun_e2e: true - publish
successorfailureon that SHA undere2e_status_context - publish success only after every required E2E suite passes and the tested
digest becomes the mutable source tag consumed by the release gate
An absent status is a neutral waiting state: digest and signature checks still
run, the PR receives release/pending, but the workflow stays green and does
not enqueue. An explicit producer failure, digest failure, or signature failure
fails closed, marks release/blocked, and opens or updates a priority/p1
issue. A later passing gate closes that issue. Because status is attached to an
immutable commit, it remains valid for that commit and needs no age-based
revalidation.
reusable-promote-squash.yml runs the release gate only when
enqueue_promotion is true. Refresh-only events maintain the PR, remove stale
release labels, and do not post the required validate status. After the gate
reports ready=true, the enqueue job posts validate and enrolls the PR. Queue
and auto-merge enrollment are idempotent, and the single exact do-not-merge
decision is shared by validation and enrollment.
Release callers that resolve mutable source tags may pass source_branch to
reusable-execute-release.yml. Before resolving tags, it requires that branch's
current tree to match fast_forward_sha; a concurrent source advance fails
closed. Recovery callers can omit source_branch after independently selecting
and verifying the intended source digest.
reusable-release.yml — calling from a consuming repo#
Image stable-release mode (artifact path)#
Use this when your build workflow uploads a *.sbom.json artifact via reusable-build.yml:
jobs:
release:
uses: projectbluefin/actions/.github/workflows/reusable-release.yml@v1
secrets:
github_token: ${{ secrets.GITHUB_TOKEN }}
with:
stream_name: stable
build_workflow: build-image-stable.yml
build_branch: stable
image: ghcr.io/projectbluefin/bluefin
project_name: Bluefin
cert_identity_regexp: ^https://github\.com/projectbluefin/(bluefin|actions)/\.github/workflows/
notable_packages: >-
[
{"sbom_name": "kernel", "label": "Kernel"},
{"sbom_name": "gnome-shell", "label": "GNOME Shell"},
{"sbom_name": "mesa-filesystem","label": "Mesa"},
{"sbom_name": "flatpak", "label": "Flatpak"},
{"sbom_name": "systemd", "label": "systemd"}
]
This mode finds the latest successful build run for the requested stream, downloads the uploaded SBOM artifact, resolves the current image digest, and calls bootc-build/create-release to publish the GitHub Release. The reusable workflow owns the production environment gate and grants only contents: write plus actions: read to the image release job.
Image release notes also embed the latest testsuite desktop screenshot at:
https://projectbluefin.github.io/testsuite/screenshots/<slug>-smoke-latest.png
where <slug> is the image ref with the registry/org prefix removed and : replaced by -
(for example ghcr.io/projectbluefin/bluefin:stable → bluefin-stable).
Image inline-SBOM mode (promote-from-testing path)#
Use generate_sbom_inline: true when promotion retags a testing image directly (no intermediate build run with a SBOM artifact to download). The workflow pulls the promoted image via skopeo copy to a local OCI archive, then scans it with Syft using all catalogers. The job fails hard if Syft fails — no silent stub.
with:
stream_name: stable
image: ghcr.io/projectbluefin/bluefin
generate_sbom_inline: true
checkout_ref: main
# ... other inputs
Do NOT use generate_sbom_inline: true for BST-built images (e.g. dakota). BST images have no RPM/dpkg database — Syft returns 0 or 1 packages. Use the artifact path instead and upload the BST-native SBOM (from just sbom / buildstream-sbom) with a static artifact name.
notable_packages — SPDX name reference#
sbom_name must match the exact name field in the SPDX packages array. Values differ by image type:
| Package | Fedora/CentOS RPM (sbom_name) | BST/GNOME OS (sbom_name) |
|---|---|---|
| Kernel | kernel | linux |
| GNOME Shell | gnome-shell | gnome-shell |
| Mesa | mesa-filesystem | mesa |
| Flatpak | flatpak | flatpak |
| systemd | systemd | systemd |
| bootc | bootc | bootc |
Unmatched entries are silently skipped — no error. Verify against a real SBOM if the Key Components table is empty.
Variants table (multi-image promotions)#
reusable-release.yml generates release notes for a single primary image. For repos that promote multiple variants (e.g. bluefin + bluefin-nvidia), add a post-release-variants job that prepends a digest table after release-notes completes. See projectbluefin/bluefin:.github/workflows/execute-release.yml for the reference implementation.
reusable-execute-release.yml — stable promotion gate#
Promotes one or more OCI variants (e.g. :testing → :stable) for bootc image repos. The workflow resolves the source digest once, optionally runs testsuite e2e against that exact digest, then re-verifies cosign and promotes the same digest to the target tag. The digest is never re-resolved after the gate, eliminating TOCTOU drift between test and promotion.
Inputs#
| Input | Required | Default | Description |
|---|---|---|---|
registry | no | ghcr.io/projectbluefin | Registry prefix for image refs |
variants | yes | — | JSON array of {image, source_tag, target_tag} objects |
cosign_identity_regexp | yes | — | Cosign certificate identity regexp for re-verification |
fast_forward_branch | no | '' | Branch to fast-forward after promotion (e.g. main) |
fast_forward_sha | no | github.sha | SHA to fast-forward the branch to |
tag_name | no | '' | Release tag for Discord notification |
run_release_gate | no | true | Run testsuite e2e against the candidate digest before promotion |
gate_suites | no | smoke,common | Comma-separated suites for the release gate |
Caller example#
jobs:
execute:
uses: projectbluefin/actions/.github/workflows/reusable-execute-release.yml@v1
secrets: inherit
permissions:
actions: read
contents: write
issues: write
packages: write
pull-requests: write
with:
registry: ghcr.io/projectbluefin
variants: >-
[
{"image":"bluefin","source_tag":"testing","target_tag":"stable"}
]
cosign_identity_regexp: ^https://github\.com/projectbluefin/(bluefin|actions)/\.github/workflows/
gate_suites: smoke,common
Gate behavior#
- The gate runs one matrix job per variant, so multi-variant promotions test each image independently.
- The e2e job calls
projectbluefin/testsuite/.github/workflows/e2e.ymlpinned to the currentv1SHA. - The image ref passed to testsuite uses the digest resolved in the
resolvejob (ghcr.io/projectbluefin/<image>@sha256:…), not the source tag. - Set
run_release_gate: falseonly as an emergency escape hatch; changing the default affects every consumer of this reusable workflow.
Permissions#
The caller must grant packages: write so the nested testsuite workflow can push desktop-screenshot OCI artifacts. The promotion job itself needs contents: write, issues: write, packages: write, and pull-requests: write for the release mechanics.
Legacy semver mode#
jobs:
release:
uses: projectbluefin/actions/.github/workflows/reusable-release.yml@v1
secrets:
github_token: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }} # e.g. v1.2.3
# draft: false # optional
# prerelease: false # optional
# cliff-config: cliff.toml # optional; defaults to repo root
The legacy semver mode checks out with fetch-depth: 0 (required by git-cliff), runs generate-release-notes, and creates a GitHub Release with the generated body.
cliff.toml requirement: a cliff.toml must exist in the caller's repo root (or override via cliff-config input). A factory-wide config is available at the root of this repo and can be copied verbatim.
reusable-execute-release.yml — stable promotion gate#
Promotes one or more OCI variants (e.g. :testing → :stable) for bootc image repos. The workflow resolves the source digest once, optionally runs testsuite e2e against that exact digest, then re-verifies cosign and promotes the same digest to the target tag. The digest is never re-resolved after the gate, eliminating TOCTOU drift between test and promotion.
Testsuite e2e pin — keep aligned with bluefin's run-testsuite.yml#
The release-gate job calls projectbluefin/testsuite/.github/workflows/e2e.yml@<SHA> # v1. This pin must match the SHA in projectbluefin/bluefin/.github/workflows/run-testsuite.yml exactly. The two workflows execute the same testsuite e2e code — bluefin at PR time, the release gate at promotion time. A drift between them means the gate and bluefin CI can disagree on the same image.
To verify alignment before merging a change to this workflow:
# The pin in this workflow:
grep 'testsuite.*e2e.yml@' .github/workflows/reusable-execute-release.yml
# The pin bluefin uses (the source of truth for the managed @v1 tag):
gh api repos/projectbluefin/bluefin/contents/.github/workflows/run-testsuite.yml --jq .content \
| base64 -d | grep 'testsuite.*e2e.yml@'
If they differ, bump this workflow's pin to bluefin's SHA in the same PR. Do not trust the # v1 (matches ...) comment — verify the SHAs themselves. The testsuite v1 tag auto-tracks main on every testsuite merge, so the managed tag advances independently of this pin; the pin is the SHA the gate actually executes and must be a deliberate, verified match.
Gate behavior#
- The gate runs one matrix job per variant, so multi-variant promotions test each image independently.
- The image ref passed to testsuite uses the digest resolved in the
resolvejob (ghcr.io/projectbluefin/<image>@sha256:…), not the source tag. - Set
run_release_gate: falseonly as an emergency escape hatch; changing the default affects every consumer of this reusable workflow.
Permissions#
The caller must grant packages: write so the nested testsuite workflow can push desktop-screenshot OCI artifacts. The promotion job itself needs contents: write, issues: write, packages: write, and pull-requests: write for the release mechanics.
When to Use#
Use this skill when:
- Authoring, maintaining, or debugging the shared reusable workflows (
reusable-build.yml,reusable-release.yml,reusable-execute-release.yml). - Integrating a consumer repository with
reusable-build.yml(Path 1 consumer integration). - Configuring JSON array inputs for matrix builds, architectures, or flavor lists.
- Hardening permissions on reusable workflow caller jobs and step definitions.
- Aligning testsuite e2e pins between
reusable-execute-release.ymland consumer repos.
When NOT to Use#
Do not use this skill to:
- Learn individual composite action interfaces or implementations (use
composite-actions.mdandaction-reference.md). - Implement consumer repo custom Justfiles (use
consumer-guide.md). - Manage high-level promotion schedules and 2-human approval gates (use
factory-operations.md).
Core Process#
- Review workflow contract: Check
workflow_calltriggers, inputs, and secrets required by the reusable workflow. - Apply permissions hardening: Set top-level
permissions: {}and grant minimum required permissions on each individual job. - Use self-repository syntax: Reference internal actions using
$/bootc-build/<name>rather than hardcoded repo refs. - Preserve multi-arch digest shapes: Ensure build jobs emit immutable image digests rather than mutable tags.
- Verify pins across repos: Ensure external pins (like testsuite e2e) match the caller repos exactly.
- Validate with actionlint: Run
actionlintlocally before submitting PRs.
Common Rationalizations#
| Rationalization | Reality |
|---|---|
"Top-level permissions: write-all is easier for reusable workflows." | Reusable workflows inherit wide permissions if set at the top level; per-job least privilege prevents token compromise. |
| "A tag is fine as an output instead of a digest." | Tags are mutable and introduce race conditions (TOCTOU) between testing, signing, and promotion; digests are immutable. |
| "The e2e pin comment matches, so the SHA must match." | Comments can drift from actual commit SHAs; always compare SHA hashes directly via git or the API. |
Red Flags#
- Top-level workflow permissions granting write access globally across all jobs.
- Passing bare unvalidated JSON strings into matrix dimensions without decoding.
- Resolving image digests multiple times across gates instead of carrying a single resolved digest.
- Mismatched
projectbluefin/testsuitee2e pins between the release gate and consuming repos.
Verification#
- Top-level
permissions: {}is set on the workflow. - Each job specifies only the exact permissions needed for its execution.
- Internal composite action invocations use
$/bootc-build/...self-repo syntax. - Output digests are verified immutable SHA-256 strings (
sha256:...). - Actionlint and consumer contract checks pass without errors.