Operand

do no harem.

gram: docs

> ./.github/workflows/release.yml

name: Release
# Two triggers, single job:
# 1. Every push to main (1.x) or 0.x (maintenance) → if there are pending
# changesets in `.changeset/`, open or update a "chore: release" PR with the
# version bumps and CHANGELOG entries applied. The PR sits there until a
# maintainer merges it.
# 2. When that PR is merged → the same job runs again, sees no pending
# changesets (they were drained by `changeset version`), and instead
# publishes the new versions to npm.
#
# The heavy gate (lint, format, typecheck, tests, build, Playwright, parity)
# only guards the PUBLISH path (mode 2). On the PR-update path (mode 1) those
# steps are skipped: `changeset version` just rewrites versions + CHANGELOGs,
# needs no build, and the work is already validated by each contributing PR's
# own CI. Skipping keeps PR-update runs to ~1 min so they finish before the
# next push to main supersedes them in the `release` concurrency group —
# otherwise slow runs queue and get cancelled, leaving the release PR stale.
#
# Each branch maintains its own `.changeset/*.md` queue. 0.x and 1.x publish
# distinct npm packages (the rename in 1.0 means no `latest` dist-tag
# collision between branches) so no per-branch `--tag` handling is needed.
#
# Manual workflow_dispatch is kept for the rare case of triggering a publish
# without a fresh push (e.g. recovering from a failed publish step).
on:
push:
branches: [main, 0.x]
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Release
runs-on: ubuntu-latest
# Default job timeout is 6h; a hung step (e.g. an apt prompt) otherwise burns
# a runner for hours. A healthy run finishes in well under 10 min.
timeout-minutes: 30
permissions:
contents: write # push tags, create GitHub Releases
pull-requests: write # changesets/action opens / updates the release PR
id-token: write # OIDC for npm Trusted Publishing
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.11'
# Node 24 ships with npm 11.x; Trusted Publishing requires >=11.5.1.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Verify npm version
run: |
which npm
npm --version
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Install dependencies
run: bun install --frozen-lockfile
# `bun install` runs `prepare` (husky), pointing core.hooksPath at the
# repo's pre-commit hook (typecheck + parity + api:check + lint-staged).
# That hook is for local dev — it must NOT gate the bot's automated
# `chore: release` commit, where it both fails (api:check needs a build
# this path skips, and breaks on any pre-existing snapshot drift on main)
# and is redundant: ci.yml already runs these on contributor PRs, and the
# publish path runs them as real steps below. Neuter hooks for the job.
- name: Disable git hooks for automated commits
run: git config core.hooksPath /dev/null
# Pending changesets ⇒ PR-update path (skip the heavy gate); none ⇒
# publish path (run the full gate before publishing to npm). README.md /
# config.json are not changesets. Inline check so it works even if a later
# step crashes.
- name: Detect pending changesets
id: pending
run: |
count=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')
echo "Pending changesets: $count"
if [ "$count" -gt 0 ]; then
echo "publishing=false" >> "$GITHUB_OUTPUT"
else
echo "publishing=true" >> "$GITHUB_OUTPUT"
fi
- name: Lint
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun run lint
- name: Format check
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun run format:check
- name: Type check
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun run typecheck
- name: Run tests
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun test
- name: Build packages (publish reuses the dist/)
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun run build:packages
env:
# tsup's dts worker (rollup-plugin-dts) loads the full TS program;
# core's 67 entries OOM under the default 1.5 GB heap.
NODE_OPTIONS: --max-old-space-size=8192
# Restore the browser binaries from cache so the common path skips the
# download from cdn.playwright.dev. Keyed on the lockfile so a
# @playwright/test bump invalidates it; a miss falls through to a fresh
# download.
- name: Cache Playwright browsers
if: ${{ steps.pending.outputs.publishing == 'true' }}
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
# `--with-deps` shells out to apt-get; a post-install needrestart prompt
# can hang it, so NEEDRESTART_MODE=a + noninteractive suppress it.
# timeout-minutes turns any remaining hang into a fast failure.
#
# NB: Playwright < 1.60.0 deadlocks here on Node >= 24.16 — a vendored
# yauzl regression hangs extraction *after* the download hits 100%
# (microsoft/playwright#40724). `setup-node` pins Node 24, whose runner
# default crossed 24.16, so every publish-path run hung until the job
# timeout. The fix is the >=1.60.0 bump in package.json, not a longer
# timeout. Keep this pinned at >=1.60.0.
- name: Install Playwright browsers for parity gate
if: ${{ steps.pending.outputs.publishing == 'true' }}
timeout-minutes: 10
run: npx playwright install --with-deps chromium
env:
DEBIAN_FRONTEND: noninteractive
NEEDRESTART_MODE: a
- name: Parity prepublish gate
if: ${{ steps.pending.outputs.publishing == 'true' }}
run: bun run parity:prepublish
# The single step that does both PR-management and publishing.
# - If `.changeset/*.md` are pending: opens / updates a "chore: release X.Y.Z" PR
# that runs `version` (drains changesets, bumps versions, writes CHANGELOG).
# - If no pending changesets and current package.json versions aren't on npm yet:
# runs `publish` and creates per-package git tags.
- name: Release PR or Publish
id: changesets
uses: changesets/action@v1.9.0
with:
version: bun run version-packages
publish: bunx changeset publish
title: 'chore: release'
commit: 'chore: release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: 'true'
# changesets/action creates per-package tags (e.g. `@eigenpal/docx-editor-react@1.0.1`);
# also create a single `vX.Y.Z` umbrella tag for Renovate/Dependabot/Releases.
# The fixed group keeps every 1.x package in lockstep, so the umbrella
# version = the version of any published package. The `published` gate
# makes this idempotent: a re-run after publish flips published=false and
# this step skips.
- name: Create umbrella version tag (vX.Y.Z)
if: ${{ steps.changesets.outputs.published == 'true' && github.ref == 'refs/heads/main' }}
env:
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
run: |
version=$(jq -r '.[0].version' <<<"$PUBLISHED_PACKAGES")
git tag -a "v$version" -m "Release v$version"
git push origin "v$version"
- name: Notify Slack — release succeeded
if: ${{ steps.changesets.outputs.published == 'true' && env.SLACK_WEBHOOK_URL != '' }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
ACTOR: ${{ github.actor }}
RUN_NUMBER: ${{ github.run_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# publishedPackages is JSON like:
# [{"name":"@eigenpal/docx-editor-react","version":"1.0.0"}, ...]
version=$(echo "$PUBLISHED_PACKAGES" | jq -r '.[0].version')
packages_md=$(echo "$PUBLISHED_PACKAGES" | jq -r '.[] | "• <https://www.npmjs.com/package/\(.name)/v/\(.version)|\(.name)@\(.version)>"')
# changesets/action with a fixed group creates per-package tags (e.g.
# `@eigenpal/docx-editor-react@1.0.0`), not a single `vX.Y.Z`. URL-encode
# `@`/`/` so the link resolves on GitHub.
release_tag=$(jq -rn --argjson pkgs "$PUBLISHED_PACKAGES" '$pkgs[0].name + "@" + $pkgs[0].version | @uri')
release_url="${{ github.server_url }}/${{ github.repository }}/releases/tag/$release_tag"
payload=$(jq -n \
--arg version "$version" \
--arg actor "$ACTOR" \
--arg run_n "$RUN_NUMBER" \
--arg run_url "$RUN_URL" \
--arg release "$release_url" \
--arg packages "$packages_md" \
'{
text: ":white_check_mark: docx-editor \($version) released",
blocks: [
{ type: "section", text: { type: "mrkdwn", text: ":white_check_mark: *docx-editor \($version) released*" } },
{ type: "section", fields: [
{ type: "mrkdwn", text: "*Version*\n<\($release)|\($version)>" },
{ type: "mrkdwn", text: "*Triggered by*\n\($actor)" }
] },
{ type: "section", text: { type: "mrkdwn",
text: "*Packages*\n\($packages)" } },
{ type: "context", elements: [ { type: "mrkdwn", text: "<\($run_url)|Run #\($run_n)>" } ] }
]
}')
curl -sSf -X POST -H 'Content-Type: application/json' -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null
# Distinguish three failure modes when notifying Slack:
# - MODE=pre-release → an earlier step failed (lint, format check,
# typecheck, tests, build, install) before the changesets step ever ran.
# - MODE=release-pr → changesets step failed AND pending changesets
# exist, so the run was trying to open/update the "chore: release" PR
# (no publish was attempted).
# - MODE=publish → changesets step failed AND no pending changesets,
# so the run was trying to publish to npm and failed mid-publish.
# The pending-changeset check is inline (not a step output) so it works
# even when the changesets step itself crashed before producing outputs.
- name: Notify Slack — workflow failed
if: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
ACTOR: ${{ github.actor }}
RUN_NUMBER: ${{ github.run_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
CHANGESETS_OUTCOME: ${{ steps.changesets.outcome }}
run: |
# Count pending changesets (exclude README.md / config.json).
pending=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')
if [ "$CHANGESETS_OUTCOME" != "failure" ]; then
mode="pre-release"
title=":x: docx-editor — pre-release step failed"
detail="A step before the changesets action failed (install, lint, format check, typecheck, tests, or build). No release PR was updated and no publish was attempted. Check the run logs for the failing step."
elif [ "$pending" -gt 0 ]; then
mode="release-pr"
title=":warning: docx-editor — failed to update release PR"
detail="The Release workflow could not assemble the \`chore: release\` PR (\`$pending\` pending changeset(s)). No publish was attempted. Likely cause: a malformed changeset file (wrong package name, bad frontmatter)."
else
mode="publish"
title=":x: docx-editor — release publish failed"
detail="No pending changesets, so the workflow tried to publish to npm and failed mid-publish. The package may be in a partial state — check the run logs."
fi
payload=$(jq -n \
--arg title "$title" \
--arg detail "$detail" \
--arg mode "$mode" \
--arg actor "$ACTOR" \
--arg run_n "$RUN_NUMBER" \
--arg run_url "$RUN_URL" \
'{
text: $title,
blocks: [
{ type: "section", text: { type: "mrkdwn", text: "*\($title)*" } },
{ type: "section", text: { type: "mrkdwn", text: $detail } },
{ type: "section", fields: [
{ type: "mrkdwn", text: "*Mode*\n\($mode)" },
{ type: "mrkdwn", text: "*Triggered by*\n\($actor)" },
{ type: "mrkdwn", text: "*Run*\n<\($run_url)|#\($run_n)>" }
] }
]
}')
curl -sSf -X POST -H 'Content-Type: application/json' -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null