Repository navigation
publish #38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |