Repository navigation
a release the iso could have taken down with it #14
Workflow file for this run
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
| name: release | |
| on: | |
| # A tag is a release: it gets a version number, release notes, and a URL | |
| # that keeps meaning the same thing a year from now. | |
| push: | |
| tags: | |
| - "v*" | |
| # The rolling channel calls this workflow instead of sharing its push | |
| # trigger. A path filter on `push` cannot be scoped to branches, so | |
| # skipping doc-only builds here would also put the filter in front of | |
| # every tag, and a tag usually points at a commit that was already | |
| # pushed. What GitHub reports as "changed" for that push is not | |
| # something to find out by cutting a release and watching nothing | |
| # happen. See rolling.yml. | |
| workflow_call: | |
| inputs: | |
| attach_iso: | |
| description: "Build the live ISO and attach it to the release" | |
| required: false | |
| default: false | |
| type: boolean | |
| workflow_dispatch: | |
| inputs: | |
| attach_iso: | |
| description: "Build the live ISO and attach it to the release" | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: write | |
| # Keyless signing: cosign trades this token for a short-lived certificate | |
| # instead of anyone holding a private key. | |
| id-token: write | |
| env: | |
| CARGO_TERM_COLOR: always | |
| TARGET: x86_64-unknown-linux-musl | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| env: | |
| # Whether a release carries the live ISO, decided in one place. | |
| # | |
| # A tag is a `push` event, and the `inputs` context is empty for | |
| # those: defaults declared under workflow_dispatch and | |
| # workflow_call never materialise on a push. So the tag path — the | |
| # only trigger that actually cuts a release — reads the literal | |
| # below, and every other trigger reads its input verbatim. Flip | |
| # 'false' to 'true' to start attaching the ISO; the steps at the | |
| # end of this job say why it is off. | |
| ATTACH_ISO: ${{ github.event_name == 'push' && 'false' || inputs.attach_iso }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: work out what this build is | |
| id: what | |
| run: | | |
| set -euo pipefail | |
| version=$(cargo metadata --no-deps --format-version 1 \ | |
| | jq -r '.packages[] | select(.name == "kuma") | .version') | |
| # The asset name carries no version on purpose. A versioned | |
| # filename would make releases/latest/download/<name> change | |
| # every release, so the README could never hold a durable URL, | |
| # and the version is not lost: the tag has it and the binary | |
| # reports it. | |
| echo "name=kuma-${TARGET}" >> "$GITHUB_OUTPUT" | |
| if [ "${GITHUB_REF_TYPE}" = "tag" ]; then | |
| # A tag that disagrees with Cargo.toml would publish a binary | |
| # whose own --version contradicts the release it sits in. That | |
| # is the exact class of lie the build stamp exists to prevent, | |
| # so it fails here rather than shipping. | |
| if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then | |
| echo "tag ${GITHUB_REF_NAME} does not match Cargo.toml version ${version}" >&2 | |
| exit 1 | |
| fi | |
| { | |
| echo "tag=v${version}" | |
| echo "title=kuma v${version}" | |
| echo "publish=true" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| # Everything above the publish step still runs from main. What | |
| # main does not get is a release: the releases page lists | |
| # versions, and a rolling entry sitting on top of them says a | |
| # version shipped when none did. The binary is still built, | |
| # tested, signed, and downloadable — as a workflow artifact, | |
| # which is where a build that is not a release belongs. | |
| echo "publish=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: x86_64-unknown-linux-musl | |
| - uses: Swatinem/rust-cache@v2 | |
| # ci.yml runs on this same push, but nothing makes this job wait for | |
| # it, so without a gate here a red tree still publishes a binary. | |
| # Testing on the release target rather than the host's costs nothing | |
| # extra: it is one compile instead of two, and it exercises the | |
| # artifact actually being shipped. | |
| - name: test | |
| run: cargo test --locked --target "$TARGET" | |
| # Static by construction, not by flag: kuma is pure Rust with no NSS | |
| # calls and no in-process networking, so a musl build has nothing to | |
| # dynamically link. That is what lets it run on an image-based | |
| # desktop that has podman and no toolchain, which is the machine | |
| # most likely to want it. | |
| # remap-path-prefix, because rustc records source paths so a panic | |
| # can name a file, and `kuma build` bakes this binary into every | |
| # image it produces. Without it a published image carries the | |
| # absolute paths of whoever built the binary, which on a laptop is | |
| # their username. `[profile.release] trim-paths` would be the tidy | |
| # way and is still unstable in Cargo 1.97, where an unstable profile | |
| # key is a hard manifest error rather than a warning, so it cannot | |
| # be used yet. scripts/publish-audit.sh fails on any home path that | |
| # survives. | |
| - name: build | |
| env: | |
| RUSTFLAGS: >- | |
| --remap-path-prefix=${{ github.workspace }}=/kuma | |
| --remap-path-prefix=/home/runner/.cargo/registry=/cargo | |
| run: cargo build --locked --release --target "$TARGET" | |
| - name: package | |
| id: pkg | |
| run: | | |
| set -euo pipefail | |
| name='${{ steps.what.outputs.name }}' | |
| cp "target/${TARGET}/release/kuma" "$name" | |
| strip "$name" | |
| sha256sum "$name" > "${name}.sha256" | |
| # Prove the artifact runs before publishing it. A binary that | |
| # cannot answer --version on a clean runner is not a release. | |
| "./$name" --version | |
| echo "name=$name" >> "$GITHUB_OUTPUT" | |
| - uses: sigstore/cosign-installer@v3 | |
| # One bundle rather than a separate .sig and .pem: cosign deprecated | |
| # --output-signature and --output-certificate, and the flags that | |
| # read them back are deprecated on the verify side too. Those | |
| # commands are quoted in the release notes below, and a release's | |
| # notes cannot be corrected once people have them, so this uses the | |
| # form that will still be current later. | |
| - name: sign | |
| run: | | |
| set -euo pipefail | |
| cosign sign-blob --yes \ | |
| --bundle '${{ steps.pkg.outputs.name }}.bundle' \ | |
| '${{ steps.pkg.outputs.name }}' | |
| # A build from main is not a release, so it does not get one. It is | |
| # still the whole release path up to this point — same test, same | |
| # target, same packaging, same signature — which is what makes every | |
| # push to main a rehearsal of what a tag will do. The artifact keeps | |
| # the binary reachable for anyone who wants main before a tag, and | |
| # expires on its own instead of accumulating on the releases page. | |
| - name: keep the build without releasing it | |
| if: steps.what.outputs.publish != 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ steps.pkg.outputs.name }} | |
| path: | | |
| ${{ steps.pkg.outputs.name }} | |
| ${{ steps.pkg.outputs.name }}.sha256 | |
| ${{ steps.pkg.outputs.name }}.bundle | |
| if-no-files-found: error | |
| # The notes used to be identical for every release, so the page a | |
| # download lands on said nothing about what changed. CHANGELOG.md is | |
| # the source, and a tag with no section fails here rather than | |
| # shipping generic notes. That is what keeps the file from rotting: | |
| # a release cannot be cut without writing one. | |
| - name: release notes | |
| if: steps.what.outputs.publish == 'true' | |
| run: | | |
| set -euo pipefail | |
| heading='## ${{ steps.what.outputs.tag }}' | |
| # Prefix match, so the heading can carry a date. Anchored with | |
| # index() rather than a regex because a version is full of dots. | |
| awk -v h="$heading" ' | |
| !found && index($0, h) == 1 \ | |
| && (length($0) == length(h) || substr($0, length(h) + 1, 1) == " ") { | |
| found = 1 | |
| next | |
| } | |
| found && /^## / { exit } | |
| found { print } | |
| ' CHANGELOG.md > section.md | |
| if [ ! -s section.md ]; then | |
| echo "CHANGELOG.md has no ${heading} section; a release nobody can read is not a release" >&2 | |
| exit 1 | |
| fi | |
| # Unwrap the paragraphs before publishing. | |
| # | |
| # A release body is not rendered the way a .md file in the repo | |
| # is. GitHub treats it like an issue comment, where every | |
| # newline becomes a line break, so CHANGELOG.md's 78-column hard | |
| # wrapping arrives as text that stops ragged at half the page | |
| # while the docs beside it look fine. The file should stay | |
| # wrapped; what gets published should not be. | |
| # | |
| # Joins a line onto the one before it unless that line starts | |
| # something: a blank, a heading, a list item, a table row, or a | |
| # fence. Inside a fence nothing is touched, because reflowing a | |
| # command is how a release ships one nobody can paste. | |
| awk ' | |
| /^```/ { fence = !fence; flush(); print; next } | |
| fence { print; next } | |
| /^[[:space:]]*$/ || /^#/ || /^\|/ { flush(); print; next } | |
| # A list item starts a buffer rather than printing: its own | |
| # continuation lines have to join onto it, not follow it. | |
| /^[-*+] / || /^[0-9]+\. / { flush(); buf = $0; next } | |
| { | |
| sub(/^[[:space:]]+/, "") | |
| buf = (buf == "" ? $0 : buf " " $0) | |
| next | |
| } | |
| END { flush() } | |
| function flush() { if (buf != "") { print buf; buf = "" } } | |
| ' section.md > reflowed.md && mv reflowed.md section.md | |
| - name: publish | |
| if: steps.what.outputs.publish == 'true' | |
| run: | | |
| set -euo pipefail | |
| gh release create '${{ steps.what.outputs.tag }}' \ | |
| --title '${{ steps.what.outputs.title }}' \ | |
| --notes "$(cat section.md; cat <<'NOTES' | |
| Static `x86_64` binary, no runtime dependencies. | |
| ```console | |
| $ curl -LO ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ steps.what.outputs.tag }}/${{ steps.pkg.outputs.name }} | |
| $ chmod +x ${{ steps.pkg.outputs.name }} && sudo mv ${{ steps.pkg.outputs.name }} /usr/local/bin/kuma | |
| ``` | |
| Verify it came from this workflow: | |
| ```console | |
| $ cosign verify-blob \ | |
| --bundle ${{ steps.pkg.outputs.name }}.bundle \ | |
| --certificate-identity-regexp '^${{ github.server_url }}/${{ github.repository }}/' \ | |
| --certificate-oidc-issuer https://token.actions.githubusercontent.com \ | |
| ${{ steps.pkg.outputs.name }} | |
| ``` | |
| Built from `${{ github.sha }}`. | |
| NOTES | |
| )" \ | |
| --target '${{ github.sha }}' \ | |
| '${{ steps.pkg.outputs.name }}' \ | |
| '${{ steps.pkg.outputs.name }}.sha256' \ | |
| '${{ steps.pkg.outputs.name }}.bundle' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| # Everything below runs *after* the release exists, and that | |
| # ordering is the point: building the ISO means a full image build | |
| # and a QEMU boot with a serial-console assertion, so putting it | |
| # ahead of `publish` meant a live session that failed to reach a | |
| # desktop took the binary's release down with it. The ISO is an | |
| # addition to a release that has already happened. | |
| # | |
| # Off by default, and deliberately so. ci.yml builds and boots this | |
| # ISO on every push, which is where its failures should be found; | |
| # attaching it to a tag means a 1.8 GB asset whose first real | |
| # exercise happens during a release. Turn it on by flipping the | |
| # `|| false` below to `|| true`, once that job has a run history. | |
| # | |
| # Read through `env` rather than `inputs.attach_iso` directly | |
| # because a tag is a `push` event, and the `inputs` context is empty | |
| # for those: defaults declared under workflow_dispatch and | |
| # workflow_call never materialise there. Testing the input alone | |
| # meant the documented way to switch this on could not affect the | |
| # only trigger that actually cuts a release. | |
| - uses: ./.github/actions/kvm | |
| if: env.ATTACH_ISO == 'true' && steps.what.outputs.publish == 'true' | |
| - name: live ISO | |
| id: iso | |
| if: env.ATTACH_ISO == 'true' && steps.what.outputs.publish == 'true' | |
| env: | |
| QEMU_VGA: virtio-vga | |
| QEMU_DISPLAY: none | |
| run: | | |
| set -euo pipefail | |
| sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ | |
| /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" || true | |
| cargo build --locked | |
| ./scripts/smoke.sh --iso --keep niri | |
| name="kuma-${{ steps.what.outputs.tag }}-x86_64.iso" | |
| mv vm-smoke/niri-iso/KUMA.iso "$name" | |
| sha256sum "$name" > "$name.sha256" | |
| echo "name=$name" >> "$GITHUB_OUTPUT" | |
| # SECURITY.md says every release asset is signed with Sigstore, and | |
| # that has to stay true of the asset that becomes a bootable | |
| # machine. A checksum beside the file is not a substitute: it is | |
| # served from the same page, so anything that can replace one can | |
| # replace the other. | |
| - name: sign the live ISO | |
| if: env.ATTACH_ISO == 'true' && steps.what.outputs.publish == 'true' | |
| run: | | |
| set -euo pipefail | |
| cosign sign-blob --yes \ | |
| --bundle '${{ steps.iso.outputs.name }}.bundle' \ | |
| '${{ steps.iso.outputs.name }}' | |
| # Uploaded separately rather than templated into the file list | |
| # above, so the release exists either way: an ISO that failed to | |
| # attach should not take the binary's release down with it. | |
| - name: attach the live ISO | |
| if: env.ATTACH_ISO == 'true' && steps.what.outputs.publish == 'true' | |
| run: | | |
| set -euo pipefail | |
| gh release upload '${{ steps.what.outputs.tag }}' \ | |
| '${{ steps.iso.outputs.name }}' \ | |
| '${{ steps.iso.outputs.name }}.sha256' \ | |
| '${{ steps.iso.outputs.name }}.bundle' | |
| env: | |
| GH_TOKEN: ${{ github.token }} |