Skip to content

ci: route pinned binary through .mdsmith.pinned.yml #68

ci: route pinned binary through .mdsmith.pinned.yml

ci: route pinned binary through .mdsmith.pinned.yml #68

Workflow file for this run

name: Release

Check failure on line 1 in .github/workflows/release.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/release.yml

Invalid workflow file

(Line: 504, Col: 16): Unrecognized named-value: 'env'. Located at position 1 within expression: env.VERSION, (Line: 551, Col: 16): Unrecognized named-value: 'env'. Located at position 1 within expression: env.VERSION
on:
push:
tags:
- "v*"
create:
permissions:
contents: read
env:
VERSION: ${{ github.event_name == 'create' && github.event.ref || github.ref_name }}
# Serialize only release-intent runs so two publish jobs cannot mint
# OIDC tokens against the same registry at the same time. Ordinary
# branch-creation `create` events get a per-run group and never queue
# behind a real release. `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: >-
${{
(github.event_name != 'create' ||
(github.event.ref_type == 'tag' && startsWith(github.event.ref, 'v'))) &&
'release' ||
format('release-nonrelease-{0}-{1}', github.run_id, github.run_attempt)
}}
cancel-in-progress: false
jobs:
trigger-guard:
# `on: create` also fires for branch creation. Skip the whole job
# (checkout + setup-go + go run) for create events that are not a
# v* tag; downstream jobs gate on should_run and skip in turn.
if: >-
github.event_name != 'create' ||
(github.event.ref_type == 'tag' && startsWith(github.event.ref, 'v'))
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
create_release_is_draft: ${{ steps.decide.outputs.create_release_is_draft }}
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: Decide whether this trigger should publish
id: decide
env:
EVENT_NAME: ${{ github.event_name }}
CREATE_REF_TYPE: ${{ github.event.ref_type }}
RELEASE_TAG: ${{ env.VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The runtime logic lives in mdsmith-release per
# docs/development/release-tooling.md. The step itself stays a
# fixed one-liner so no event payload is interpolated into an
# inline shell script (zizmor template-injection finding).
run: go run ./cmd/mdsmith-release check-release-trigger
build:
needs: [trigger-guard]
if: &release_trigger_ok >-
needs.trigger-guard.outputs.should_run == 'true'
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: [trigger-guard]
runs-on: ubuntu-latest
# 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 >-
github.repository == 'jeduden/mdsmith' &&
needs.trigger-guard.outputs.should_run == 'true'
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 }}
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- 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).
run: bun install --frozen-lockfile --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
npm:
needs: [trigger-guard, 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: Publish root package
working-directory: npm/mdsmith
run: npm publish --access public --provenance
pypi:
needs: [trigger-guard, 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:
needs: [trigger-guard, build, vscode]
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/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
merge-multiple: true
- 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: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
draft: ${{ needs.trigger-guard.outputs.create_release_is_draft == 'true' }}
generate_release_notes: true
files: |
mdsmith-*
checksums.txt
checksums.txt.bundle
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: [trigger-guard, npm, pypi, release]
if: needs.trigger-guard.outputs.should_run == 'true'
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 }}
env:
VERSION: ${{ env.VERSION }}
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"
pages-deploy:
# Publish mdsmith.dev from website/ on every tag push. Gated
# on `build` so a tag that fails to compile/test never
# deploys docs, but deliberately NOT on the registry-publish
# jobs (npm/pypi/release): the site is pure HTML from
# docs/**/*.md and the homepage data files, so a flaky
# registry publish should not block the docs deploy. The
# `github-pages` environment is GitHub's built-in protection
# boundary for Pages; the per-repo Pages settings choose
# GitHub Actions as the source.
name: Deploy mdsmith.dev to GitHub Pages
needs: [trigger-guard, build]
if: *release_repo_trigger_ok
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
env:
# Pin the Hugo version used by the deploy. The same pin
# appears in the local dev instructions in website/README.md;
# bump both together so a local preview cannot drift from
# CI. (mdsmith-release build-website does not install Hugo —
# it expects the binary already on PATH — so no pin lives
# there.)
HUGO_VERSION: "0.161.1"
# The release tag drives the version Hugo renders in
# ``.Site.Params.version`` and the on-disk pin in
# website/hugo.toml. Stamp validates that the value has
# no leading "v" before rewriting, so strip it here.
VERSION: ${{ env.VERSION }}
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: Install Hugo
# `go install` resolves via the Go module proxy with sumdb
# checksum verification, so the binary is pinned by content
# hash even though the version selector is a tag.
run: go install github.com/gohugoio/hugo@v${HUGO_VERSION}
- name: Stamp release version into tracked manifests
# Rewrites the version field in every tracked manifest
# (incl. website/hugo.toml) from the dev sentinel to the
# cleaned tag, so the deployed site shows the release.
run: go run ./cmd/mdsmith-release stamp "${VERSION#v}"
- name: Build Hugo content tree from docs
# Snapshot ./docs into ./website/content/docs, dropping
# proto.md schema templates, renaming index.md to
# _index.md, pruning non-content files, and escaping
# literal Hugo shortcode patterns. --no-fix because docs/
# is already lint-clean on main (the check workflow gates
# that); CI must not mutate the tree. See
# docs/development/release-tooling.md for why this lives
# in the release-tooling Go CLI rather than inline shell.
run: go run ./cmd/mdsmith-release build-website --no-fix ./docs ./website/content/docs
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
id: pages
- name: Build site
working-directory: website
env:
HUGO_ENVIRONMENT: production
# The base URL Pages assigns gets piped through a
# shell env var rather than substituted into the run
# script directly, so an attacker-controlled output
# cannot inject shell metacharacters (zizmor finding
# 36 — template-expansion vulnerability).
BASE_URL: ${{ steps.pages.outputs.base_url }}
run: hugo --minify --baseURL "${BASE_URL}/"
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: website/public
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
id: deployment