---
name: ci
description: >-
  GitHub Actions, Renovate, the two-branch release model, signing, and
  promotion. Use when changing workflows, dependency policy, or releasing.
---

# CI

## Workflows

| Workflow | Trigger | Does |
|---|---|---|
| `build-image.yml` | push to `main` or `stable`, dispatch | Builds, signs, and pushes the image. |
| `execute-release.yml` | push to `stable` | Promotes the candidate digest. Does not rebuild. |
| `promote-main-to-stable.yml` | daily schedule, dispatch | Opens the squash promotion PR and runs the release gate on it. |
| `sync-stable-to-main.yml` | push to `stable` | Merges `stable` hotfixes back into `main`. |
| `pr-validation.yml` | pull request | The `validate` check: shellcheck and hadolint. |
| `validate-brewfiles.yml` | pull request | Brewfiles, without evaluating them. |
| `validate-flatpaks.yml` | pull request | Flatpak preinstall files against Flathub. |
| `validate-justfiles.yml` | pull request | `just check`. |
| `validate-renovate.yml` | pull request | Renovate config. |
| `unit-tests.yml` | push, pull request | The bats suite. |
| `renovate.yml` | schedule, config change | Runs Renovate. |
| `clean.yml` | schedule | Deletes images older than 90 days. |

Most are thin callers of reusable workflows in `projectbluefin/actions`.

## The release model

`main` publishes `:stable-testing`. `stable` never rebuilds: promotion is a
squash PR from `main` to `stable`, and `execute-release.yml` copies the digest
`:testing` resolves to. The README owns the release table and the promotion
gate's current limits.

The factory reusable puts its release gate and its auto-merge enrollment behind
one input, `enqueue_promotion`. A personal repository cannot enroll — there is no
merge queue, and `gh pr merge --auto` refuses without a merge method — so
enrollment is off, and `promote-main-to-stable.yml` runs the gate itself in its
own `gate` job to keep the pre-merge check. That gate resolves `:testing` when it
runs, so it attests the current candidate. The binding comes from
`execute-release.yml` passing `source_branch: main`, which makes the reusable
refuse to promote at all once `main` has moved past the promotion commit; a
manual dispatch is exempt, because that path is deliberate recovery.

The same reusable builds the squash branch, and it stages deletions with
`git diff --diff-filter=D`. Git reports a moved file as a rename, so a path `main`
moved survives on its old path and the branch's tree stops matching `main`'s —
the state the guard above refuses, but only after the PR is merged.
`repair-promotion-branch` in `promote-main-to-stable.yml` rebuilds the branch from
`main`'s tree when it has drifted, and the `validate` check fails a promotion PR
whose tree does not match `main`. The sweep belongs to `projectbluefin/actions`;
the one-line fix there is `--no-renames`.

## Signing

Keyless OIDC via Cosign. There are no keys to generate or store; the workflow
needs `id-token: write` and `packages: write`. Unsigned images fail the promotion
gate. The README has the command to verify an image.

The promotion gate is the *only* enforcement point. Nothing checks the signature
on an installed system, so `00-image-info.sh` writes an unverified update
transport (`ostree-unverified-image:docker://…`) and the README says so.
`ostree-image-signed:` would send the client to `/etc/containers/policy.json`,
which Common supplies with no scope for this namespace — it would verify against
the `""` catch-all, `insecureAcceptAnything`, and report success having checked
nothing. Adding a scope does not rescue it while signing stays keyless:
containers/image matches a Fulcio certificate on `subjectEmail` alone
(mandatory, exact, with a standing FIXME for URI SANs in
`signature/fulcio_cert.go`), and a GitHub Actions certificate names its workflow
in a URI SAN with no email to match. Device-side verification is a key-based
signing change first, a policy change second.
`tests/contract/image-signing_test.bats` fails if either side moves alone.

The identity regexp the release workflows pass to the reusables is scoped with
`github.repository`, not `github.repository_owner`. Matching the owner and then
any repository accepts a signature minted by any repository in the org, and
`github.repository` keeps the scope correct in a fork without hardcoding it.

## Renovate

Self-hosted through `projectbluefin/actions`, running every six hours. It pins
GitHub Actions to SHAs and updates image digests. The policy lives in
`.github/renovate.json`: updates below a major automerge once checks pass;
majors wait for a pull request — except `quay.io/fedora-ostree-desktops/*`,
where cross-major bumps do not open a PR at all (the rule is `enabled: false`).
A Fedora major rebase for that namespace is a deliberate manual step: bump the
tag in `Containerfile` by hand when the next major is released and validated.

Renovate needs the `RENOVATE_TOKEN` secret and auto-merge enabled. Both are
onboarding steps. The secret is optional: with it unset the workflow logs a skip
and the run stays green, which is how upstream runs, where an org-wide app does
the work instead. The check sits in its own `token` job because a job that calls
a reusable workflow cannot hold steps, and `jobs.<job_id>.if` cannot read the
secrets context — `secrets` in a job-level `if` is a parse error, not a skip.

Automerge deliberately covers GitHub Actions SHA bumps, which reverses a guard
upstream kept. Those SHAs run in jobs holding `packages: write`,
`id-token: write`, and `secrets: inherit`, and PR builds are disabled, so a bump
merges with only shellcheck, hadolint, and the test suite having run. Putting the
guard back is one rule — `matchManagers: ["github-actions"]` with
`automerge: false`.

## Making a change

1. Open a pull request against `main`.
2. Wait for `validate` and the image build.
3. Merge. `main` publishes `:stable-testing`.
4. Review and merge the promotion PR to publish `:stable`.
