Skip to content

Release

Release #99

Workflow file for this run

name: Release
# Triggered from the Actions "Run workflow" UI. A pushed tag is no
# longer the trigger: the GitHub Releases UI cannot drive the
# draft-first immutable publish flow (a draft release never creates a
# tag, so no push event fires). Publishing jobs (npm, pypi, vscode,
# release) declare `environment: release` and are gated behind its
# required-reviewer rule; preflight and build run without that gate.
# The `release` job creates the tag itself. See
# docs/development/release.md.
on:
workflow_dispatch:
inputs:
version:
description: "Release version, e.g. v0.13.0 (must start with v)"
required: true
type: string
permissions:
contents: read
env:
VERSION: ${{ inputs.version }}
# Serialize release runs so two publish jobs cannot mint OIDC tokens
# against the same registry at the same time. `cancel-in-progress:
# false` keeps the first release going (cancelling mid-publish would
# leave the scoped platform packages out of sync with the root
# package).
concurrency:
group: release
cancel-in-progress: false
jobs:
# Validate the tag before any credential-bearing job runs. The tag
# is read through an env var, never interpolated into the shell, so
# a crafted value cannot inject commands (zizmor template-expansion
# rule). This job also carries the repository guard anchor reused by
# every publishing job.
preflight:
runs-on: ubuntu-latest
if: &release_repo_trigger_ok >-
github.repository == 'jeduden/mdsmith'
steps:
- name: Validate version tag
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if ! printf '%s' "$INPUT_VERSION" | \
grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.+][0-9A-Za-z.-]+)?$'; then
echo "version must look like v1.2.3 (got '$INPUT_VERSION')" >&2
exit 1
fi
echo "release version: $INPUT_VERSION"
build:
needs: [preflight]
if: *release_repo_trigger_ok
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
# Disable cgo so the native linux/amd64 build resolves
# net and os/user purely in Go. Cross-compiles already
# auto-disable CGO; the native build would otherwise
# bind glibc's resolver into the binary, breaking the
# manylinux_2_17 wheel and the @mdsmith/linux-x64 npm
# package on Alpine/musl and on systems whose glibc is
# older than the wheel tag claims. macOS and Windows
# binaries still link the platform syscall layer
# (libSystem, kernel32) — this knob is about avoiding
# glibc on Linux, not achieving full static linkage.
CGO_ENABLED: "0"
VERSION: ${{ env.VERSION }}
run: |
ext=""
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
bin="mdsmith-${GOOS}-${GOARCH}${ext}"
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o "$bin" ./cmd/mdsmith
echo "bin=$bin" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mdsmith-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ env.bin }}
vscode:
# `needs: [build]` for the binaries: the .vsix bundles a binary
# for *every* supported platform (build.ts → dist/cli/), so the
# extension picks the right one at runtime by re-using the
# @mdsmith/cli resolver. The five binaries come straight from the
# `build` job's release artifacts via `mdsmith-release build-npm`
# — the same generator the `npm` job uses — so this job no longer
# waits on (or couples to) the npm publish.
needs: [build]
runs-on: ubuntu-latest
# MDSMITH_VSIX_PLATFORM_DIR points build.ts at the full
# build-npm output. It is set at job scope so the run that
# `vsce package` triggers through the `vscode:prepublish` hook
# re-stages all five binaries too (build.ts staging is also
# non-destructive, so a missing env would keep — not wipe — the
# explicit build's binaries; the env makes it deterministic).
env:
MDSMITH_VSIX_PLATFORM_DIR: ${{ github.workspace }}/npm/dist
# VSCE_PAT and OVSX_PAT are long-lived publisher tokens — see
# docs/development/release.md for why the `release` environment
# gates them and what reviewer rules the maintainer should set
# on it.
if: *release_repo_trigger_ok
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-bun
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
# zizmor's cache-poisoning rule treats the GitHub Actions
# tool cache as an unprotected mutation surface in
# release-context workflows; setup-go would default to
# caching the module download. Disable it to match the
# build job above.
cache: false
- name: Stamp tracked manifests with the tag
env:
VERSION: ${{ env.VERSION }}
# Stamp rewrites the `version` field in
# editors/vscode/package.json (the .vsix version) and the
# one in npm/mdsmith/package.json that build-npm stamps
# into each generated platform manifest.
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: artifacts
merge-multiple: true
- name: Build platform packages
# Reuse the npm generator so the .vsix carries the exact
# same per-platform binaries the npm channel ships. Output
# lands in npm/dist/<target>/bin/, which
# MDSMITH_VSIX_PLATFORM_DIR points build.ts at.
run: go run ./cmd/mdsmith-release build-npm artifacts npm/dist
- name: Install extension dependencies
working-directory: editors/vscode
# `--ignore-scripts` blocks postinstall / preinstall hooks from
# every dep in the resolved tree. The mdsmith extension's
# production deps (vscode-languageclient) and dev deps
# (@vscode/vsce, typescript, @types/*) are pure-JS and need
# no install-time hooks — disabling them shrinks the blast
# radius if a future lockfile update or registry compromise
# ships a malicious lifecycle script (the TanStack /
# shai-hulud worm class).
#
# No `--frozen-lockfile` here: the Stamp step above just
# rewrote the @mdsmith/cli optionalDependency to the
# version this run is publishing, so bun.lock is
# intentionally out-of-date for that one entry.
# `--ignore-scripts` still guards the lifecycle-hook
# surface that the shai-hulud / TanStack worm relies on.
run: bun install --ignore-scripts
- name: Run extension unit tests
working-directory: editors/vscode
run: bun test
- name: Compile extension
working-directory: editors/vscode
run: bun run build.ts --production
- name: Package .vsix
env:
VERSION: ${{ env.VERSION }}
working-directory: editors/vscode
run: |
ver="${VERSION#v}"
bunx --bun @vscode/vsce package --no-dependencies \
--out "mdsmith-${ver}.vsix"
# Verify the publisher tokens are set BEFORE the publish steps
# run. The publishes themselves use `continue-on-error: true`
# so a transient registry outage does not block the GitHub
# release. That same flag would also hide an unset/empty
# secret, so guard misconfiguration here (no continue-on-error)
# while still letting outages slide on the actual publish.
- name: Verify Marketplace and Open VSX tokens are set
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
missing=""
[ -n "${VSCE_PAT:-}" ] || missing="$missing VSCE_PAT"
[ -n "${OVSX_PAT:-}" ] || missing="$missing OVSX_PAT"
if [ -n "$missing" ]; then
echo "missing required repo secret(s):$missing" >&2
exit 1
fi
- name: Publish to Visual Studio Marketplace
# The GitHub release .vsix is the documented fallback, so a
# transient Marketplace outage should not block the release
# job downstream of this one. Misconfiguration is caught by
# the preceding verify step, so this only swallows runtime
# registry errors.
continue-on-error: true
env:
VERSION: ${{ env.VERSION }}
VSCE_PAT: ${{ secrets.VSCE_PAT }}
working-directory: editors/vscode
# Reuse the exact .vsix the artifact upload below ships, so
# Marketplace, Open VSX, and the GitHub release are byte-
# identical. The publisher namespace is jeduden — claim it
# in https://aka.ms/vscode-create-publisher before the first
# release. PAT scope: Marketplace > Manage. Azure caps PATs
# at one year; rotate annually and record the date in
# CLAUDE.md.
run: |
ver="${VERSION#v}"
bunx --bun @vscode/vsce publish \
--no-dependencies \
--packagePath "mdsmith-${ver}.vsix" \
--pat "$VSCE_PAT"
- name: Publish to Open VSX
continue-on-error: true
env:
VERSION: ${{ env.VERSION }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
working-directory: editors/vscode
# Open VSX is the registry VSCodium, Cursor, Theia, and
# Gitpod query. Claim the jeduden namespace on
# https://open-vsx.org and store the publisher token as the
# OVSX_PAT secret before the first release. Rotate annually.
run: |
ver="${VERSION#v}"
bunx --bun ovsx publish \
--packagePath "mdsmith-${ver}.vsix" \
--pat "$OVSX_PAT"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mdsmith-vscode-extension
path: editors/vscode/mdsmith-*.vsix
obsidian:
# Plan 217. The Obsidian plugin ships ONE WASM artifact (not a
# per-platform binary), so it does not depend on the `build` matrix.
# It compiles the engine to WebAssembly, builds the plugin, and zips
# dist/ as mdsmith-obsidian-<version>.zip. The `release` job
# downloads it with the other artifacts and the `mdsmith-*` globs
# there cover it for the GitHub release attachment, the checksum
# file, the SLSA provenance, and the cosign signature.
#
# No publisher tokens and no `release` environment: the only channel
# is GitHub Releases (plan 217 Non-Goals), so there is nothing to
# gate. The plugin manifests (editors/obsidian/{manifest,package}.json)
# are in the tracked-manifest set, so `mdsmith-release stamp` rewrites
# their dev sentinel to the release version alongside the rest of the
# tracked set; only this job's zip carries the result.
needs: [preflight]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-bun
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- name: Stamp the manifests with the release version
env:
VERSION: ${{ env.VERSION }}
# Runtime logic lives in mdsmith-release per
# docs/development/release-tooling.md. stamp rewrites every
# tracked manifest's dev sentinel to the release tag (the obsidian
# manifest.json/package.json among them); the checkout is
# discarded after the run, so only the zip built below ships the
# stamped files.
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- name: Install plugin dependencies
working-directory: editors/obsidian
# --ignore-scripts neutralizes install-time hooks (see the npm
# job and release.md). No --frozen-lockfile: the stamp above
# rewrote package.json's version, so bun.lock is intentionally
# out of date for that field.
run: bun install --ignore-scripts
- name: Run plugin unit tests
working-directory: editors/obsidian
run: bun test --preload ./src/test-setup.ts
- name: Build the engine to WebAssembly
run: bash cmd/mdsmith-wasm/build.sh
- name: Build the plugin
working-directory: editors/obsidian
env:
MDSMITH_OBSIDIAN_WASM_DIR: ${{ github.workspace }}/cmd/mdsmith-wasm/dist
run: bun run build.ts --production
- name: Package the release zip
# Runtime logic lives in mdsmith-release per
# docs/development/release-tooling.md. The stamp step above
# rewrote dist's manifest.json to the release version, so
# package-obsidian reads the version from there and writes
# mdsmith-obsidian-<version>.zip with the five files Obsidian
# loads, flat, via archive/zip. outDir is editors/obsidian so
# the upload glob below matches.
run: go run ./cmd/mdsmith-release package-obsidian editors/obsidian/dist editors/obsidian
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mdsmith-obsidian-plugin
path: editors/obsidian/mdsmith-obsidian-*.zip
npm:
needs: [build]
runs-on: ubuntu-latest
# See docs/development/release.md for the canonical description
# of `if:`, `environment:`, OIDC Trusted Publishing scope, and
# the operational checklist for npmjs.com / pypi.org / GitHub
# environment configuration. Workflow comments here only record
# the local intent of each setting.
if: *release_repo_trigger_ok
environment: release
# `id-token: write` lets `npm publish --provenance` mint an OIDC
# token so the npm registry stamps each tarball with verifiable
# build metadata pointing at this exact workflow run.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# Node 24 ships npm 11.x. npm Trusted Publishing
# requires npm >= 11.5; older CLIs silently fall back
# to token auth and the registry returns 404 for
# missing-credential publishes (404 instead of 401 so
# package existence isn't leaked).
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Verify npm >= 11.5 for Trusted Publishing
# Defensive guardrail: even though Node 24 currently ships
# npm 11.x, a future Node 24 patch could bundle an older
# CLI. If npm < 11.5 the publish would silently 404.
run: |
actual=$(npm --version)
echo "npm version: $actual"
node -e '
const v = process.argv[1].split(".").map(Number);
const min = [11, 5, 0];
for (let i = 0; i < 3; i++) {
if (v[i] > min[i]) process.exit(0);
if (v[i] < min[i]) {
console.error("npm " + process.argv[1] +
" is too old for Trusted Publishing (need >= 11.5.0)");
process.exit(1);
}
}
' "$actual"
- name: Stamp tracked manifests with the tag
env:
VERSION: ${{ env.VERSION }}
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: artifacts
merge-multiple: true
- name: Build platform packages
run: go run ./cmd/mdsmith-release build-npm artifacts npm/dist
- name: Publish platform packages
# Platform packages publish first so the root never advertises
# an optionalDependency npm cannot find. The root package
# publishes last, after every platform exists. Auth uses npm
# Trusted Publishing (OIDC) — see docs/development/release.md
# for the npmjs.com publisher configuration each of the six
# packages needs.
run: |
for pkg in npm/dist/*; do
(cd "$pkg" && npm publish --access public --provenance)
done
- name: Stage LICENSE for root package
# The root @mdsmith/cli package directory has no checked-in
# LICENSE because the canonical one lives at repo root. npm
# auto-includes a top-level LICENSE in the published tarball,
# but only if it sits next to package.json at publish time —
# so copy it in before `npm publish`. The root LICENSE also
# carries the vendored neurosnap/sentences MIT notice for
# internal/punkt/, which is what makes this step legally
# required for the npm channel.
run: cp LICENSE npm/mdsmith/LICENSE
- name: Publish root package
working-directory: npm/mdsmith
run: npm publish --access public --provenance
pypi:
needs: [build]
runs-on: ubuntu-latest
# See docs/development/release.md for the canonical PyPI Trusted
# Publisher config (workflow + environment scope) and the
# operational checklist.
if: *release_repo_trigger_ok
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Stamp tracked manifests with the tag
env:
VERSION: ${{ env.VERSION }}
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- name: Install build tooling
# `python -m build` and `python -m wheel` orchestrate the
# wheel build and the platform-tag retag respectively;
# hatchling is the build backend pyproject.toml selects.
run: python -m pip install --upgrade build wheel hatchling
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: artifacts
merge-multiple: true
- name: Build platform wheels
run: go run ./cmd/mdsmith-release build-wheels artifacts python/dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: python/dist
release:
# `flatpak` joins `build`/`vscode` here so the .flatpak bundle it
# produces is downloaded with the other artifacts and uploaded to
# the draft before it freezes. The bundle is named with the
# `mdsmith-` prefix, so the checksum, SLSA attestation, and cosign
# steps below cover it via the same `mdsmith-*` glob as the raw
# binaries — no release-job changes beyond this dependency. `obsidian`
# (plan 217) joins for the same reason: its
# mdsmith-obsidian-<version>.zip is matched by the `mdsmith-*` globs.
needs: [build, vscode, obsidian, flatpak]
runs-on: ubuntu-latest
# See docs/development/release.md for the rationale on `if:`,
# `environment:`, and the OIDC + attestations permission set.
if: *release_repo_trigger_ok
environment: release
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
merge-multiple: true
- name: Generate CycloneDX SBOM
# CycloneDX SBOM of the Go module that produced the release
# binaries. Runtime logic lives in mdsmith-release per
# docs/development/release-tooling.md: the tool version is
# pinned in internal/release/sbom.go and the implementation
# uses `go run <module>@<pinned>` so no PATH setup beyond
# the Go toolchain is required. Output is named with the
# `mdsmith-` prefix so the checksum step below picks it up
# automatically and the cosign signature covers it
# transitively. Verify with:
# sha256sum -c <(grep mdsmith-sbom.cdx.json checksums.txt)
run: go run ./cmd/mdsmith-release sbom mdsmith-sbom.cdx.json
- name: Create checksums
run: sha256sum mdsmith-* > checksums.txt
- name: Generate SLSA build provenance
# Attests every binary the build matrix produced (and the
# .vsix the vscode job uploaded — `mdsmith-*` matches both).
# Each attestation ties the file's SHA-256 back to this
# workflow run and the commit it was built from. Consumers
# verify with:
# gh attestation verify mdsmith-<plat> -R jeduden/mdsmith
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: "mdsmith-*"
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pin cosign to a known v3.x release. The sign-blob step
# below relies on `--bundle` being the required output
# path (cosign 3.0.0 promoted it from optional to
# required); pinning shields the release from a future
# installer default rolling forward to a major that
# changes the bundle contract again.
cosign-release: "v3.0.6"
- name: Sign checksums with cosign
# Keyless Sigstore signature on the checksum file. The
# GitHub OIDC token binds the signature to this exact
# workflow file at this exact tag, so an attacker who
# rewrites checksums.txt on the release page can't also
# forge a matching signature without compromising
# release.yml on this repo. The bundle file carries both
# the signature and the signing certificate; cosign 3.x
# deprecated the separate --output-signature /
# --output-certificate flags in favor of --bundle.
# Verify with:
# cosign verify-blob \
# --bundle checksums.txt.bundle \
# --certificate-identity-regexp \
# "^https://github.com/jeduden/mdsmith/.github/workflows/release.yml@" \
# --certificate-oidc-issuer \
# https://token.actions.githubusercontent.com \
# checksums.txt
env:
COSIGN_YES: "true"
run: |
cosign sign-blob \
--bundle checksums.txt.bundle \
checksums.txt
- name: Upload assets to draft release
# Create the release as a draft so every asset uploads while
# the release is still mutable. With immutable releases
# enforced, uploading to an already-published release is
# rejected — the publish must be the final step.
#
# The workflow is dispatched from a branch, not a tag, so the
# tag does not exist yet: `tag_name` + `target_commitish`
# make this step create it at the dispatched commit.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
draft: true
tag_name: ${{ env.VERSION }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
files: |
mdsmith-*
checksums.txt
checksums.txt.bundle
- name: Publish release
# Flip the fully-populated draft to published as the final
# atomic step, yielding an immutable release. Runtime logic
# lives in mdsmith-release per
# docs/development/release-tooling.md.
env:
RELEASE_TAG: ${{ env.VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: go run ./cmd/mdsmith-release publish-release
# Build the self-hosted .flatpak bundle. Like `vscode`, this chains
# off `build` (not `release`) and hands its artifact to the `release`
# job, which attaches `mdsmith-x86_64.flatpak` to the draft before it
# freezes — uploading to an immutable, already-published release is
# rejected. The bundle is built from the freshly built x86_64 binary
# via flatpak-builder, so it needs no published download URL. x86_64
# only: flatpak-builder targets the runner's native arch and
# cross-building aarch64 under emulation is not worth it for this
# channel (aarch64 Linux uses the binary, npm, or PyPI channels).
# The job carries no secrets, so it runs without the `release`
# environment gate. See docs/development/release-channels/flatpak.md.
flatpak:
needs: [build]
runs-on: ubuntu-latest
# Bound the heavy steps (apt install + ~hundreds-of-MB Flathub
# runtime pull + flatpak-builder) so a stuck download cannot hold
# the release-gating job to the 6h default.
timeout-minutes: 20
if: *release_repo_trigger_ok
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: artifacts
merge-multiple: true
- name: Stage manifest and binaries
# build-flatpak writes the flatpak-builder manifest and copies
# the Linux binaries it references (via local `path:` sources)
# into flatpak-build/, so the bundle builds without a release
# download URL.
run: go run ./cmd/mdsmith-release build-flatpak artifacts flatpak-build
- name: Install flatpak-builder and the freedesktop runtime
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
# Ubuntu 24.04 restricts unprivileged user namespaces via
# AppArmor, which blocks the bubblewrap sandbox that
# flatpak-builder and `flatpak run` rely on. Re-enable it on
# the runner (best-effort: the knob is absent on older
# kernels, where the restriction does not exist).
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
flatpak remote-add --user --if-not-exists \
flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Build the bundle
# flatpak-builder pulls the org.freedesktop 24.08 Platform/SDK
# from flathub, builds the app into a local OSTree repo, and
# build-bundle packs it into a single file. --runtime-repo
# records flathub so `flatpak install ./mdsmith-x86_64.flatpak`
# can offer the runtime if the host lacks it.
# --disable-rofiles-fuse avoids the rofiles-fuse mount, which is
# fragile under the runner's restricted FUSE.
run: |
flatpak-builder --user --install-deps-from=flathub \
--disable-rofiles-fuse --force-clean --repo=flatpak-repo \
flatpak-build-dir flatpak-build/io.github.jeduden.mdsmith.yml
flatpak build-bundle \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
flatpak-repo mdsmith-x86_64.flatpak io.github.jeduden.mdsmith
- name: Verify the bundle installs and reports the right version
# No other job exercises this channel (smoke-test runs in
# flatpak-less containers), so confirm the bundle installs and
# `mdsmith version` matches the tag before it ships. A broken
# bundle fails the job — and, since `release` needs it, blocks
# the release rather than shipping a dud asset.
env:
VERSION: ${{ env.VERSION }}
run: |
flatpak install --user -y ./mdsmith-x86_64.flatpak
# Take the last stdout line so a first-run portal/sandbox
# notice cannot break the exact-match check.
got=$(flatpak run io.github.jeduden.mdsmith version | tail -n1)
want="mdsmith ${VERSION}"
if [ "$got" != "$want" ]; then
echo "flatpak bundle version mismatch: got '$got', want '$want'" >&2
exit 1
fi
echo "flatpak bundle: $got"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mdsmith-flatpak-bundle
path: mdsmith-x86_64.flatpak
smoke-test:
# Wait until every channel is on the new version before checking
# — the npm and PyPI registries can take ~60s to surface a fresh
# publish, so the channel-specific install commands re-run if the
# registry briefly returns the previous version.
needs: [npm, pypi, release]
strategy:
fail-fast: false
matrix:
include:
- channel: npm
# node:lts (debian-slim) ships bash, so the install step
# runs under the action's default `bash -e` shell. Alpine
# would force `shell: sh` everywhere because busybox has
# no bash before `apk add` runs.
container: node:lts
install: |
# npm registry propagation can lag the publish by ~60s,
# so retry with backoff until the just-published version
# is resolvable. Mirrors the pip retry loop below.
ok=0
for attempt in 1 2 3 4 5; do
if npm install -g --force "@mdsmith/cli@${VERSION#v}"; then
ok=1; break
fi
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "npm install never succeeded after 5 attempts" >&2
exit 1
fi
run: mdsmith version
- channel: pip
container: python:3.12-slim
install: |
python -m pip install --upgrade pip
# `--upgrade` forces pip to pick the just-published
# wheel rather than a cached older one.
ok=0
for attempt in 1 2 3 4 5; do
if python -m pip install --upgrade "mdsmith==${VERSION#v}"; then
ok=1; break
fi
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "pip install never succeeded after 5 attempts" >&2
exit 1
fi
run: mdsmith version
- channel: mise
container: jdxcode/mise:latest
# `ubi:jeduden/mdsmith@VER` resolves the binary directly
# off the GitHub release the same `release` job above
# just published. The shorter `mdsmith@VER` form depends
# on the mise-plugins/registry follow-up; until that PR
# lands the smoke-test would fail on every release, so
# exercise the form that works today.
install: |
ok=0
for attempt in 1 2 3 4 5; do
if mise use -g "ubi:jeduden/mdsmith@${VERSION#v}"; then
ok=1; break
fi
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "mise install never succeeded after 5 attempts" >&2
exit 1
fi
run: |
eval "$(mise activate bash --shims)"
mdsmith version
runs-on: ubuntu-latest
container: ${{ matrix.container }}
steps:
- name: Install
run: ${{ matrix.install }}
- name: Verify version
run: |
got=$(${{ matrix.run }})
want="mdsmith ${VERSION}"
if [ "$got" != "$want" ]; then
echo "channel=${{ matrix.channel }}: got '$got', want '$want'" >&2
exit 1
fi
echo "channel=${{ matrix.channel }}: $got"
# Nudge the Homebrew tap to bump its formula to this version right
# away. Best-effort glue, not part of the gated publish: the tap at
# jeduden/homebrew-mdsmith also self-bumps on a daily schedule, so a
# missing token or a failed dispatch never blocks a release. The
# token is a fine-grained PAT with Contents: write on the tap repo,
# stored as the HOMEBREW_TAP_DISPATCH_TOKEN repo secret.
notify-homebrew-tap:
needs: [release]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
steps:
- name: Dispatch a formula bump to the tap
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_DISPATCH_TOKEN }}
VERSION: ${{ env.VERSION }}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "HOMEBREW_TAP_DISPATCH_TOKEN unset; the tap will self-bump on its daily schedule."
exit 0
fi
gh api repos/jeduden/homebrew-mdsmith/dispatches \
-f event_type=mdsmith-release \
-f "client_payload[version]=${VERSION#v}"
# Nudge the Scoop bucket to bump its manifest to this version right
# away. Best-effort glue, not part of the gated publish: the bucket
# at jeduden/scoop-mdsmith also self-bumps via its `checkver` +
# `autoupdate` blocks on a daily schedule, so a missing token or a
# failed dispatch never blocks a release. The token is a fine-grained
# PAT with Contents: write on the bucket repo, stored as the
# SCOOP_BUCKET_DISPATCH_TOKEN repo secret.
notify-scoop-bucket:
needs: [release]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
steps:
- name: Dispatch a manifest bump to the Scoop bucket
env:
GH_TOKEN: ${{ secrets.SCOOP_BUCKET_DISPATCH_TOKEN }}
VERSION: ${{ env.VERSION }}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "::notice::SCOOP_BUCKET_DISPATCH_TOKEN unset; the bucket will self-bump via checkver."
exit 0
fi
gh api repos/jeduden/scoop-mdsmith/dispatches \
-f event_type=mdsmith-release \
-f "client_payload[version]=${VERSION#v}"
# Submit the WinGet manifest for this release. Best-effort: if
# WINGET_PR_TOKEN is unset, komac cannot be fetched, or the
# submission fails, the job logs a notice/warning and exits 0 so
# the release is never blocked. komac builds the manifest from the
# published Windows installer URL and opens the PR — the same
# approach peers like mise use (via the winget-releaser action).
# `komac update` targets an existing package, so the first version
# is bootstrapped manually with `mdsmith-release
# render-winget-manifest`, mirroring how render-scoop-manifest
# bootstraps the Scoop bucket. The token is a PAT with fork+PR
# rights on microsoft/winget-pkgs, stored as the WINGET_PR_TOKEN
# repo secret gated by the `release` environment (same gate as the
# other publish jobs) so a stolen token cannot silently submit
# manifests without reviewer approval.
winget-submit:
needs: [release]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
environment: release
steps:
- name: Submit the WinGet manifest via komac
env:
GITHUB_TOKEN: ${{ secrets.WINGET_PR_TOKEN }}
VERSION: ${{ env.VERSION }}
run: |
if [ -z "${GITHUB_TOKEN:-}" ]; then
echo "::notice::WINGET_PR_TOKEN unset; skipping WinGet submission."
exit 0
fi
# Best-effort past this point: a WinGet hiccup must never
# fail an otherwise-successful release, so each fallible
# step logs a warning and exits 0.
KOMAC_VERSION="2.10.0"
if ! curl -fsSL \
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/komac-${KOMAC_VERSION}-x86_64-unknown-linux-musl" \
-o /usr/local/bin/komac; then
echo "::warning::could not download komac; skipping WinGet submission."
exit 0
fi
chmod +x /usr/local/bin/komac
installer="https://github.com/jeduden/mdsmith/releases/download/${VERSION}/mdsmith-windows-amd64.exe"
if ! komac update jeduden.mdsmith \
--version "${VERSION#v}" \
--urls "${installer}" \
--submit; then
echo "::warning::komac submission failed; submit the WinGet PR manually (see docs/development/release-channels/winget.md)."
exit 0
fi
# A tool release also ships the website. The deploy itself lives
# in pages.yml (its own workflow, also triggered by docs-only
# pushes to main and by manual workflow_dispatch); this job just
# calls it with the release version so the published site shows
# the new version. Gated on `release` so the site is deployed
# only after the release has been frozen — the draft is published
# and immutable — so mdsmith.dev never advertises a version whose
# GitHub release does not yet exist. `release` transitively
# depends on `npm` (release -> vscode -> npm), so a failed npm
# publish also blocks this deploy. That is intentional: with no
# frozen release there is no version to ship the site for. `pypi`
# is not in the release chain, so a PyPI outage does not block it.
pages:
needs: [release]
if: *release_repo_trigger_ok
permissions:
contents: read
pages: write
id-token: write
uses: ./.github/workflows/pages.yml
with:
version: ${{ inputs.version }}
# Re-measure the cross-tool benchmark on every release and open a PR
# with the refreshed numbers and the pages that embed them, so the
# committed snapshot the README, the benchmark doc, and the website
# read never freezes between releases (there is no local run.sh step
# in the normal flow). bench-check is the regression gate: it compares
# mdsmith's ratio to mado -- machine- and corpus-size-independent, the
# "same code, same factor" signal -- and fails this job, visibly, on a
# real relative slowdown. It runs AFTER `release` and is intentionally
# not in the publish gating chain: the cross-tool fetch (pinned
# tarballs, npm, Rust clones) has enough external moving parts that a
# flake should not block shipping. The PR lands through the normal
# review-and-merge path; the bench-fragments gate re-checks the
# fragments against the data when the PR is enqueued in the merge
# queue, before it merges. (Like record-secret-rotation.yml, the PR
# is opened with the default token, so ci.yml does not auto-run on
# PR open — the merge queue is where the gate fires.)
benchmark-refresh:
needs: [release]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
timeout-minutes: 60
# contents: write to push the refresh branch; pull-requests: write
# for `gh pr create`. The change to main still goes through the
# normal review-and-merge path -- this token only opens the PR.
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Never persist the token in git config; the push step
# authenticates explicitly via a one-shot, masked header.
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# markdownlint-cli2 installs via `npm ci` from the committed
# docs/research/benchmarks/npm/ lockfile.
node-version: "24"
- name: Snapshot the committed baseline
# The committed data/ is the previous release's numbers. Keep a
# copy so bench-check can compare the fresh ratios against it
# after the measurement overwrites data/ in place.
run: |
# rm first so a re-run on a persistent runner replaces the
# baseline rather than nesting it under /tmp/baseline-data/data.
rm -rf /tmp/baseline-data
cp -r docs/research/benchmarks/data /tmp/baseline-data
- name: Re-measure the cross-tool benchmark
run: go run ./cmd/mdsmith-release bench /tmp/mdsmith-bench
- name: Re-splice the fragment includes
run: |
go run ./cmd/mdsmith fix \
docs/research/benchmarks/results.fragment.md \
docs/research/benchmarks/headline.fragment.md
go run ./cmd/mdsmith fix \
README.md docs/research/benchmarks/README.md
- name: Check for regression and open the refresh PR
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Ratio-based regression gate: mdsmith vs mado within each
# run, so the runner speed and the (growing) corpus cancel and
# only a real relative slowdown trips it. Capture the verdict
# for the PR body and defer failing the job until after the PR
# is opened, so a regressed run still leaves a reviewable diff.
verdict=$(go run ./cmd/mdsmith-release bench-check \
/tmp/baseline-data docs/research/benchmarks/data 2>&1) && rc=0 || rc=$?
echo "$verdict"
git add docs/research/benchmarks/data \
docs/research/benchmarks/results.fragment.md \
docs/research/benchmarks/headline.fragment.md \
docs/research/benchmarks/README.md \
README.md
if git diff --staged --quiet; then
echo "::notice::benchmark numbers unchanged -- no PR opened"
exit "$rc"
fi
branch="bench/refresh-${VERSION}-${GITHUB_RUN_ID}"
git checkout -b "$branch"
git commit -m "bench: refresh cross-tool benchmark numbers for ${VERSION}"
# Authenticate the push via a masked http.extraheader rather
# than embedding the token in the remote URL.
auth_header=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)
echo "::add-mask::$auth_header"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
push origin "HEAD:refs/heads/${branch}"
# Build the PR body with echo appends (no heredoc) so dedent
# and backtick expansion cannot bite. Single quotes keep the
# Markdown backticks literal.
{
echo 'Automated refresh of the committed cross-tool benchmark snapshot,'
echo "re-measured on the CI runner during the ${VERSION} release."
echo ''
echo 'Review the number diff under `docs/research/benchmarks/data/` and the'
echo 'regenerated fragments. Absolute times wobble on a shared runner, so the'
echo '**ratio** is the signal -- the regression gate compares mdsmith to mado,'
echo 'which cancels both the runner speed and the corpus size:'
echo ''
echo '```'
echo "$verdict"
echo '```'
} > /tmp/pr-body.md
gh pr create \
--base main \
--head "$branch" \
--title "bench: refresh cross-tool benchmark numbers for ${VERSION}" \
--body-file /tmp/pr-body.md
if [ "$rc" -ne 0 ]; then
# rc!=0 is either a real regression or bench-check failing to
# read the data (missing tool/file); the verdict echoed above
# says which. Either way fail the job so it is visible.
echo "::error::bench-check failed — perf regression, or it could not read the benchmark data (see the verdict above). The refresh PR opened for review; this run is marked failed."
exit "$rc"
fi