Skip to content

publish

publish #38

Workflow file for this run

# Publish a kuma image to ghcr.
#
# Manual only, on purpose. A published image is the one artifact here that
# strangers pull and boot, and the first ones should go out when somebody
# decides to, not as a side effect of a tag. Tag-triggered publishing is
# the eventual shape and costs one `on: push: tags:` block when the flow
# has been run a few times by hand.
#
# What this buys, and it is worth being clear that it is the cheap half:
# somebody already on an atomic Fedora can `bootc switch` to this image
# and keep their accounts, because those live in /var/home. Installing
# from live media is the other half and needs `kuma install`, which needs
# this to exist first: there is nothing to install until something is
# published.
#
# The shape is compose, stage, prove, promote. The compose builds the
# image; the audit checks it; the push puts it on a staging tag nobody
# tracks; the validation laps install and boot THOSE bytes from the
# registry; and the promote job moves the two real tags only if every lap
# came back green. The order is not style: two of the first three
# publishes failed their validations (44.3.0's signatures the fleet's own
# policy could not read, 44.6.0's greeter segfault), and under the old
# order the tags were already live when the laps said so — the laps could
# tell somebody the same day, but what they said was that the broken
# thing was public. Now a red lap means the tags still name the last
# image that booted. The staging tag is disposable by design: nobody
# installs from it, and the next publish overwrites it.
#
# Composes of one commit are not byte-stable — the layered packages
# deliberately resolve live (src/lock.rs: pinning rpm NVRAs is worse than
# useless on a live Fedora), so the base digest is pinned and everything
# else moves with the repo. That is exactly why the promotion shape is
# mandatory rather than nice: the bytes that go live must be the bytes
# that were validated, because there is no such thing as "the same image
# built again".
name: publish
on:
workflow_dispatch:
inputs:
example:
description: Declaration to build from
type: choice
options: [niri, cosmic]
default: niri
allow_unsigned:
description: >-
Publish without a cosign signature. Off by default: an unsigned
image can only be rebased onto with ostree-unverified-registry,
which verifies nothing.
type: boolean
default: false
permissions:
contents: read
packages: write
jobs:
# Compose and stage. The real tags (the moving `:niri` and the pinned
# `:niri-$version`) are computed here but pushed nowhere; only the
# staging tag leaves this job.
publish:
runs-on: ubuntu-24.04
outputs:
repo: ${{ steps.ref.outputs.repo }}
example: ${{ steps.guard.outputs.example }}
version: ${{ steps.ref.outputs.version }}
staging: ${{ steps.ref.outputs.staging }}
remote: ${{ steps.ref.outputs.remote }}
pinned: ${{ steps.ref.outputs.pinned }}
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# The choice list above constrains the web UI only; the API hands
# whatever it is given to the expressions below, and those
# interpolate into the run blocks. So the input is guarded at the
# source, through an env var (data the shell reads) rather than a
# template (text the run block becomes), and only the two values
# the choice list names ever reach the steps downstream.
- name: guard the inputs
id: guard
env:
EXAMPLE: ${{ inputs.example }}
run: |
set -euo pipefail
case "$EXAMPLE" in
niri | cosmic) ;;
*) echo "::error::example must be niri or cosmic; the API does not enforce the choice list, this does" >&2; exit 1 ;;
esac
echo "example=$EXAMPLE" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
# The binary is baked into the image by `kuma build`, so its build
# paths ride along into everything published. Same remap as
# release.yml; scripts/publish-audit.sh fails on any that survive.
- name: build kuma
env:
RUSTFLAGS: >-
--remap-path-prefix=${{ github.workspace }}=/kuma
--remap-path-prefix=/home/runner/.cargo/registry=/cargo
run: cargo build --locked --release
# The image above is composed by the kuma this job just built, so
# the shell must sit beside it in target/release.
- uses: ./.github/actions/kuma-shell
with:
kuma-dir: target/release
# Lowercased because ghcr rejects an uppercase path, and the owner
# here is not.
# Three tags, two jobs. The staging tag is where this job's image
# lives while the validation laps ask it questions. The moving tag
# (`:niri`) means "the current niri image", which is what somebody
# rebasing wants and is also useless for saying "the one that
# worked last week": every publish overwrites what it means. The
# pinned tag (`:niri-$version`) is the one to go back to. Both real
# tags are moved by the promote job, on the digest this job's push
# records. kuma.lock exists because kuma cares about pinning what a
# build came from; the images it produces had no equivalent.
#
# The version comes from Cargo.toml, so a publish cannot label
# itself something the binary inside it disagrees with.
- name: name the image
id: ref
run: |
set -euo pipefail
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
repo="ghcr.io/$owner/kuma"
{
echo "repo=$repo"
echo "version=$version"
echo "staging=$repo:${{ steps.guard.outputs.example }}-staging"
echo "remote=$repo:${{ steps.guard.outputs.example }}"
echo "pinned=$repo:${{ steps.guard.outputs.example }}-$version"
echo "local=localhost/kuma-publish:${{ steps.guard.outputs.example }}"
} >> "$GITHUB_OUTPUT"
# Composes its own base, so this is the long step. The declaration
# is a committed example precisely because it declares no [user] and
# no hostname; the audit below is what enforces that rather than
# trusting the file to stay that way.
- name: build the image
run: |
./target/release/kuma build \
--config "examples/${{ steps.guard.outputs.example }}.toml" \
--tag "${{ steps.ref.outputs.local }}"
# Before the registry, never after. A published image cannot be
# unpublished from anybody who already pulled it.
- name: audit for personal information
# No rpm to install: the freeworld guard grades the rpmdb with
# the image's own rpm (podman run --entrypoint), because the
# runner's apt rpm is 4.18 and cannot read a Fedora 44 database
# (its dbpath predates the sysimage move). The apt lines this
# replaces also swallowed their own output, so when the query
# failed the audit died wordlessly -- a gate that fails red must
# fail with a name.
run: ./scripts/publish-audit.sh "${{ steps.ref.outputs.local }}"
# Pinned to the cosign 2.6 line, not the installer's default: v4 of
# this action installs cosign v3, and cosign v3 changed where
# `cosign sign` puts signatures — OCI 1.1 referrers, no longer the
# legacy `sha256-<digest>.sig` attachment tags. Every machine
# running a published image verifies pulls through its baked policy
# (sigstoreSigned over use-sigstore-attachments), which reads only
# the legacy tags, so a v3-signed image is unverifiable by the very
# fleet it exists for. Found 2026-09-28: 44.3.0's first publish
# failed every bootc upgrade in the cross-version validation in
# under three seconds, silently, while this workflow's own verify
# passed — the new cosign reads what it wrote. The pin stays until
# the images' baked policy understands referrers and a deliberate
# migration moves the fleet across. release.yml's installer is not
# pinned: it signs blobs keylessly, which has no attachment path.
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
if: ${{ !inputs.allow_unsigned }}
with:
cosign-release: v2.6.5
# Twice, because they do not share a credential store. podman writes
# its auth file under XDG_RUNTIME_DIR; cosign reads Docker's
# keychain at ~/.docker/config.json and would find nothing there.
# Signing pushes a signature artifact to the registry, so it needs
# write access whether or not the package is public, and the failure
# without this arrives at the one step that cannot be rehearsed.
- name: log in to ghcr
env:
ALLOW_UNSIGNED: ${{ inputs.allow_unsigned }}
run: |
set -euo pipefail
echo "${{ secrets.GITHUB_TOKEN }}" \
| podman login ghcr.io -u "${{ github.actor }}" --password-stdin
if [ "$ALLOW_UNSIGNED" != "true" ]; then
echo "${{ secrets.GITHUB_TOKEN }}" \
| cosign login ghcr.io -u "${{ github.actor }}" --password-stdin
fi
# Refused rather than warned about. An image people rebase onto
# without a signature is one they cannot verify at all, and a
# workflow that quietly publishes the weaker thing is how that
# becomes permanent. `allow_unsigned` makes it a decision.
- name: require a signing key
if: ${{ !inputs.allow_unsigned }}
env:
KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::error::No COSIGN_PRIVATE_KEY secret."
echo "Generate one with 'cosign generate-key-pair', add the private"
echo "half as COSIGN_PRIVATE_KEY and its password as"
echo "COSIGN_PASSWORD, and commit the public half so machines can"
echo "verify. Or re-run with allow_unsigned to publish anyway."
exit 1
fi
# The staging tag is the only thing this job puts on the registry:
# the validation laps install and boot these exact bytes before the
# tags people pull have moved. `--digestfile`, because the registry
# decides the digest and podman only finds out by being told.
# `podman inspect .RepoDigests` reports the LOCAL manifest, which is
# not what the registry stored once podman recompressed layers on
# the way up, and signing that gives a signature for a manifest
# nobody can fetch.
- name: push the staging tag
id: push
run: |
set -euo pipefail
# The build tags the image as a localhost name; the push needs
# the registry's name. A tag is a pointer, so this moves no
# bytes and cannot drift the digest the signature and the
# validation laps key off. The staging shape's first run
# (2026-10-10) pushed with no local retag and died here:
# "image not known", before a byte moved.
podman tag "${{ steps.ref.outputs.local }}" "${{ steps.ref.outputs.staging }}"
podman push --digestfile digest.txt "${{ steps.ref.outputs.staging }}"
echo "digest=$(cat digest.txt)" >> "$GITHUB_OUTPUT"
- name: sign
if: ${{ !inputs.allow_unsigned }}
env:
COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
# By digest, not by tag: a tag can move between the push and the
# signature, and a signature on a tag says nothing about which
# bytes were signed. The digest comes from the push itself.
cosign sign --key env://COSIGN_KEY --yes \
"${{ steps.ref.outputs.staging }}@${{ steps.push.outputs.digest }}"
# By TAG, with the committed public key: exactly the command
# SECURITY.md gives a stranger, run against the registry rather
# than against anything this job computed.
#
# That is the whole point, and the earlier version got it wrong. It
# verified the same digest string the sign step had produced, so when
# that digest turned out to name a manifest the registry did not
# have, cosign found the signature it had just written and passed.
# The step meant to catch a silent signing failure shared the
# failure's input and confirmed it. Verifying the tag resolves
# through the registry, so it can only pass if what people pull is
# what was signed. The staging tag is the tag that exists here.
- name: verify the way a stranger would
if: ${{ !inputs.allow_unsigned }}
run: |
set -euo pipefail
cosign verify --key cosign.pub \
"${{ steps.ref.outputs.staging }}" >/dev/null
echo "verified: ${{ steps.ref.outputs.staging }}"
# The registry question can only change when the registry changes, so
# the staging push triggers the validation rather than a person
# remembering to. Every lap installs and boots the staged digest —
# pinned by digest, so a concurrent publish re-staging the tag cannot
# put different bytes under this run's verdict. The pin is repo + digest,
# NOT tag + digest: containers/image -- skopeo, and the install's own
# reachability probe with it -- refuses a reference that carries both,
# so the tag form died at the install preflight before any VM booted
# (2026-10-10's third red publish; cosign's parser tolerates the form,
# which is why the publish job's own verify stayed green). The REAL
# tags have not moved yet: a red lap here is a red run and no published
# change.
validate:
needs: publish
uses: ./.github/workflows/published.yml
with:
image: ${{ needs.publish.outputs.repo }}@${{ needs.publish.outputs.digest }}
# The tags move here, and only here, and only by digest: `crane tag`
# re-points a registry tag at a manifest that is already stored, which
# moves no bytes and cannot change the digest the signature names —
# `podman pull` + `podman push` instead would risk recompressing layers
# on the way back up and signing a manifest nobody can fetch, the
# exact class the digestfile comment above is about. The signature
# already covers these bytes (it was made against this digest in the
# publish job), so the verified thing and the promoted thing are the
# same thing by construction, and the verify below is the stranger's
# own command run against the tags the stranger uses.
promote:
needs: [publish, validate]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
if: ${{ !inputs.allow_unsigned }}
with:
cosign-release: v2.6.5
# crane reads Docker's keychain, so the docker login is what it
# finds (the publish job's podman login wrote a different file in
# a different job's storage).
- name: log in to ghcr
run: |
set -euo pipefail
echo "${{ secrets.GITHUB_TOKEN }}" \
| docker login ghcr.io -u "${{ github.actor }}" --password-stdin
# Downloaded and verified by hash rather than trusted by tag: a
# moving download is one more silent thing deciding what this gate
# means, and the hash is what makes "verified" mean the same bytes
# somebody read once. v0.22.1's Linux x86_64 tarball, hashed
# against the release's own checksums.txt the day it was pinned.
- name: install crane
run: |
set -euo pipefail
curl --proto '=https' --tlsv1.2 --silent --show-error --fail --location \
-o crane.tgz \
https://github.com/google/go-containerregistry/releases/download/v0.22.1/go-containerregistry_Linux_x86_64.tar.gz
echo "0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 crane.tgz" | sha256sum -c -
tar -xzf crane.tgz crane
- name: move the tags
env:
IMAGE_REPO: ${{ needs.publish.outputs.repo }}
DIGEST: ${{ needs.publish.outputs.digest }}
EXAMPLE: ${{ needs.publish.outputs.example }}
VERSION: ${{ needs.publish.outputs.version }}
run: |
set -euo pipefail
./crane tag "$IMAGE_REPO@$DIGEST" "$EXAMPLE"
./crane tag "$IMAGE_REPO@$DIGEST" "$EXAMPLE-$VERSION"
- name: verify the way a stranger would
if: ${{ !inputs.allow_unsigned }}
env:
REMOTE: ${{ needs.publish.outputs.remote }}
PINNED: ${{ needs.publish.outputs.pinned }}
run: |
set -euo pipefail
for ref in "$REMOTE" "$PINNED"; do
cosign verify --key cosign.pub "$ref" >/dev/null
echo "verified: $ref"
done
- name: summary
env:
REMOTE: ${{ needs.publish.outputs.remote }}
PINNED: ${{ needs.publish.outputs.pinned }}
ALLOW_UNSIGNED: ${{ inputs.allow_unsigned }}
run: |
# shellcheck disable=SC2016 # Actions substitutes its own
# expressions before the shell runs, so single quotes here are
# what keeps the markdown backticks literal.
{
echo "### Published"
echo
echo 'The validation laps booted these exact bytes before either'
echo 'tag moved.'
echo
echo '`'"$REMOTE"'` (moves)'
echo '`'"$PINNED"'` (pinned)'
echo
if [ "$ALLOW_UNSIGNED" = "true" ]; then
echo "**Unsigned.** Rebasing onto it verifies nothing."
fi
echo "Rebase from an atomic Fedora with:"
echo
echo '```'
echo "sudo bootc switch $REMOTE"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"