Release #18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |