Skip to content

0.4.0

0.4.0 #7

Workflow file for this run

---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
#
# Attach the static musl binaries to a published GitHub Release.
#
# Release Please owns the release itself (version, tag, changelog body) — see
# release-please.yml. This workflow only builds artifacts and uploads them, so
# it must never generate release notes of its own or it would clobber the
# changelog Release Please just wrote.
#
# The container image is built separately by docker.yml, which also triggers on
# `release: published`.
#
# Asset naming — `domarinn_<version>_linux_<arch>`, e.g.
# `domarinn_0.2.0_linux_amd64`. Three properties are deliberate:
#
# * The version is always the second underscore-separated field, so a
# downloaded file identifies itself. Nothing else in a release does: the
# binary was previously named only for its target, and a file sitting in
# ~/Downloads was unidentifiable.
# * `linux_amd64`, not the Rust triple `x86_64-unknown-linux-musl`. The triple
# puts the literal word "unknown" (it is the *vendor* field) exactly where a
# reader expects the version. `musl` is not in the name either — that these
# are fully static is a documented property, not a filename.
# * Underscores, matching the GoReleaser convention this project's assets are
# read against, even though it does not use GoReleaser (see 007df97 for why).
#
# Because the names carry a version, `releases/latest/download/<name>` is no
# longer constructible — GitHub has no wildcard there. Consumers resolve the tag
# first via the `/releases/latest` redirect; README.md, docs/getting-started.md
# and .github/actions/domarinn-eval/action.yml all do this.
name: Release
on:
release:
types: [published]
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
# ---------------------------------------------------------------------------
# 1. Build the static musl binaries and stage them as workflow artifacts.
# Both targets build natively — no `cross`, no QEMU — because GitHub
# provides arm64 runners free to public repositories. That also means
# neither leg is allowed to fail: an aarch64 binary is a shipped artifact,
# not a best-effort extra.
# ---------------------------------------------------------------------------
binaries:
name: build ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
# `target` is the Rust triple the compiler needs; `arch` is the name
# the world uses, and the one that reaches the released filename.
- target: x86_64-unknown-linux-musl
arch: amd64
runner: ubuntu-24.04
- target: aarch64-unknown-linux-musl
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# mise installs the pinned Rust/Node/pnpm from .mise/config.toml +
# .mise/mise.lock, the single source of toolchain versions for both CI
# and releases. Caching is off: this workflow publishes artifacts, and a
# poisoned cache entry must never be able to reach a shipped binary.
- uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3
with:
install_args: node pnpm rust
cache: false
# The release binary embeds the web UI, so build web/dist first.
- run: pnpm -C web install --frozen-lockfile
- run: pnpm -C web build
# musl-tools gives the C toolchain rusqlite's bundled SQLite (and the
# rustls crypto backend) need; cmake is a hedge for crypto backends.
- name: Install musl toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends musl-tools cmake
# The `musl-build` task adds the rustup target itself and then builds;
# same command locally and in CI. No rust-cache here (unlike ci.yml):
# release artifacts build from a cold cache.
- name: Build
env:
MUSL_TARGET: ${{ matrix.target }}
run: mise run musl-build
# Only the binary. Checksums are a single release-wide manifest written by
# the upload job, because a per-target file cannot cover the artifacts
# that do not belong to any one target.
- name: Stage artifact
env:
TARGET: ${{ matrix.target }}
ARCH: ${{ matrix.arch }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# Release Please tags bare semver (`0.2.0`, not `v0.2.0`), so this
# normally strips nothing — it is here so a future tag-format change
# cannot silently ship `domarinn_v0.2.0_linux_amd64`.
version="${TAG#v}"
mkdir -p dist
name="domarinn_${version}_linux_${ARCH}"
cp "target/${TARGET}/release/domarinn" "dist/${name}"
file "dist/${name}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binary-${{ matrix.arch }}
path: dist/*
if-no-files-found: error
# ---------------------------------------------------------------------------
# 2. Catalogue the dependency graph, once for the whole release.
#
# This is a job of its own rather than a step in the matrix above, and the
# separation is the point: the SBOM is generated from Cargo.lock and
# web/pnpm-lock.yaml, which say nothing about the target triple, so the two
# matrix legs used to produce the same 890-package document twice and ship
# it under two names — implying a per-target dependency graph that does not
# exist. One release, one SBOM.
#
# An empty checkout is also exactly what syft wants. On a pristine tree it
# sees only the two lockfiles; after a build it would additionally crawl
# target/ and node_modules/ — gigabytes of build output, for a worse
# result. And `[profile.release] strip = true` (Cargo.toml) means scanning
# the shipped binary would report 1 package where the lockfiles report 890.
#
# Both lockfiles matter: the binary embeds the built web UI, so the npm
# graph is genuinely part of what ships.
# ---------------------------------------------------------------------------
sbom:
name: catalogue dependencies
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Generate SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
format: spdx-json
output-file: sbom.spdx.json
# This workflow owns what reaches the release; the action must not
# attach assets or artifacts of its own.
upload-artifact: false
upload-release-assets: false
# `.spdx.json`, not `.sbom.json`: the extension is how a consumer tells
# SPDX from CycloneDX without parsing 1.9 MB of JSON. syft also names the
# document after the directory it scanned — literally "." — so stamp the
# release onto it, which is where the version belongs given the filename
# deliberately carries none (see the upload job).
- name: Name the document
env:
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
version="${TAG#v}"
mkdir -p dist
# -c because syft writes compact JSON and this step should change one
# field, not the whole encoding. Without it jq re-indents all 900-odd
# packages and the shipped asset grows ~28% (2.03 MB -> 2.60 MB in
# 0.1.3) for nothing a machine reads.
jq -c --arg name "domarinn-${version}" '.name = $name' sbom.spdx.json \
> "dist/domarinn_${version}.spdx.json"
jq -r '"\(.name): \(.spdxVersion), \(.packages | length) packages"' \
"dist/domarinn_${version}.spdx.json"
# Shipped as a release asset so it inherits the checksums and the cosign
# signature. The file is also readable at a raw tag URL without any of
# this — but that URL is unsigned, and a schema is something editors fetch
# and trust.
- name: Stage the config schema
env:
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
version="${TAG#v}"
cp domarinn.schema.json "dist/domarinn_${version}.schema.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: dist/*
if-no-files-found: error
# ---------------------------------------------------------------------------
# 3. Checksum, sign, and attach everything to the release Release Please
# already published.
# ---------------------------------------------------------------------------
upload:
name: upload release assets
needs: [binaries, sbom]
runs-on: ubuntu-24.04
permissions:
contents: write
# Keyless cosign: the OIDC token is the signing identity, so there is no
# private key to hold. Same mechanism docker/github-builder already uses
# to sign the container image.
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
merge-multiple: true
# One checksum manifest for the release, the convention every other Go/Rust
# project ships. Generated from inside dist/ because sha256sum records the
# path exactly as given: doing this from the repo root would bake in a
# `dist/` prefix that does not exist for whoever downloads the release,
# and their `sha256sum --check` would fail with "No such file or
# directory". That shipped broken in 0.1.1.
#
# Operands are listed explicitly rather than globbed as `*` for two
# reasons: the manifest must not end up inside itself, and `failglob`
# turns a missing artifact into a failed release instead of a silently
# short manifest.
- name: Generate checksums
env:
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
shopt -s failglob
version="${TAG#v}"
cd dist
sha256sum -- "domarinn_${version}_linux_"* "domarinn_${version}.spdx.json" \
"domarinn_${version}.schema.json" \
> "domarinn_${version}_checksums.txt"
cat "domarinn_${version}_checksums.txt"
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
# Sign every artifact — binaries, SBOM, and the checksum manifest alike —
# so a consumer can verify provenance without trusting the download path.
# Bundles are self-contained (certificate + signature + Rekor entry), so
# verification needs no keyserver, only `cosign verify-blob --bundle`.
# They are not themselves checksummed: a bundle carries its own integrity.
- name: Sign artifacts
run: |
set -euo pipefail
shopt -s nullglob
signed=0
for f in dist/*; do
case "$f" in
*.sigstore.json) continue ;;
esac
echo "::group::sign $f"
cosign sign-blob --yes --bundle "${f}.sigstore.json" "$f"
echo "::endgroup::"
signed=$((signed + 1))
done
# A silent zero here would publish an unsigned release that looks fine.
if [ "$signed" -eq 0 ]; then
echo "::error::no artifacts were signed - dist/ was empty"
exit 1
fi
echo "signed ${signed} artifacts"
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
GH_REPO: ${{ github.repository }}
# --clobber so re-running the workflow on an existing release replaces
# the assets instead of erroring out.
run: gh release upload "$TAG" dist/* --clobber