release.yml
In .github/workflows/ · At 619cbadCopy the commit ID
Preview
Code · 35.4 KiBname: "Release: Artifacts"
# Triggered by pushing a semver tag (v0.1.0, v1.2.3, etc.).
#
# This workflow:
# 1. Builds mkit binaries for macOS (arm64/x86_64) and
# Linux (x86_64/arm64; glibc, dynamically linked) with a pinned Rust
# toolchain (see rust/rust-toolchain.toml), one archive per target.
# Windows is not a supported target (MKIT-6; see docs/INVARIANTS.md) —
# WSL runs the Linux binary.
# 2. Produces a CycloneDX SBOM (sbom.cdx.json).
# 3. Signs every archive with cosign OIDC keyless (transparency log: Rekor)
# and signs the aggregated SHA256SUMS the same way.
# 4. Generates a GitHub-native SLSA build provenance attestation
# (actions/attest-build-provenance) over the archives, in addition to
# the cosign signatures.
# 5. Aggregates all artifacts into a GitHub Release.
#
# macOS binaries are intentionally NOT codesigned or notarized — we do not
# hold an Apple Developer ID. Trust is established via cosign keyless
# signatures, which are verified by `install.sh` and documented in
# docs/RELEASE.md. Users who launch the binary by double-clicking
# in Finder will hit Gatekeeper; running from a terminal works.
#
# Cache safety: every Swatinem/rust-cache invocation here uses a release-
# specific `prefix-key`, partitioned away from the keys that PR-event
# workflows write to. This prevents a malicious PR from poisoning a cache
# that this workflow (which carries `secrets.MKIT_NPM_TOKEN` and
# `id-token: write`) would later deserialize. See the cache-poisoning
# supply-chain audit pass.
#
# No secrets are needed for cosign keyless — OIDC token comes from GitHub.
on:
push:
tags:
- 'v*.*.*'
# Manual escape hatch. GitHub occasionally de-dupes tag-push events when
# a prior workflow for a nearby tag is still active or recently cancelled.
# workflow_dispatch lets us force a release build against an existing tag
# from the Actions UI. The workflow must be run from that tag ref, not from
# a branch, so cosign's OIDC certificate identity remains tag-scoped.
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0). Must already be pushed to origin.'
required: true
type: string
permissions:
contents: read
env:
RUST_VERSION: "1.95.0"
BINARY_NAME: mkit
jobs:
validate-release-tag:
name: Validate release tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.validate.outputs.tag }}
ref: ${{ steps.validate.outputs.ref }}
tag_object: ${{ steps.validate.outputs.tag_object }}
commit: ${{ steps.validate.outputs.commit }}
version: ${{ steps.validate.outputs.version }}
steps:
- name: Checkout release refs
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Validate tag trust and ancestry
id: validate
shell: bash
env:
DISPATCH_TAG: ${{ inputs.tag }}
TRUSTED_FINGERPRINTS: ${{ vars.MKIT_RELEASE_GPG_FINGERPRINTS }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${DISPATCH_TAG}"
else
TAG="${GITHUB_REF_NAME}"
fi
if [ -z "${TAG}" ]; then
echo "ERROR: no tag resolved (event=${{ github.event_name }})" >&2
exit 1
fi
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
echo "ERROR: release tag must match vMAJOR.MINOR.PATCH with optional prerelease suffix: ${TAG}" >&2
exit 1
fi
if [ -z "${TRUSTED_FINGERPRINTS}" ]; then
echo "ERROR: MKIT_RELEASE_GPG_FINGERPRINTS repo/org variable is required" >&2
exit 1
fi
TRUSTED_FINGERPRINTS="$(printf '%s' "${TRUSTED_FINGERPRINTS}" | tr '[:lower:]' '[:upper:]')"
git fetch --force --tags origin
git fetch --force origin main:refs/remotes/origin/main
# Import each trusted key from a keyserver. We gate on the key
# actually LANDING IN THE KEYRING, not on `gpg --recv-keys`' exit
# code: keys.openpgp.org serves a key with NO user IDs until its
# owner verifies their email, and GnuPG SKIPS importing a UID-less
# key ("contains no user ID - skipped") while STILL exiting 0 — so a
# bare `recv || recv` fallback never fires and the next step dies
# with NO_PUBKEY. (This broke v0.2.0's first release run.)
#
# keyserver.ubuntu.com serves user IDs WITHOUT email verification, so
# it's tried first; keys.openpgp.org is a fallback. After each fetch
# we check the key is present (UID-bearing) before trusting it.
KEYSERVERS="hkps://keyserver.ubuntu.com hkps://keys.openpgp.org"
for TRUSTED_FINGERPRINT in ${TRUSTED_FINGERPRINTS}; do
if [[ ! "${TRUSTED_FINGERPRINT}" =~ ^[A-Fa-f0-9]{40}$ ]]; then
echo "ERROR: invalid GPG fingerprint in MKIT_RELEASE_GPG_FINGERPRINTS: ${TRUSTED_FINGERPRINT}" >&2
exit 1
fi
IMPORTED=0
for KS in ${KEYSERVERS}; do
gpg --batch --keyserver "${KS}" --recv-keys "${TRUSTED_FINGERPRINT}" || true
if gpg --list-keys "${TRUSTED_FINGERPRINT}" >/dev/null 2>&1; then
echo "Imported ${TRUSTED_FINGERPRINT} from ${KS}"
IMPORTED=1
break
fi
echo "WARN: ${KS} did not yield a usable (UID-bearing) key for ${TRUSTED_FINGERPRINT}; trying next" >&2
done
if [ "${IMPORTED}" -ne 1 ]; then
echo "ERROR: could not import GPG key ${TRUSTED_FINGERPRINT} from any keyserver (${KEYSERVERS})." >&2
echo " If the key lives only on keys.openpgp.org, verify its email there so it serves user IDs." >&2
exit 1
fi
done
REF="refs/tags/${TAG}"
if ! git show-ref --verify --quiet "${REF}"; then
echo "ERROR: tag ${TAG} does not exist" >&2
exit 1
fi
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${GITHUB_REF}" != "${REF}" ]; then
echo "ERROR: manual releases must run from ${REF}, not ${GITHUB_REF}" >&2
echo "Select the release tag in the Actions UI's 'Use workflow from' picker and keep the tag input identical." >&2
exit 1
fi
if [ "$(git cat-file -t "${REF}")" != "tag" ]; then
echo "ERROR: ${TAG} must be an annotated signed tag, not a lightweight tag" >&2
exit 1
fi
VERIFY_LOG="$(mktemp)"
if ! git verify-tag --raw -v "${REF}" >"${VERIFY_LOG}" 2>&1; then
cat "${VERIFY_LOG}" >&2
echo "ERROR: ${TAG} does not have a valid GPG signature" >&2
exit 1
fi
FINGERPRINT="$(awk '/^\[GNUPG:\] VALIDSIG / { print $3; exit }' "${VERIFY_LOG}" | tr '[:lower:]' '[:upper:]')"
if [ -z "${FINGERPRINT}" ]; then
cat "${VERIFY_LOG}" >&2
echo "ERROR: could not extract tag signing fingerprint for ${TAG}" >&2
exit 1
fi
case " ${TRUSTED_FINGERPRINTS} " in
*" ${FINGERPRINT} "*) ;;
*)
echo "ERROR: tag signer ${FINGERPRINT} is not allowlisted in MKIT_RELEASE_GPG_FINGERPRINTS" >&2
exit 1
;;
esac
TAG_OBJECT="$(git rev-parse "${REF}^{tag}")"
TAG_COMMIT="$(git rev-parse "${REF}^{commit}")"
if ! git merge-base --is-ancestor "${TAG_COMMIT}" origin/main; then
echo "ERROR: ${TAG} target commit ${TAG_COMMIT} is not reachable from origin/main" >&2
exit 1
fi
VERSION="${TAG#v}"
{
echo "tag=${TAG}"
echo "ref=${REF}"
echo "tag_object=${TAG_OBJECT}"
echo "commit=${TAG_COMMIT}"
echo "version=${VERSION}"
} >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
needs: validate-release-tag
permissions:
contents: read
id-token: write # cosign keyless OIDC
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-14
archive_ext: tar.gz
# x86_64 macOS built on the arm64 host via rustup cross-compile.
- target: x86_64-apple-darwin
runner: macos-14
archive_ext: tar.gz
# Pin both Linux runners to keep the linked glibc requirement stable.
- target: x86_64-unknown-linux-gnu
runner: ubuntu-24.04
archive_ext: tar.gz
- target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
archive_ext: tar.gz
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- name: Resolve release metadata
id: meta
shell: bash
run: |
TAG="${{ needs.validate-release-tag.outputs.tag }}"
REF="${{ needs.validate-release-tag.outputs.ref }}"
COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
VERSION="${{ needs.validate-release-tag.outputs.version }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "ref=${REF}" >> "$GITHUB_OUTPUT"
echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "archive=${{ env.BINARY_NAME }}-${VERSION}-${{ matrix.target }}.${{ matrix.archive_ext }}" >> "$GITHUB_OUTPUT"
echo "stage=${{ env.BINARY_NAME }}-${VERSION}-${{ matrix.target }}" >> "$GITHUB_OUTPUT"
- name: Checkout (at resolved tag)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.meta.outputs.commit }}
fetch-depth: 0
- name: Install Rust ${{ env.RUST_VERSION }} + target
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.target }}
components: rustfmt,clippy
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
with:
workspaces: rust -> rust/target
# Partition release caches away from PR-event workflow caches —
# see header note on cache poisoning.
prefix-key: "rust-release-${{ matrix.target }}"
key: release-${{ matrix.target }}
cache-on-failure: false
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: '31.0'
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Select only mkit-cli with -p to avoid workspace feature unification (D31).
- name: Build release mkit binary
shell: bash
working-directory: rust
env:
# codegen-units=1 + strip = reproducible-build friendly; mirrors
# [profile.release] in rust/Cargo.toml.
RUSTFLAGS: "-C codegen-units=1 -C strip=symbols"
run: |
cargo build \
--release \
--locked \
--target ${{ matrix.target }} \
-p mkit-cli \
--bin ${{ env.BINARY_NAME }} \
--message-format=json-render-diagnostics > "${RUNNER_TEMP}/mkit-build.jsonl"
# Checks what the mkit build really compiled (its compiler-artifact
# messages) and scans the stripped binary: only packages in
# scripts/release/mkit-packages.golden, no server-only package, no
# hyper `server`, no connectrpc `server`/`axum`, tower-http only with
# reqwest's features, no SQLite, no test seam. release-artifact-check.yml
# runs the same builds and check on PRs to main for one Linux target.
- name: Check release mkit artifact
shell: bash
run: |
bash scripts/check-release-artifact-features.sh \
"${RUNNER_TEMP}/mkit-build.jsonl" \
"rust/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
- name: Smoke-test release mkit binary
shell: bash
working-directory: rust
run: |
set -euo pipefail
HOST="$(rustc -vV | sed -n 's/^host: //p')"
if [ "${HOST}" != "${{ matrix.target }}" ]; then
echo "Cross-built for ${{ matrix.target }} on ${HOST}; skipping the run check."
exit 0
fi
VERSION="${{ steps.meta.outputs.version }}"
BIN_DIR="target/${{ matrix.target }}/release"
test "$("${BIN_DIR}/${{ env.BINARY_NAME }}" version)" = "mkit ${VERSION}"
- name: Stage archive contents
shell: bash
run: |
STAGE="${{ steps.meta.outputs.stage }}"
mkdir -p "${STAGE}"
BIN_SRC="rust/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
if [ -f "${BIN_SRC}" ]; then
cp "${BIN_SRC}" "${STAGE}/"
else
echo "ERROR: ${{ env.BINARY_NAME }} binary not found at ${BIN_SRC}" >&2
ls -la "rust/target/${{ matrix.target }}/release/" || true
exit 1
fi
cp LICENSE-MIT LICENSE-APACHE README.md "${STAGE}/"
# Optional artifacts — included when present.
[ -f CHANGELOG.md ] && cp CHANGELOG.md "${STAGE}/" || true
mkdir -p "${STAGE}/share/man/man1" "${STAGE}/share/completions"
cp man/mkit.1 "${STAGE}/share/man/man1/mkit.1"
cp completions/mkit.bash "${STAGE}/share/completions/mkit.bash"
cp completions/mkit.zsh "${STAGE}/share/completions/_mkit"
cp completions/mkit.fish "${STAGE}/share/completions/mkit.fish"
- name: Compute per-archive SHA256SUMS
shell: bash
run: |
for STAGE in "${{ steps.meta.outputs.stage }}"; do
(
cd "${STAGE}"
if command -v sha256sum >/dev/null 2>&1; then
find . -type f ! -name SHA256SUMS -exec sha256sum {} \; | sort -k 2 > SHA256SUMS
else
find . -type f ! -name SHA256SUMS -exec shasum -a 256 {} \; | sort -k 2 > SHA256SUMS
fi
cat SHA256SUMS
)
done
- name: Create archives (tar.gz)
shell: bash
run: |
tar -czf "${{ steps.meta.outputs.archive }}" "${{ steps.meta.outputs.stage }}"
ls -la "${{ steps.meta.outputs.archive }}"
- name: Compute archive-level SHA256
shell: bash
run: |
for ARCHIVE in "${{ steps.meta.outputs.archive }}"; do
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256"
else
shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256"
fi
cat "${ARCHIVE}.sha256"
done
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v2.4.1
- name: Sign archives with cosign (keyless OIDC)
shell: bash
env:
COSIGN_EXPERIMENTAL: "1"
COSIGN_YES: "true"
run: |
for ARCHIVE in "${{ steps.meta.outputs.archive }}"; do
cosign sign-blob --yes \
--bundle "${ARCHIVE}.cosign.bundle" \
--output-signature "${ARCHIVE}.sig" \
--output-certificate "${ARCHIVE}.crt" \
"${ARCHIVE}"
echo "Signed ${ARCHIVE}; transparency log entries visible at https://search.sigstore.dev/"
done
# NOTE: macOS binaries are deliberately not codesigned or notarized.
# We rely on cosign keyless signatures (signed above) for trust.
# If/when an Apple Developer ID is provisioned, add codesign +
# `xcrun notarytool submit … --wait` + `xcrun stapler staple` here
# and re-tar the archive AFTER stapling so the ticket is embedded.
- name: Upload build artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: build-${{ matrix.target }}
path: |
${{ steps.meta.outputs.archive }}
${{ steps.meta.outputs.archive }}.sha256
${{ steps.meta.outputs.archive }}.sig
${{ steps.meta.outputs.archive }}.crt
${{ steps.meta.outputs.archive }}.cosign.bundle
if-no-files-found: error
retention-days: 7
sbom:
name: Generate SBOM
runs-on: ubuntu-latest
needs: [validate-release-tag, build]
permissions:
contents: read
steps:
- name: Resolve release metadata
id: meta
shell: bash
run: |
TAG="${{ needs.validate-release-tag.outputs.tag }}"
REF="${{ needs.validate-release-tag.outputs.ref }}"
COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
VERSION="${{ needs.validate-release-tag.outputs.version }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "ref=${REF}" >> "$GITHUB_OUTPUT"
echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Checkout (at resolved tag)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.meta.outputs.commit }}
fetch-depth: 0
# Scans the checked-out tree, so it reads rust/Cargo.lock: one graph
# for the whole workspace, including library and test dependencies.
- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
path: .
format: cyclonedx-json
output-file: sbom.cdx.json
artifact-name: sbom.cdx.json
upload-artifact: false
# Schema-validates the generated SBOM (plus a non-empty-components
# sanity check) before it ships as a real release artifact — see
# scripts/validate-sbom.sh for why. cyclonedx-cli ships as a single
# self-contained binary per release (no package-manager action for
# it in common use), so fetch it directly rather than adding a new
# third-party GitHub Action to this trust boundary.
- name: Install cyclonedx-cli
run: |
set -euo pipefail
curl -sSfL -o /usr/local/bin/cyclonedx \
https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64
chmod +x /usr/local/bin/cyclonedx
cyclonedx --version
- name: Validate SBOM
run: bash scripts/validate-sbom.sh sbom.cdx.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error
retention-days: 7
third-party-notices:
name: Generate THIRD-PARTY-NOTICES
runs-on: ubuntu-latest
needs: [validate-release-tag, build]
permissions:
contents: read
steps:
- name: Resolve release metadata
id: meta
shell: bash
run: |
COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
- name: Checkout (at resolved tag)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.meta.outputs.commit }}
fetch-depth: 0
# cargo-about has no cargo-nextest-style default binary — the crates.io
# crate gates its CLI behind the `cli` feature (see rust/about.toml's
# header comment). taiki-e/install-action fetches a prebuilt binary the
# same way the cargo-nextest/cargo-semver-checks installs above do, so
# this job never compiles cargo-about from source.
- name: Install cargo-about
uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
with:
tool: cargo-about@0.9.1
# Scoped to the whole `rust` workspace's resolved dependency graph
# (--all-features, so no optional/feature-gated dependency's license is
# silently dropped from attribution just because this release's default
# build didn't enable it) — matches the SBOM job's whole-workspace scope
# above. rust/about.toml carries the accepted-license policy in lockstep
# with rust/deny.toml's [licenses] allow list; cargo-about fails this
# step outright on any dependency whose license isn't in that list, and
# that failure is a real release blocker (see rust/about.toml).
- name: Generate THIRD-PARTY-NOTICES
working-directory: rust
run: |
cargo about generate --all-features about.hbs -o THIRD-PARTY-NOTICES
- name: Upload THIRD-PARTY-NOTICES artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: third-party-notices
path: rust/THIRD-PARTY-NOTICES
if-no-files-found: error
retention-days: 7
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs: [validate-release-tag, build, sbom, third-party-notices]
if: >-
${{ !cancelled()
&& needs.validate-release-tag.result == 'success'
&& needs.build.result == 'success'
&& needs.sbom.result == 'success'
&& needs.third-party-notices.result == 'success' }}
permissions:
contents: write # create the GitHub Release + upload assets
id-token: write # cosign keyless OIDC + SLSA provenance OIDC
attestations: write # actions/attest-build-provenance
steps:
- name: Resolve version
id: meta
shell: bash
run: |
TAG="${{ needs.validate-release-tag.outputs.tag }}"
REF="${{ needs.validate-release-tag.outputs.ref }}"
TAG_OBJECT="${{ needs.validate-release-tag.outputs.tag_object }}"
COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
VERSION="${{ needs.validate-release-tag.outputs.version }}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "ref=${REF}" >> "$GITHUB_OUTPUT"
echo "tag_object=${TAG_OBJECT}" >> "$GITHUB_OUTPUT"
echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
- name: Checkout (at resolved tag)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.meta.outputs.commit }}
fetch-depth: 0
- name: Verify tag still points at validated object
shell: bash
run: |
set -euo pipefail
TAG="${{ steps.meta.outputs.tag }}"
REF="${{ steps.meta.outputs.ref }}"
EXPECTED_TAG_OBJECT="${{ steps.meta.outputs.tag_object }}"
EXPECTED_COMMIT="${{ steps.meta.outputs.commit }}"
git fetch --force origin "${REF}:${REF}"
ACTUAL_TAG_OBJECT="$(git rev-parse "${REF}^{tag}")"
ACTUAL_COMMIT="$(git rev-parse "${REF}^{commit}")"
if [ "${ACTUAL_TAG_OBJECT}" != "${EXPECTED_TAG_OBJECT}" ] || [ "${ACTUAL_COMMIT}" != "${EXPECTED_COMMIT}" ]; then
echo "ERROR: tag ${TAG} moved after validation" >&2
exit 1
fi
- name: Download all artifacts
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
path: artifacts
- name: Flatten artifacts into dist/
shell: bash
run: |
mkdir -p dist
find artifacts -type f -exec cp {} dist/ \;
ls -la dist/
# ---- SLSA build provenance (GitHub/Sigstore-native) -----------------
# A standard SLSA provenance attestation over the release archives,
# generated by GitHub's own actions/attest-build-provenance action and
# recorded in the Sigstore public-good transparency log. This is
# additive to the cosign signatures: it gives downstream tooling
# (`gh attestation verify`, `slsa-verifier`) a recognized, off-the-shelf
# provenance format to check against.
# Requires `attestations: write` (granted above) and `id-token: write`.
- name: Generate SLSA build provenance
id: attest-provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.tar.gz
- name: Stage SLSA provenance attestation for release upload
shell: bash
run: |
cp "${{ steps.attest-provenance.outputs.bundle-path }}" \
"dist/mkit-${{ steps.meta.outputs.version }}.provenance.jsonl"
- name: Compute top-level SHA256SUMS
shell: bash
working-directory: dist
run: |
FILES=(*.tar.gz *.provenance.jsonl sbom.cdx.json THIRD-PARTY-NOTICES)
sha256sum "${FILES[@]}" > SHA256SUMS || \
shasum -a 256 "${FILES[@]}" > SHA256SUMS
cat SHA256SUMS
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v2.4.1
- name: Sign aggregated SHA256SUMS
shell: bash
working-directory: dist
env:
COSIGN_EXPERIMENTAL: "1"
COSIGN_YES: "true"
run: |
cosign sign-blob --yes \
--bundle SHA256SUMS.cosign.bundle \
--output-signature SHA256SUMS.sig \
--output-certificate SHA256SUMS.crt \
SHA256SUMS
- name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: mkit ${{ steps.meta.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
files: |
dist/*.tar.gz
dist/*.tar.gz.sha256
dist/*.tar.gz.sig
dist/*.tar.gz.crt
dist/*.tar.gz.cosign.bundle
dist/*.provenance.jsonl
dist/sbom.cdx.json
dist/THIRD-PARTY-NOTICES
dist/SHA256SUMS
dist/SHA256SUMS.sig
dist/SHA256SUMS.crt
dist/SHA256SUMS.cosign.bundle
body: |
## mkit ${{ steps.meta.outputs.version }}
Content-addressed VCS for creative work.
### Verifying downloads
Every archive is signed with **cosign keyless** via GitHub OIDC; the
signing certificate embeds the workflow identity and a Rekor
transparency log entry is public. See `docs/RELEASE.md`
in the repo for full verification steps.
The verifier regex below is anchored: it pins the exact workflow
path and accepts only strict semver tags (with optional
pre-release suffix like `-rc.1`).
```sh
cosign verify-blob \
--certificate-identity-regexp '^https://github\.com/officialunofficial/mkit/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle <archive>.cosign.bundle \
<archive>
```
### SLSA build provenance
Every archive also carries a standard SLSA build provenance
attestation, generated by GitHub's
[`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance)
and recorded in the Sigstore public-good transparency log. Verify
it with the GitHub CLI (no extra tooling required):
```sh
gh attestation verify <archive> --repo officialunofficial/mkit
```
or with [`slsa-verifier`](https://github.com/slsa-framework/slsa-verifier):
```sh
slsa-verifier verify-artifact <archive> \
--provenance-path mkit-${{ steps.meta.outputs.version }}.provenance.jsonl \
--source-uri github.com/officialunofficial/mkit \
--source-tag ${{ steps.meta.outputs.tag }}
```
This is additive to the cosign signature above, not a
replacement for it.
### Platform notes
macOS binaries are **not codesigned or notarized**. Trust is
established via the cosign signature above. Running from a
terminal works; double-clicking in Finder hits Gatekeeper.
Windows is not a supported target. Windows users should run
mkit under WSL, which uses the Linux binary.
### SBOM
A CycloneDX SBOM (`sbom.cdx.json`) is attached. Reflects the
Rust crate graph of the whole workspace, including library and
test dependencies, plus toolchain and OS libraries.
### npm package
The WASM bindings are also published to npm as
[`@officialunofficial/mkit-wasm`](https://www.npmjs.com/package/@officialunofficial/mkit-wasm) at the
same version, with Sigstore-backed provenance attached to this
workflow run.
publish-wasm:
name: Publish mkit-wasm to npm
runs-on: ubuntu-latest
# npm provenance (`npm publish --provenance`, below) requires a PUBLIC
# source repository. While the repo is private/internal this job is
# SKIPPED (not failed) so the CLI binary release still goes green; it
# self-activates the next time a tag is pushed after the repo is made
# public. The job body keeps its own explicit public-repo guard as a
# defense-in-depth check.
#
if: ${{ !cancelled() && needs.release.result == 'success' && github.event.repository.visibility == 'public' }}
needs: [validate-release-tag, build, release]
permissions:
contents: write # attach assets to the GitHub Release
id-token: write # npm provenance via OIDC
steps:
- name: Resolve tag
id: meta
shell: bash
run: |
TAG="${{ needs.validate-release-tag.outputs.tag }}"
REF="${{ needs.validate-release-tag.outputs.ref }}"
COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
VERSION="${{ needs.validate-release-tag.outputs.version }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "ref=${REF}" >> "$GITHUB_OUTPUT"
echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Checkout (at resolved tag)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.meta.outputs.commit }}
fetch-depth: 0
- name: Install Rust ${{ env.RUST_VERSION }} + wasm32 target
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: ${{ env.RUST_VERSION }}
targets: wasm32-unknown-unknown
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
with:
workspaces: rust -> rust/target
prefix-key: "rust-release-wasm32"
key: release-wasm32
cache-on-failure: false
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: '31.0'
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js (npm registry)
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Trusted Publishing (OIDC) needs npm >= 11.5.1; Node 22's bundled npm
# on GitHub-hosted runners drifts with each LTS patch, so pin
# explicitly rather than trust whatever ships that day.
- name: Ensure npm >= 11.5.1 (Trusted Publishing requirement)
run: npm install -g npm@^11.5.1
- name: Install wasm-pack
run: cargo install wasm-pack --locked --version 0.13.1
- name: Build mkit-wasm (target=bundler)
working-directory: rust
run: |
wasm-pack build crates/mkit-wasm \
--release \
--target bundler \
--out-dir pkg
- name: Patch bundler glue for Bun / Workers compat (issue #90)
# wasm-pack 0.13's auto-init `__wbg_set_wasm(wasm)` call breaks
# under Bun + Cloudflare Workers (their bundler resolution stores
# a WebAssembly.Module / path-shim, not the wasm-bindgen exports
# namespace). The patcher additively exports `mkit_wasm_init(module)`
# so consumers on broken bundlers can recover with a one-liner.
# Strictly additive — esbuild/webpack/vite consumers are untouched.
run: node scripts/patch-wasm-bundler-glue.mjs rust/crates/mkit-wasm/pkg
- name: Set npm package name + version
working-directory: rust/crates/mkit-wasm/pkg
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
# Override the npm package name to the scoped form. The Rust
# crate stays unscoped (`mkit-wasm`); the npm distribution name
# is `@officialunofficial/mkit-wasm`, matching the GitHub org
# this repo lives under. See docs/RELEASE.md for rationale.
npm pkg set name=@officialunofficial/mkit-wasm
# Force-align the package.json version to the git tag.
npm version --no-git-tag-version --allow-same-version "$VERSION"
jq '{name, version, license, repository}' package.json
- name: Require public repository for npm provenance
env:
REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
run: |
if [ "${REPOSITORY_VISIBILITY}" != "public" ]; then
echo "::error::npm provenance requires a public GitHub repository; refusing to publish without provenance." >&2
exit 1
fi
# Trusted Publishing (OIDC): npm CLI auto-detects this job's GitHub
# Actions OIDC token (id-token: write, above) and authenticates
# without any stored npm token, PROVIDED the @officialunofficial/
# mkit-wasm package's Trusted Publisher is configured on npmjs.com to
# trust org=officialunofficial, repo=mkit, workflow=release.yml — see
# docs/RELEASE.md. No NODE_AUTH_TOKEN: if OIDC isn't wired up yet on
# the npm side, this fails closed instead of silently falling back to
# a stale/absent token. Provenance is automatic under Trusted
# Publishing — the explicit --provenance flag is kept for older-CLI
# defense in depth.
- name: Publish to npm
working-directory: rust/crates/mkit-wasm/pkg
run: npm publish --access public --provenance
- name: Stage WASM bundle for GitHub Release
shell: bash
run: |
STAGE="mkit-wasm-${{ steps.meta.outputs.version }}-npm"
mkdir -p "${STAGE}"
cp -R rust/crates/mkit-wasm/pkg/. "${STAGE}/"
tar -czf "${STAGE}.tar.gz" "${STAGE}"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${STAGE}.tar.gz" > "${STAGE}.tar.gz.sha256"
else
shasum -a 256 "${STAGE}.tar.gz" > "${STAGE}.tar.gz.sha256"
fi
ls -la "${STAGE}.tar.gz" "${STAGE}.tar.gz.sha256"
- name: Attach WASM bundle to GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
tag_name: ${{ steps.meta.outputs.tag }}
files: |
mkit-wasm-${{ steps.meta.outputs.version }}-npm.tar.gz
mkit-wasm-${{ steps.meta.outputs.version }}-npm.tar.gz.sha256