Release #25
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 }} | |
| # Pin the grant instead of inheriting whatever the installation | |
| # happens to hold. The App is installed org-wide and agent-pane's | |
| # workflows need Pull requests: write and Actions: write from it, so | |
| # an inherited token would silently widen the moment those are added | |
| # — and this one is persisted to disk by the checkout below. | |
| permission-contents: write | |
| - 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. 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. | |
| # | |
| # A SECOND App token, scoped to agent-pane. The one minted at the top of | |
| # this job is scoped to this repository (create-github-app-token's default | |
| # when `repositories` is empty) and cannot reach agent-pane. Minting a | |
| # separate one, rather than widening the first, keeps the credential | |
| # `actions/checkout` persisted as this job's git push credential narrow: a | |
| # token good for two repos would sit on disk for the whole release. | |
| - uses: actions/create-github-app-token@v3 | |
| id: dispatch-token | |
| if: ${{ !inputs.dry_run && !inputs.prerelease }} | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| owner: copse-dev | |
| repositories: agent-pane | |
| # Actions: write is all a workflow_dispatch needs, and unlike the | |
| # Contents: write the old PAT carried, it cannot push code there. | |
| permission-actions: write | |
| - name: Notify agent-pane | |
| if: ${{ !inputs.dry_run && !inputs.prerelease }} | |
| env: | |
| GH_TOKEN: ${{ steps.dispatch-token.outputs.token }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| # See notify-agent-pane.yml for why this is a workflow_dispatch rather | |
| # than a repository_dispatch, and why there is no --ref. | |
| gh workflow run sync-streaming-markdown.yml \ | |
| --repo copse-dev/agent-pane \ | |
| -f "version=$VERSION" | |
| echo "Dispatched sync-streaming-markdown for $VERSION to copse-dev/agent-pane" |