Skip to content

Release

Release #18

Workflow file for this run

name: Release
# Manual, one-button release. Pick a version (a semver bump keyword or an exact
# version), and this workflow: runs the full CI gate, bumps package.json,
# writes the changelog from the commits since the last tag, commits + tags,
# publishes to public npm, and cuts a GitHub Release whose body is that
# changelog. Run `dry_run: true` first to preview without pushing anything.
#
# Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. Configure
# a trusted publisher for @copse/streaming-markdown at npmjs.com (Settings ->
# Trusted Publisher) pointing at this repo + workflow file (release.yml). The
# package must exist first, so the initial publish is a one-time manual
# `npm publish` (interactive 2FA); every release after that flows through here.
on:
workflow_dispatch:
inputs:
bump:
description: 'Semver bump to apply (ignored when "exact version" is set below)'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
- prepatch
- preminor
- premajor
- prerelease
exact_version:
description: 'Optional: exact version to release (e.g. 1.2.0). Overrides the bump dropdown when set.'
required: false
default: ''
type: string
prerelease:
description: 'Mark the GitHub Release as a pre-release'
required: false
default: false
type: boolean
dry_run:
description: 'Build the release notes and version bump but do not commit, tag, publish, or release'
required: false
default: false
type: boolean
# Least privilege: write to the repo (commit + tag + release). id-token: write
# lets the runner mint the GitHub OIDC token that npm Trusted Publishing uses to
# authenticate the publish (and to attach provenance automatically).
permissions:
contents: write
id-token: write
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
# Deliberately NOT routed to the self-hosted pool (vars.CHECKS_RUNNER):
# npm Trusted Publishing / provenance requires a GitHub-hosted runner, and
# hosted minutes are free on a public repo anyway.
runs-on: ubuntu-latest
steps:
# Mint a short-lived installation token from the release GitHub App. The
# App is on the `main` branch (and tag) ruleset bypass list, so the
# version-bump commit + tag pushed below clear the "changes must be made
# through a pull request" rule (GH013) that rejects a direct push by the
# default GITHUB_TOKEN (github-actions[bot]). The token expires in ~1h and
# is scoped to this repo's contents — nothing long-lived lives in CI.
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
# Check out with the App token so `actions/checkout` persists it as the
# push credential — the later `git push` then authenticates as the App
# (a ruleset bypass actor), not as github-actions[bot].
token: ${{ steps.app-token.outputs.token }}
# Full history + tags so the changelog can diff against the last tag.
fetch-depth: 0
- uses: actions/setup-node@v7
with:
# The gate below must mirror ci.yml EXACTLY, and that includes the
# Node version: V8's coverage attribution shifts between majors, so
# the coverage-baseline ratchet pinned under ci.yml's Node 22 reads
# ~0.1% lower under Node 24 and fails a release from a green tree
# (seen cutting v1.0.0-rc.1: 99.9% vs the 99.98% baseline). Gate on
# 22; the publish step switches to Node 24 just-in-time.
node-version: 22
cache: npm
- run: npm ci
# Release gate: never cut a release from a red tree. Mirrors CI exactly —
# `coverage:ci` runs the suite under c8 and enforces the coverage-baseline
# ratchet, so a release can't ship a commit CI would have blocked on a PR.
- name: Typecheck
run: npm run typecheck
# Fetch the GFM spec.txt (gitignored, not on npm) exactly like ci.yml does
# before ITS coverage run: without it the GFM conformance suite self-skips
# and the lines only it exercises read as uncovered — which failed the
# v1.0.0-rc.1 release gate at 99.9% vs the 99.98% baseline from a
# perfectly green tree. "Mirrors CI exactly" includes the fixtures.
- name: Fetch + verify GFM spec
run: npm run check:gfm-spec
- name: Test + coverage
run: npm run coverage:ci
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage/lcov-report
if-no-files-found: ignore
- name: Build
run: npm run build
- name: Normalizer parity
run: npm run check:normalizer-parity
- name: Resolve version
id: version
# Pass the free-text input through `env:` rather than interpolating
# `${{ inputs.exact_version }}` into the script body — inline expansion
# would let a value like `"; rm -rf … ; "` execute at template-render time.
env:
EXACT_VERSION: ${{ inputs.exact_version }}
BUMP: ${{ inputs.bump }}
run: |
set -euo pipefail
# Prefer the free-text exact version when provided; otherwise use the
# bump keyword from the dropdown. `npm version` accepts both forms and
# prints the resulting `vX.Y.Z`. --no-git-tag-version edits
# package.json + package-lock.json without tagging (we tag ourselves).
TARGET="${EXACT_VERSION:-$BUMP}"
TAG=$(npm version "$TARGET" --no-git-tag-version)
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG"
- name: Generate changelog
run: npx tsx scripts/gen-changelog.mts "${{ steps.version.outputs.version }}" > RELEASE_NOTES.md
- name: Update CHANGELOG.md
run: |
set -euo pipefail
DATE=$(date -u +%Y-%m-%d)
npx tsx scripts/update-changelog.mts "${{ steps.version.outputs.version }}" "$DATE" RELEASE_NOTES.md
- name: Preview release notes
run: |
{
echo "## Release ${{ steps.version.outputs.tag }}"
echo
cat RELEASE_NOTES.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Commit and tag
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add package.json package-lock.json CHANGELOG.md
git commit -m "chore(release): ${{ steps.version.outputs.tag }}"
git tag -a "${{ steps.version.outputs.tag }}" -m "${{ steps.version.outputs.tag }}"
# Switch to Node 24 ONLY for the publish: it bundles npm 11.x
# (>= 11.5.1), which npm Trusted Publishing requires. Doing the switch
# here rather than up top keeps the release gate on ci.yml's exact Node
# version (see the first setup-node step), and avoids upgrading npm in
# place — that in-place self-upgrade could prune npm's own bundled
# `sigstore` dependency and break provenance generation. dist/ is
# already built (plain JS) and no install runs after the switch, so the
# Node-22-built node_modules stays valid.
- uses: actions/setup-node@v7
if: ${{ !inputs.dry_run }}
with:
node-version: 24
registry-url: https://registry.npmjs.org
scope: '@copse'
# Publish BEFORE pushing so a failed publish (e.g. bad token) leaves main
# and the tag list untouched — the commit + tag exist only locally on the
# runner and vanish with it, leaving nothing to clean up before a retry.
- name: Publish to npm
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
# Trusted Publishing authenticates via the GitHub OIDC token (see the
# id-token permission above) — no NPM_TOKEN needed. It requires npm
# >= 11.5.1, which the Node 24 runner ships out of the box (see the
# setup-node step above), so no npm self-upgrade is needed here.
# publishConfig pins the public registry + `--access public`, and
# provenance is attached automatically under trusted publishing.
#
# Prereleases (any version with a `-` segment, e.g. 1.0.0-rc.1)
# publish under the `next` dist-tag so a plain
# `npm install @copse/streaming-markdown` keeps resolving to the
# last stable release; stable versions publish as `latest` (npm's
# default). Promote an rc later with
# `npm dist-tag add @copse/streaming-markdown@<ver> latest`.
case "${{ steps.version.outputs.version }}" in
*-*) npm publish --tag next ;;
*) npm publish ;;
esac
- name: Push commit and tag
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
git push origin HEAD
git push origin "${{ steps.version.outputs.tag }}"
- name: Create GitHub Release
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh release create "${{ steps.version.outputs.tag }}" \
--title "${{ steps.version.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \
${{ inputs.prerelease && '--prerelease' || '' }}
# Push-model dependency sync: poke copse-dev/agent-pane so its
# sync-streaming-markdown workflow bumps @copse/streaming-markdown and
# opens a PR immediately. Done HERE, as an explicit step, rather than
# relying on the `release: published` event picked up by
# notify-agent-pane.yml: the release above is created with the default
# GITHUB_TOKEN, and GitHub deliberately does not fire workflow-triggering
# events for actions taken with GITHUB_TOKEN (an anti-recursion
# safeguard), so that event never fires for automated releases. This step
# uses the same AGENT_PANE_DISPATCH_TOKEN PAT (see notify-agent-pane.yml
# for how to provision it). Skipped for pre-releases — consumers opt into
# those manually. Failure here does not roll back the release; re-notify
# via notify-agent-pane.yml's workflow_dispatch if needed.
- name: Notify agent-pane
if: ${{ !inputs.dry_run && !inputs.prerelease }}
env:
GH_TOKEN: ${{ secrets.AGENT_PANE_DISPATCH_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
gh api repos/copse-dev/agent-pane/dispatches \
-f event_type=streaming-markdown-release \
-f "client_payload[version]=$VERSION" \
-f "client_payload[tag]=$TAG"
echo "Dispatched streaming-markdown-release $VERSION to copse-dev/agent-pane"