Repository navigation
publish #7
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. | |
| 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: | |
| publish: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@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 | |
| # Lowercased because ghcr rejects an uppercase path, and the owner | |
| # here is not. | |
| # Two tags for one image: a moving one to track, and a pinned one | |
| # to go back to. `: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. 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 "remote=$repo:${{ inputs.example }}" | |
| echo "pinned=$repo:${{ inputs.example }}-$version" | |
| echo "local=localhost/kuma-publish:${{ inputs.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/${{ inputs.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 | |
| run: ./scripts/publish-audit.sh "${{ steps.ref.outputs.local }}" | |
| - uses: sigstore/cosign-installer@v3 | |
| if: ${{ !inputs.allow_unsigned }} | |
| # 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 | |
| run: | | |
| set -euo pipefail | |
| echo "${{ secrets.GITHUB_TOKEN }}" \ | |
| | podman login ghcr.io -u "${{ github.actor }}" --password-stdin | |
| if [ "${{ inputs.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 | |
| # --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 | |
| id: push | |
| run: | | |
| set -euo pipefail | |
| podman tag "${{ steps.ref.outputs.local }}" "${{ steps.ref.outputs.remote }}" | |
| podman tag "${{ steps.ref.outputs.local }}" "${{ steps.ref.outputs.pinned }}" | |
| podman push --digestfile digest.txt "${{ steps.ref.outputs.remote }}" | |
| # Same manifest, so the same digest and the one signature below | |
| # covers both tags. | |
| podman push "${{ steps.ref.outputs.pinned }}" | |
| 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.remote }}@${{ steps.push.outputs.digest }}" | |
| # A key pair rather than keyless, decided against the alternative: | |
| # a policy.json sigstoreSigned requirement takes one of keyPath, | |
| # fulcio or pki, and the fulcio block needs both oidcIssuer and | |
| # subjectEmail. A GitHub Actions certificate carries no email, only | |
| # a URI SAN naming the workflow, so no policy can express "signed | |
| # by kuma's release workflow". The release binary stays keyless | |
| # because `cosign verify-blob` has no such constraint. | |
| # 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. | |
| - name: verify the way a stranger would | |
| if: ${{ !inputs.allow_unsigned }} | |
| run: | | |
| set -euo pipefail | |
| for ref in "${{ steps.ref.outputs.remote }}" "${{ steps.ref.outputs.pinned }}"; do | |
| cosign verify --key cosign.pub "$ref" >/dev/null | |
| echo "verified: $ref" | |
| done | |
| - name: summary | |
| 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 '`${{ steps.ref.outputs.remote }}` (moves)' | |
| echo '`${{ steps.ref.outputs.pinned }}` (pinned)' | |
| echo | |
| if [ "${{ inputs.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 ${{ steps.ref.outputs.remote }}" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" |