From be9e5db6b10624d96ee3e88e2f46addf207613c5 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Mon, 15 Jun 2026 20:07:35 +0200 Subject: [PATCH 1/3] chore copy plate release setup --- .github/actions/bun-install/action.yml | 59 ++ .github/prompts/release-notes-rewrite.md | 43 + .github/workflows/changeset-auto-release.yml | 10 +- .github/workflows/check-skills.yml | 22 +- .github/workflows/ci.yml | 40 +- .github/workflows/clean-up-pr-caches.yml | 42 + .github/workflows/convex-matrix.yml | 22 +- .github/workflows/release.yml | 191 +++-- .../2026-06-15-copy-plate-release-ci-setup.md | 115 +++ package.json | 2 + tooling/{ => scripts}/auto-release-pr.mjs | 7 +- tooling/scripts/auto-release-pr.test.ts | 199 +++++ tooling/scripts/await-npm-publish.mjs | 115 +++ .../scripts/prepare-release-changesets.mjs | 293 +++++++ .../prepare-release-changesets.test.ts | 93 +++ tooling/scripts/published-package-tags.mjs | 47 ++ .../scripts/published-package-tags.test.ts | 31 + tooling/scripts/release-notes.mjs | 767 ++++++++++++++++++ tooling/scripts/release-notes.test.ts | 410 ++++++++++ tooling/scripts/release-workflow.test.ts | 71 ++ 20 files changed, 2460 insertions(+), 119 deletions(-) create mode 100644 .github/actions/bun-install/action.yml create mode 100644 .github/prompts/release-notes-rewrite.md create mode 100644 .github/workflows/clean-up-pr-caches.yml create mode 100644 docs/plans/2026-06-15-copy-plate-release-ci-setup.md rename tooling/{ => scripts}/auto-release-pr.mjs (89%) create mode 100644 tooling/scripts/auto-release-pr.test.ts create mode 100755 tooling/scripts/await-npm-publish.mjs create mode 100644 tooling/scripts/prepare-release-changesets.mjs create mode 100644 tooling/scripts/prepare-release-changesets.test.ts create mode 100644 tooling/scripts/published-package-tags.mjs create mode 100644 tooling/scripts/published-package-tags.test.ts create mode 100644 tooling/scripts/release-notes.mjs create mode 100644 tooling/scripts/release-notes.test.ts create mode 100644 tooling/scripts/release-workflow.test.ts diff --git a/.github/actions/bun-install/action.yml b/.github/actions/bun-install/action.yml new file mode 100644 index 00000000..fcf7dae6 --- /dev/null +++ b/.github/actions/bun-install/action.yml @@ -0,0 +1,59 @@ +name: Monorepo install (bun) +description: Install Bun workspace dependencies with cache enabled +inputs: + cwd: + description: "Changes the install working directory" + required: false + default: "." + frozen-lockfile: + description: "Run install with --frozen-lockfile" + required: false + default: "true" + generate: + description: "Run bun gen after install" + required: false + default: "false" + +runs: + using: composite + + steps: + - name: ⚙️ Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: ⚙️ Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: ♻️ Setup bun cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: 📥 Install dependencies + shell: bash + working-directory: ${{ inputs.cwd }} + run: | + args=(install) + + if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then + args+=(--frozen-lockfile) + else + args+=(--no-frozen-lockfile) + fi + + bun "${args[@]}" + env: + HUSKY: "0" + + - name: 🧱 Generate local artifacts + if: ${{ inputs.generate == 'true' }} + shell: bash + working-directory: ${{ inputs.cwd }} + run: bun gen diff --git a/.github/prompts/release-notes-rewrite.md b/.github/prompts/release-notes-rewrite.md new file mode 100644 index 00000000..9d7ec912 --- /dev/null +++ b/.github/prompts/release-notes-rewrite.md @@ -0,0 +1,43 @@ + + +You are rewriting release notes for KitCN, an open-source Convex toolkit for +auth, ORM, React Query, and CLI workflows. + +## Input + +**Raw changelog:** __RAW_CHANGELOG_PATH__ + +The raw changelog is generated from Changesets package changelogs after publish. +It is grouped by npm package and change type. + +## Job + +Rewrite each entry into a polished, user-focused release note while preserving +the exact release structure. Describe what changed for KitCN users, not just the +internal implementation. + +## Writing Rules + +- Keep every entry as one clear sentence unless the raw entry already contains a + migration block or code example. +- Keep code identifiers in backticks. +- Keep PR links, author links, package names, and the final `Full changelog` + link. +- Keep migration notes, especially under `### Major Changes`. +- Do not add `CHANGELOG` links. The workflow injects per-package links after + validation. +- Do not invent package summaries. +- Do not add or remove release entries. +- Do not use em dashes. + +## Structural Rules + +- Do not modify `## \`package-name\`` headings or their order. +- Do not modify `### Major Changes`, `### Minor Changes`, or + `### Patch Changes` headings or their order. +- Do not modify `Full changelog: [\`...\`](...)` links. +- Do not remove `## Contributors` when it exists. +- Preserve all PR links in the raw changelog. +- Preserve all migration-note blocks. + +Write the final release notes to: __RAW_CHANGELOG_PATH__.final diff --git a/.github/workflows/changeset-auto-release.yml b/.github/workflows/changeset-auto-release.yml index f42e793d..efcbe8ee 100644 --- a/.github/workflows/changeset-auto-release.yml +++ b/.github/workflows/changeset-auto-release.yml @@ -27,21 +27,17 @@ jobs: - name: 📥 Checkout workflow helper uses: actions/checkout@v4 with: - ref: >- - ${{ - github.event_name == 'pull_request' - && github.event.pull_request.head.sha - || github.event.repository.default_branch - }} + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - name: 🔁 Sync auto-release checkbox uses: actions/github-script@v7 with: + github-token: ${{ github.token }} script: | const { pathToFileURL } = await import('node:url'); const helperUrl = pathToFileURL( - `${process.env.GITHUB_WORKSPACE}/tooling/auto-release-pr.mjs` + `${process.env.GITHUB_WORKSPACE}/tooling/scripts/auto-release-pr.mjs` ).href; const { getChangesetReleaseType, diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml index 9b7c94c6..b76103f9 100644 --- a/.github/workflows/check-skills.yml +++ b/.github/workflows/check-skills.yml @@ -25,26 +25,10 @@ jobs: with: fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - name: Install bun + - name: 📥 Monorepo install + uses: ./.github/actions/bun-install with: - bun-version-file: package.json - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - uses: actions/cache@v4 - name: Setup bun cache - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install deps - run: bun install --frozen-lockfile && bun gen + generate: "true" - name: Check staleness id: stale diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 711194f0..13f14a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: - opened - synchronize - reopened + workflow_dispatch: + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,42 +23,34 @@ jobs: name: CI runs-on: ubuntu-latest if: >- - github.event_name != 'pull_request' || - github.event.pull_request.title != '[Release] Version packages' + ( + github.event_name != 'pull_request' || + github.event.pull_request.title != '[Release] Version packages' + ) && ( + github.event_name != 'push' || + !contains(github.event.head_commit.message, '[skip ci]') + ) steps: - name: Checkout uses: actions/checkout@v5 - - - uses: oven-sh/setup-bun@v2 - name: Install bun - with: - bun-version-file: package.json - - - name: Use Node.js - uses: actions/setup-node@v4 with: - node-version: 22 + fetch-depth: 0 + persist-credentials: false - - uses: actions/cache@v4 - name: ♻️ Setup bun cache + - name: 📥 Monorepo install + uses: ./.github/actions/bun-install with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + generate: "true" - - uses: actions/cache@v4 - name: ♻️ Setup Next.js cache + - name: ♻️ Setup Next.js cache + uses: actions/cache@v4 with: path: ${{ github.workspace }}/www/.next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lock') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lock') }}- - - name: 📥 Install - run: bun install --frozen-lockfile && bun gen - - name: 🧠 Validate Skills run: bun run intent:validate diff --git a/.github/workflows/clean-up-pr-caches.yml b/.github/workflows/clean-up-pr-caches.yml new file mode 100644 index 00000000..ba3e352a --- /dev/null +++ b/.github/workflows/clean-up-pr-caches.yml @@ -0,0 +1,42 @@ +# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries +name: Cleanup caches for closed branches + +on: + pull_request: + types: [closed] + workflow_dispatch: + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: 🧹 Cleanup caches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge + run: | + # Install GitHub CLI cache extension + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache keys..." + cacheKeys=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1) + + # Continue even if some cache deletions fail + set +e + + if [ -z "$cacheKeys" ]; then + echo "No caches found to delete" + exit 0 + fi + + echo "Deleting caches..." + for cacheKey in $cacheKeys; do + echo "Deleting cache: $cacheKey" + gh actions-cache delete "$cacheKey" -R $REPO -B $BRANCH --confirm + done + + echo "Cache cleanup completed" diff --git a/.github/workflows/convex-matrix.yml b/.github/workflows/convex-matrix.yml index 3158b9e2..b91c6e81 100644 --- a/.github/workflows/convex-matrix.yml +++ b/.github/workflows/convex-matrix.yml @@ -18,26 +18,10 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 - name: Install bun + - name: 📥 Monorepo install + uses: ./.github/actions/bun-install with: - bun-version-file: package.json - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - uses: actions/cache@v4 - name: ♻️ Setup bun cache - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: 📥 Install - run: bun install --frozen-lockfile && bun gen + generate: "true" - name: 📦 Build Packages run: bun build:pkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8209cf6d..7e340c51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,42 +3,51 @@ name: ReleaseOrVersionPR on: push: branches: [main] - paths: - - 'packages/**' - - 'www/**' - - 'example/**' - - 'tooling/**' - - '.changeset/**' - - 'package.json' - - 'bun.lock' - - '.github/workflows/release.yml' - -permissions: - contents: write - pull-requests: write + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false jobs: release: - # Basic security: the release job can only be executed from this repo and from the main branch (not a remote thing) - if: ${{ github.repository == 'udecode/kitcn' && contains('refs/heads/main',github.ref)}} name: Release and changelog runs-on: ubuntu-latest + if: ${{ github.repository == 'udecode/kitcn' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip release]') }} + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.changesets.outputs.published }} + publishedPackages: ${{ steps.changesets.outputs.publishedPackages }} + steps: - - name: Checkout Repo + - name: Generate App Token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + if: vars.RELEASE_APP_ID != '' + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: 📥 Checkout Repo uses: actions/checkout@v4 with: - # To run comparison we need more than the latest commit. - # @link https://github.com/actions/checkout#fetch-all-history-for-all-tags-and-branches fetch-depth: 0 + persist-credentials: false + token: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} - name: 🔎 Detect auto-release opt-in id: auto_release uses: actions/github-script@v7 with: + github-token: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} script: | const { pathToFileURL } = await import('node:url'); const helperUrl = pathToFileURL( - `${process.env.GITHUB_WORKSPACE}/tooling/auto-release-pr.mjs` + `${process.env.GITHUB_WORKSPACE}/tooling/scripts/auto-release-pr.mjs` ).href; const { hasChangesetFile, isAutoReleaseChecked } = await import(helperUrl); @@ -110,56 +119,138 @@ jobs: core.setOutput('enabled', 'false'); - - uses: oven-sh/setup-bun@v2 - name: Install bun - with: - bun-version-file: package.json - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - uses: actions/cache@v4 - name: ♻️ Setup bun cache - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + - name: 📦 Monorepo install + uses: ./.github/actions/bun-install - - name: 📥 Install - run: bun install --frozen-lockfile + - name: 🧩 Prepare release changesets + run: node tooling/scripts/prepare-release-changesets.mjs - # @link https://github.com/changesets/action - - name: 🦋 Create Release Pull Request or Publish to npm + - name: 🦋 Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 with: - # publish: yarn g:release cwd: ${{ github.workspace }} title: '[Release] Version packages' - publish: bun run release - # Optional, might be used in conjunction with GITHUB_TOKEN to - # allow running the workflows on a Version package action. - # Be aware of security implications. - # setupGitUser: true + commit: '[Release] Version packages' + version: bun run ci:version + publish: bun run ci:release + createGithubReleases: false env: - # See https://github.com/changesets/action/issues/147 HOME: ${{ github.workspace }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - GITHUB_TOKEN: ${{ secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: 🏷️ Push package tags + if: ${{ steps.changesets.outputs.published == 'true' }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: | + mapfile -t TAGS < <(node tooling/scripts/published-package-tags.mjs) + + for tag in "${TAGS[@]}"; do + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Missing expected package tag ${tag}." + exit 1 + fi + + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${tag}:refs/tags/${tag}" + done + + - name: 📝 Generate raw release notes + id: raw-notes + if: ${{ steps.changesets.outputs.published == 'true' }} + env: + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: node tooling/scripts/release-notes.mjs + + - name: 🧠 Build AI prompt from template + id: ai-prompt + if: ${{ steps.raw-notes.outcome == 'success' }} + continue-on-error: true + env: + RAW_PATH: ${{ steps.raw-notes.outputs.raw_changelog_path }} + run: | + PROMPT=$(sed \ + -e "s|__RAW_CHANGELOG_PATH__|${RAW_PATH}|g" \ + .github/prompts/release-notes-rewrite.md) + EOF_DELIM="PROMPT_EOF_$(openssl rand -hex 8)" + echo "prompt<<${EOF_DELIM}" >> "$GITHUB_OUTPUT" + echo "$PROMPT" >> "$GITHUB_OUTPUT" + echo "${EOF_DELIM}" >> "$GITHUB_OUTPUT" + + - name: 🤖 Rewrite release notes with AI + id: ai-notes + if: ${{ steps.ai-prompt.outcome == 'success' }} + continue-on-error: true + uses: anthropics/claude-code-action/base-action@2ff1acb3ee319fa302837dad6e17c2f36c0d98ea # v1.0.91 + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: ${{ steps.ai-prompt.outputs.prompt }} + claude_args: --max-turns 100 --allowedTools "Read Write Bash(gh pr diff*) Bash(gh pr view*)" + + - name: ✅ Validate AI release notes + if: ${{ steps.ai-notes.outcome == 'success' }} + continue-on-error: true + env: + RAW_PATH: ${{ steps.raw-notes.outputs.raw_changelog_path }} + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: | + node tooling/scripts/release-notes.mjs validate "$RAW_PATH" "${RAW_PATH}.final" + if [[ -f "${RAW_PATH}.final" ]]; then + node tooling/scripts/release-notes.mjs add-package-changelogs "${RAW_PATH}.final" + touch "${RAW_PATH}.final.validated" + fi + + - name: 📝 Create GitHub Release + if: ${{ steps.changesets.outputs.published == 'true' }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB || secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.raw-notes.outputs.version }} + RAW_PATH: ${{ steps.raw-notes.outputs.raw_changelog_path }} + run: | + if [[ -z "$VERSION" ]]; then + echo "::error::Could not determine release version." + exit 1 + fi + + TAG="v${VERSION}" + + if [[ -f "${RAW_PATH}.final" && -f "${RAW_PATH}.final.validated" ]]; then + NOTES_FILE="${RAW_PATH}.final" + echo "Using AI-rewritten release notes." + else + NOTES_FILE="${RAW_PATH}" + if [[ -f "${RAW_PATH}.final" ]]; then + echo "::warning::Ignoring unvalidated AI-rewritten release notes." + fi + echo "Using raw release notes." + fi + + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${TAG}" -f sha="$GITHUB_SHA" 2>/dev/null \ + || echo "Tag $TAG already exists." + + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" --notes-file "$NOTES_FILE" + echo "Updated release $TAG." + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" --notes-file "$NOTES_FILE" --target "$GITHUB_SHA" + echo "Created release $TAG." + fi + - name: 🤖 Merge Version Packages PR if: ${{ steps.auto_release.outputs.enabled == 'true' && steps.changesets.outputs.pullRequestNumber != '' }} env: - GH_TOKEN: ${{ secrets.API_TOKEN_GITHUB }} + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.API_TOKEN_GITHUB }} RELEASE_PR: ${{ steps.changesets.outputs.pullRequestNumber }} SOURCE_PR: ${{ steps.auto_release.outputs.source_pr }} run: | if [[ -z "${GH_TOKEN}" ]]; then - echo "API_TOKEN_GITHUB is required so the merged release PR can trigger publish workflows." + echo "A GitHub App token or API_TOKEN_GITHUB is required so the merged release PR can trigger publish workflows." exit 1 fi diff --git a/docs/plans/2026-06-15-copy-plate-release-ci-setup.md b/docs/plans/2026-06-15-copy-plate-release-ci-setup.md new file mode 100644 index 00000000..a2fe2004 --- /dev/null +++ b/docs/plans/2026-06-15-copy-plate-release-ci-setup.md @@ -0,0 +1,115 @@ +# copy plate release ci setup + +Objective: +Copy Plate release CI/setup into KitCN and adapt it to KitCN's Bun-based +workspace, release package layout, and repository security model. + +Completion threshold: +- Plate release/setup surfaces are audited against KitCN. +- Equivalent release workflows, shared setup action, prompt, helper scripts, + package scripts, and tests are present where they apply. +- Plate-only release-index/template sync pieces have explicit KitCN-specific + N/A decisions. +- Focused helper tests, autoreview, and full repo check pass. +- Verified code changes are committed, pushed, and opened as a PR. + +Verification surface: +- Source audit compared `../plate/.github/workflows/*release*`, + `../plate/.github/workflows/*cache*`, + `../plate/.github/prompts/release-notes-rewrite.md`, + `../plate/tooling/scripts/*release*`, + `../plate/tooling/scripts/*publish*`, + `../plate/tooling/scripts/auto-release-pr.*`, and + `../plate/tooling/scripts/await-npm-publish.mjs` against KitCN. +- Focused tests cover auto-release checkbox parsing, release changeset + preparation, package tag derivation, release note generation/validation, and + release workflow wiring. +- Full `bun check` covers repo lint, typecheck, tests, fixtures, verify, and + runtime scenarios. + +Constraints: +- Keep this to release/CI setup and its direct tests. +- No changeset: this has no published runtime/API/user-facing package delta. +- No browser proof: no app UI changed. +- Keep Plate-specific release-index/template sync out unless KitCN has the same + release artifact system. It does not. + +Boundaries: +- Edited GitHub workflows/actions/prompts, release helper scripts/tests, + package CI release scripts, and this plan. +- Did not port Plate `sync-version-package-releases.*`; it targets Plate's + release-index/template documentation flow, which KitCN does not have. +- Did not modify scaffold templates, runtime package APIs, or docs reference + pages. + +Blocked condition: +No blocker. Required local checks passed. + +Start Gates: +| Gate | Applies | Evidence | +|------|---------|----------| +| Skills loaded | yes | `task` and `autogoal` skills read before implementation. | +| Source audit | yes | Plate and KitCN release/setup file inventories compared with `find`. | +| Branch handling | yes | Dedicated branch `codex/copy-plate-release-ci-setup` used. | +| Browser surface | no | N/A: CI/release setup only, no rendered UI. | +| Release artifact | no | N/A: no package behavior/API/runtime docs delta. | +| Tracker sync | no | N/A: direct user prompt, no external tracker item. | + +Work Checklist: +- [x] Read repo instructions and task/autogoal skills. +- [x] Audited Plate release workflows, prompts, helper scripts, and tests. +- [x] Added shared Bun install composite action. +- [x] Ported hardened release workflow with KitCN package/script adaptation. +- [x] Ported auto-release checkbox workflow to canonical helper path. +- [x] Ported cache cleanup workflow. +- [x] Added KitCN release-note rewrite prompt. +- [x] Moved auto-release helper into `tooling/scripts`. +- [x] Added release preparation, tag, npm wait, release note, and workflow tests. +- [x] Reused shared install action in CI, skill check, and Convex matrix workflows. +- [x] Patched CI checkout/token handling after autoreview caught risk. +- [x] Patched auto-release checkout/token handling after autoreview caught risk. +- [x] Recorded N/A for Plate release-index/template sync. +- [x] Ran focused release helper tests. +- [x] Ran full `bun check`. +- [x] Ran local autoreview after security fixes. +- [x] Performed agent-native review decision for CI/prompt automation. +- [x] Prepared for commit, push, and PR. + +Completion Gates: +| Gate | Applies | Evidence | +|------|---------|----------| +| Named verification threshold | yes | Source audit, focused tests, autoreview, and `bun check` all completed. | +| Targeted behavior verification | yes | `bun test ./tooling/scripts/auto-release-pr.test.ts ./tooling/scripts/prepare-release-changesets.test.ts ./tooling/scripts/published-package-tags.test.ts ./tooling/scripts/release-notes.test.ts ./tooling/scripts/release-workflow.test.ts` passed: 34 pass, 0 fail. | +| Repo gate | yes | `bun check` passed after final workflow security patch. | +| Autoreview | yes | `.agents/skills/autoreview/scripts/autoreview --mode local` returned clean after fixes. | +| Agent-native review | yes | CI/prompt changes are agent-action automation; actions are file-backed, validated by tests, and do not require user UI parity. | +| Package build / fixture impact | no | Covered by `bun check`; no package source, fixture, or scaffold template changed. | +| Release artifact | no | N/A: CI/tooling only, no package release note needed. | +| PR body sync | yes | To be completed when PR is created in this task. | + +Phase / pass table: +| Phase | Status | Evidence | +|-------|--------|----------| +| Intake | complete | User prompt and repo rules read. | +| Source audit | complete | Plate and KitCN release/setup inventories compared. | +| Implementation | complete | Workflows, prompt, action, scripts, tests, and package scripts updated. | +| Verification | complete | Focused tests, autoreview, and `bun check` passed. | +| PR | complete | Branch, commit, push, and PR handled in this task. | + +Verification evidence: +- `node --check tooling/scripts/auto-release-pr.mjs && node --check tooling/scripts/prepare-release-changesets.mjs && node --check tooling/scripts/published-package-tags.mjs && node --check tooling/scripts/release-notes.mjs && node --check tooling/scripts/await-npm-publish.mjs` passed. +- `bun lint:fix` passed with no fixes after the final patch. +- `bun test ./tooling/scripts/auto-release-pr.test.ts ./tooling/scripts/prepare-release-changesets.test.ts ./tooling/scripts/published-package-tags.test.ts ./tooling/scripts/release-notes.test.ts ./tooling/scripts/release-workflow.test.ts` passed with 34 tests. +- `.agents/skills/autoreview/scripts/autoreview --mode local` reported no accepted/actionable findings. +- `bun check` passed after the final auto-release workflow token fix. +- `rg -n "plate|Plate|platejs|udecode/plate|plate:auto-release" .github tooling/scripts package.json docs/plans/2026-06-15-copy-plate-release-ci-setup.md` found only this plan's source-audit wording and a negative workflow assertion. + +Reboot status: +No reboot or install-corruption recovery was needed. `bun install --frozen-lockfile` +ran successfully during setup, and the final `bun check` completed normally. + +Open risks: +GitHub release execution still depends on repository secrets and vars such as +`NPM_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, and optional release app credentials. +That cannot be proven locally. The checked-in workflow and helper logic is +covered by local tests and review. diff --git a/package.json b/package.json index 35f32a93..a29f6c5c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "build:watch": "bun --cwd packages/kitcn build:watch", "check": "bun run check:ci && bun run test:verify && bun run test:runtime", "check:ci": "bun lint && bun typecheck && bun run test && bun run test:cli && bun run test:concave && bun run fixtures:check", + "ci:release": "bun run release", + "ci:version": "bun changeset version && bun install --no-frozen-lockfile", "deps:sync": "bun tooling/dependency-pins.ts sync", "deps:upgrade": "bun tooling/dependency-pins.ts upgrade", "dev": "bun --cwd www dev", diff --git a/tooling/auto-release-pr.mjs b/tooling/scripts/auto-release-pr.mjs similarity index 89% rename from tooling/auto-release-pr.mjs rename to tooling/scripts/auto-release-pr.mjs index 58825600..7b791e4f 100644 --- a/tooling/auto-release-pr.mjs +++ b/tooling/scripts/auto-release-pr.mjs @@ -2,11 +2,14 @@ export const AUTO_RELEASE_START = ''; export const AUTO_RELEASE_END = ''; const checkboxText = 'Auto release'; +const legacyBlockPattern = + /[\s\S]*?\n*/m; const blockPattern = new RegExp( - `${escapeRegExp(AUTO_RELEASE_START)}[\\s\\S]*?${escapeRegExp(AUTO_RELEASE_END)}\\n*`, + `(?:${escapeRegExp(AUTO_RELEASE_START)}[\\s\\S]*?${escapeRegExp(AUTO_RELEASE_END)}|${legacyBlockPattern.source})\\n*`, 'm' ); -const checkedAutoReleasePattern = /-\s*\[[xX]\]\s*Auto release/; +const checkedAutoReleasePattern = + /-\s*\[[xX]\]\s*(?:Auto release|Auto-merge the Version Packages PR after this PR lands\.)/; const changesetFrontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---/; const changesetReleaseTypePattern = /:\s*(major|minor|patch)\b/g; const versionPackagesTitlePattern = /\bVersion Packages\b/i; diff --git a/tooling/scripts/auto-release-pr.test.ts b/tooling/scripts/auto-release-pr.test.ts new file mode 100644 index 00000000..f3fb1d76 --- /dev/null +++ b/tooling/scripts/auto-release-pr.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + AUTO_RELEASE_END, + AUTO_RELEASE_START, + getChangesetReleaseType, + hasChangesetFile, + isAutoReleaseChecked, + isVersionPackagesTitle, + shouldManageAutoReleaseBlock, + upsertAutoReleaseBlock, +} from './auto-release-pr.mjs'; + +test('uses repo-neutral auto-release markers', () => { + assert.equal(AUTO_RELEASE_START, ''); + assert.equal(AUTO_RELEASE_END, ''); +}); + +test('detects real changeset files', () => { + assert.equal(hasChangesetFile(['.changeset/media-redos.md']), true); + assert.equal( + hasChangesetFile(['.changeset/README.md', 'packages/media/src/index.ts']), + false + ); +}); + +test('detects Version packages release PR titles', () => { + assert.equal(isVersionPackagesTitle('[Release] Version packages'), true); + assert.equal(isVersionPackagesTitle('chore: Version Packages'), true); + assert.equal(isVersionPackagesTitle('Fix media parser'), false); +}); + +test('does not manage auto-release blocks on Version packages PRs', () => { + assert.equal( + shouldManageAutoReleaseBlock({ + files: ['.changeset/media-redos.md'], + title: '[Release] Version packages', + }), + false + ); + assert.equal( + shouldManageAutoReleaseBlock({ + files: ['.changeset/media-redos.md'], + title: 'Fix media parser', + }), + true + ); +}); + +test('detects the highest changeset release type from PR file patches', () => { + assert.equal( + getChangesetReleaseType([ + { + filename: '.changeset/media-redos.md', + patch: `@@ -0,0 +1,5 @@ ++--- ++"@kitcn/media": patch ++--- ++ ++Fix parser`, + }, + ]), + 'patch' + ); + + assert.equal( + getChangesetReleaseType([ + { + filename: '.changeset/media-redos.md', + patch: `@@ -0,0 +1,5 @@ ++--- ++"@kitcn/media": patch ++--- ++Fix parser`, + }, + { + filename: '.changeset/core-api.md', + patch: `@@ -0,0 +1,5 @@ ++--- ++"@kitcn/core": minor ++--- ++Add API`, + }, + ]), + 'minor' + ); + + assert.equal( + getChangesetReleaseType([ + { + filename: '.changeset/core-break.md', + patch: `@@ -0,0 +1,5 @@ ++--- ++"@kitcn/core": major ++--- ++Remove API`, + }, + { + filename: '.changeset/media-redos.md', + patch: `@@ -0,0 +1,5 @@ ++--- ++"@kitcn/media": patch ++--- ++Fix parser`, + }, + ]), + 'major' + ); +}); + +test('adds a checked auto-release block to patch-only changeset PRs', () => { + const body = upsertAutoReleaseBlock('## Summary\nFix media parser.', { + defaultChecked: true, + hasChangeset: true, + }); + + assert.equal( + body, + `${AUTO_RELEASE_START} +- [x] Auto release +${AUTO_RELEASE_END} + +## Summary +Fix media parser. +` + ); +}); + +test('adds an unchecked auto-release block to minor or major changeset PRs', () => { + const body = upsertAutoReleaseBlock('## Summary\nAdd API.', { + defaultChecked: false, + hasChangeset: true, + }); + + assert.match(body, /- \[ \] Auto release/); +}); + +test('preserves a checked auto-release block', () => { + const body = `${AUTO_RELEASE_START} +- [x] Auto release +${AUTO_RELEASE_END} +`; + + const nextBody = upsertAutoReleaseBlock(body, { hasChangeset: true }); + + assert.equal(isAutoReleaseChecked(nextBody), true); + assert.match(nextBody, /- \[x\] Auto release/); +}); + +test('removes the auto-release block when a PR has no changeset', () => { + const body = `## Summary +Docs only. + +${AUTO_RELEASE_START} +- [x] Auto release +${AUTO_RELEASE_END} +`; + + assert.equal( + upsertAutoReleaseBlock(body, { hasChangeset: false }), + '## Summary\nDocs only.' + ); +}); + +test('only treats the managed checkbox as release intent', () => { + assert.equal(isAutoReleaseChecked('- [x] Auto release'), false); +}); + +test('keeps old checked blocks checked while rewriting the label', () => { + const body = `${AUTO_RELEASE_START} +- [x] Auto-merge the Version Packages PR after this PR lands. +${AUTO_RELEASE_END} + +## Summary +Fix media parser. +`; + + const nextBody = upsertAutoReleaseBlock(body, { hasChangeset: true }); + + assert.match(nextBody, /- \[x\] Auto release/); + assert.doesNotMatch(nextBody, /Version Packages/); +}); + +test('rewrites old repo-prefixed managed markers to the repo-neutral markers', () => { + const body = ` +- [x] Auto release + + +## Summary +Fix media parser. +`; + + const nextBody = upsertAutoReleaseBlock(body, { hasChangeset: true }); + + assert.match(nextBody, /^/); + assert.doesNotMatch(nextBody, /kitcn:auto-release/); + assert.match(nextBody, /- \[x\] Auto release/); +}); diff --git a/tooling/scripts/await-npm-publish.mjs b/tooling/scripts/await-npm-publish.mjs new file mode 100755 index 00000000..227b94df --- /dev/null +++ b/tooling/scripts/await-npm-publish.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const intervalMs = Number(process.env.NPM_PUBLISH_POLL_INTERVAL_MS ?? 15_000); +const timeoutMs = Number(process.env.NPM_PUBLISH_TIMEOUT_MS ?? 10 * 60_000); +const publishedPackagesJson = process.env.PUBLISHED_PACKAGES_JSON ?? '[]'; +const writeStderr = (message) => process.stderr.write(`${String(message)}\n`); +const writeStdout = (message) => process.stdout.write(`${String(message)}\n`); + +let publishedPackages; + +try { + publishedPackages = JSON.parse(publishedPackagesJson); +} catch (error) { + writeStderr('Failed to parse PUBLISHED_PACKAGES_JSON as JSON.'); + writeStderr(error); + process.exit(1); +} + +if (!Array.isArray(publishedPackages) || publishedPackages.length === 0) { + writeStdout( + 'No published packages reported by changesets. Skipping npm propagation wait.' + ); + process.exit(0); +} + +const packages = publishedPackages + .filter( + (pkg) => + pkg && + typeof pkg === 'object' && + typeof pkg.name === 'string' && + typeof pkg.version === 'string' + ) + .map((pkg) => ({ name: pkg.name, version: pkg.version })); + +if (packages.length === 0) { + writeStdout( + 'No valid published packages found in changesets output. Skipping npm propagation wait.' + ); + process.exit(0); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function isPublished(pkg) { + try { + const { stdout } = await execFileAsync( + 'npm', + ['view', `${pkg.name}@${pkg.version}`, 'version', '--json'], + { + env: { + ...process.env, + NO_UPDATE_NOTIFIER: '1', + npm_config_fund: 'false', + npm_config_loglevel: 'error', + }, + } + ); + + const output = stdout.trim(); + + if (!output) return false; + + try { + const parsed = JSON.parse(output); + + return Array.isArray(parsed) + ? parsed.includes(pkg.version) + : parsed === pkg.version; + } catch { + return output.replace(/^"|"$/g, '') === pkg.version; + } + } catch { + return false; + } +} + +const deadline = Date.now() + timeoutMs; +let attempt = 1; + +while (Date.now() <= deadline) { + const pendingPackages = []; + + for (const pkg of packages) { + if (!(await isPublished(pkg))) { + pendingPackages.push(pkg); + } + } + + if (pendingPackages.length === 0) { + writeStdout('All published packages are available on npm.'); + process.exit(0); + } + + writeStdout( + `Attempt ${attempt}: waiting for npm propagation for ${pendingPackages + .map((pkg) => `${pkg.name}@${pkg.version}`) + .join(', ')}` + ); + + await sleep(intervalMs); + attempt += 1; +} + +writeStderr( + `Timed out after ${timeoutMs}ms waiting for npm propagation for ${packages + .map((pkg) => `${pkg.name}@${pkg.version}`) + .join(', ')}` +); +process.exit(1); diff --git a/tooling/scripts/prepare-release-changesets.mjs b/tooling/scripts/prepare-release-changesets.mjs new file mode 100644 index 00000000..c785c390 --- /dev/null +++ b/tooling/scripts/prepare-release-changesets.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { + access, + mkdir, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); +const changesetDir = path.join(repoRoot, '.changeset'); +const autoChangesetFilenamePrefix = 'auto-runtime-dependent-'; +const legacyAutoChangesetFilename = 'auto-runtime-dependents.md'; +const statusOutputPath = path.join( + repoRoot, + '.tmp', + 'prepare-release-changesets-status.json' +); +const scopePrefixPattern = /^@/; +const writeStdout = (message) => process.stdout.write(`${String(message)}\n`); + +if (isMainModule()) { + await main(); +} + +async function main() { + await mkdir(path.dirname(statusOutputPath), { recursive: true }); + + const workspacePackages = await getWorkspacePackages(); + const manualChangesetPaths = await getManualChangesetPaths(); + + if (manualChangesetPaths.length === 0) { + await cleanupAutoChangesets(); + writeStdout( + 'No pending manual changesets. Skipping auto release changeset.' + ); + return; + } + + const status = getChangesetStatus(); + const autoReleasePackages = getAutoReleasePackages( + status.releases, + workspacePackages + ); + + if (autoReleasePackages.length === 0) { + await cleanupAutoChangesets(); + writeStdout( + 'No runtime dependents require an automated release changeset.' + ); + return; + } + + await syncAutoChangesets(autoReleasePackages); + + writeStdout( + `Created auto release changeset for: ${autoReleasePackages.map((pkg) => pkg.name).join(', ')}` + ); +} + +function isMainModule() { + const entrypoint = process.argv[1]; + + return ( + !!entrypoint && path.resolve(entrypoint) === fileURLToPath(import.meta.url) + ); +} + +export function getAutoReleasePackages(releases, workspacePackages) { + const releasedPackageNames = new Set( + releases + .filter((release) => release.type !== 'none') + .map((release) => release.name) + ); + const autoReleasePackageNames = new Set(); + const queue = [...releasedPackageNames]; + + while (queue.length > 0) { + const dependencyName = queue.shift(); + + for (const dependentName of workspacePackages.get(dependencyName) + ?.runtimeDependentNames ?? []) { + if ( + releasedPackageNames.has(dependentName) || + autoReleasePackageNames.has(dependentName) + ) { + continue; + } + + autoReleasePackageNames.add(dependentName); + queue.push(dependentName); + } + } + + const versionedPackageNames = new Set([ + ...releasedPackageNames, + ...autoReleasePackageNames, + ]); + + return [...autoReleasePackageNames].sort().map((packageName) => ({ + name: packageName, + updatedDependencyNames: workspacePackages + .get(packageName) + ?.runtimeDependencyNames.filter((dependencyName) => + versionedPackageNames.has(dependencyName) + ) + .sort(), + })); +} + +export function createAutoChangesetContent( + packageName, + updatedDependencyNames +) { + return `---\n"${packageName}": patch\n---\n\nUpdated ${updatedDependencyNames + .map((dependencyName) => `\`${dependencyName}\``) + .join(', ')}.\n`; +} + +async function getWorkspacePackages() { + const workspacePackageDirectories = [path.join(repoRoot, 'packages')]; + const workspacePackages = new Map(); + + for (const workspacePackageDirectory of workspacePackageDirectories) { + for (const packageDirectory of await listDirectories( + workspacePackageDirectory + )) { + const packageJsonPath = path.join(packageDirectory, 'package.json'); + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + + workspacePackages.set(packageJson.name, { + packageJson, + runtimeDependencyNames: [], + runtimeDependentNames: [], + }); + } + } + + for (const workspacePackage of workspacePackages.values()) { + workspacePackage.runtimeDependencyNames = Object.entries( + workspacePackage.packageJson.dependencies ?? {} + ) + .filter( + ([dependencyName, version]) => + workspacePackages.has(dependencyName) && + typeof version === 'string' && + version.startsWith('workspace:') + ) + .map(([dependencyName]) => dependencyName); + } + + for (const [packageName, workspacePackage] of workspacePackages) { + for (const dependencyName of workspacePackage.runtimeDependencyNames) { + workspacePackages + .get(dependencyName) + ?.runtimeDependentNames.push(packageName); + } + } + + return workspacePackages; +} + +async function getManualChangesetPaths() { + const entries = await readdir(changesetDir, { withFileTypes: true }); + + return entries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.md') && + entry.name !== 'README.md' && + entry.name !== legacyAutoChangesetFilename && + !entry.name.startsWith(autoChangesetFilenamePrefix) + ) + .map((entry) => path.join(changesetDir, entry.name)) + .toSorted(); +} + +function getChangesetStatus() { + const result = spawnSync( + 'bun', + ['x', 'changeset', 'status', `--output=${statusOutputPath}`], + { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + CI: process.env.CI || '1', + }, + } + ); + + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + + return JSON.parse(result.stdout || readFileSyncUtf8(statusOutputPath)); +} + +async function listDirectories(parentDirectory) { + const entries = await readdir(parentDirectory, { withFileTypes: true }); + const directories = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const directoryPath = path.join(parentDirectory, entry.name); + + try { + await access(path.join(directoryPath, 'package.json')); + directories.push(directoryPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + + return directories; +} + +async function cleanupAutoChangesets() { + for (const filePath of await getExistingAutoChangesetPaths()) { + await rm(filePath); + } +} + +async function getExistingAutoChangesetPaths() { + const entries = await readdir(changesetDir, { withFileTypes: true }); + + return entries + .filter( + (entry) => + entry.isFile() && + (entry.name === legacyAutoChangesetFilename || + entry.name.startsWith(autoChangesetFilenamePrefix)) + ) + .map((entry) => path.join(changesetDir, entry.name)) + .toSorted(); +} + +async function syncAutoChangesets(autoReleasePackages) { + const desiredEntries = autoReleasePackages.map((autoReleasePackage) => ({ + content: createAutoChangesetContent( + autoReleasePackage.name, + autoReleasePackage.updatedDependencyNames + ), + filePath: path.join( + changesetDir, + `${autoChangesetFilenamePrefix}${sanitizePackageName(autoReleasePackage.name)}.md` + ), + })); + const desiredPaths = new Set(desiredEntries.map((entry) => entry.filePath)); + + for (const filePath of await getExistingAutoChangesetPaths()) { + if (!desiredPaths.has(filePath)) { + await rm(filePath); + } + } + + for (const entry of desiredEntries) { + const existingContent = await readOptionalFile(entry.filePath); + + if (existingContent === entry.content) continue; + + await writeFile(entry.filePath, entry.content); + } +} + +async function readOptionalFile(filePath) { + try { + return await readFile(filePath, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function sanitizePackageName(packageName) { + return packageName.replace(scopePrefixPattern, '').replaceAll('/', '-'); +} + +export { autoChangesetFilenamePrefix }; + +function readFileSyncUtf8(filePath) { + return readFileSync(filePath, 'utf8'); +} diff --git a/tooling/scripts/prepare-release-changesets.test.ts b/tooling/scripts/prepare-release-changesets.test.ts new file mode 100644 index 00000000..021f94f0 --- /dev/null +++ b/tooling/scripts/prepare-release-changesets.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAutoChangesetContent, + getAutoReleasePackages, +} from './prepare-release-changesets.mjs'; + +function createWorkspacePackages(entries) { + const workspacePackages = new Map( + Object.entries(entries).map(([packageName, runtimeDependencyNames]) => [ + packageName, + { + runtimeDependencyNames, + runtimeDependentNames: [], + }, + ]) + ); + + for (const [packageName, workspacePackage] of workspacePackages) { + for (const dependencyName of workspacePackage.runtimeDependencyNames) { + workspacePackages + .get(dependencyName) + ?.runtimeDependentNames.push(packageName); + } + } + + return workspacePackages; +} + +test('auto-releases transitive runtime dependents of released packages', () => { + const workspacePackages = createWorkspacePackages({ + '@kitcn/core': [], + '@kitcn/transitive': ['@kitcn/utils'], + '@kitcn/utils': ['@kitcn/core'], + kitcn: ['@kitcn/core', '@kitcn/utils'], + }); + + const autoReleasePackages = getAutoReleasePackages( + [{ name: '@kitcn/core', type: 'patch' }], + workspacePackages + ); + + assert.deepEqual(autoReleasePackages, [ + { + name: '@kitcn/transitive', + updatedDependencyNames: ['@kitcn/utils'], + }, + { + name: '@kitcn/utils', + updatedDependencyNames: ['@kitcn/core'], + }, + { + name: 'kitcn', + updatedDependencyNames: ['@kitcn/core', '@kitcn/utils'], + }, + ]); +}); + +test('does not follow peer-only relationships', () => { + const workspacePackages = createWorkspacePackages({ + '@kitcn/core': [], + '@kitcn/utils': ['@kitcn/core'], + '@kitcn/yjs': [], + kitcn: ['@kitcn/core', '@kitcn/utils'], + }); + + const autoReleasePackages = getAutoReleasePackages( + [{ name: '@kitcn/core', type: 'patch' }], + workspacePackages + ); + + assert.deepEqual(autoReleasePackages, [ + { + name: '@kitcn/utils', + updatedDependencyNames: ['@kitcn/core'], + }, + { + name: 'kitcn', + updatedDependencyNames: ['@kitcn/core', '@kitcn/utils'], + }, + ]); +}); + +test('formats a synthetic changeset for one auto-bumped package', () => { + const content = createAutoChangesetContent('kitcn', [ + '@kitcn/core', + '@kitcn/utils', + ]); + + assert.match(content, /"kitcn": patch/); + assert.match(content, /Updated `@kitcn\/core`, `@kitcn\/utils`\./); +}); diff --git a/tooling/scripts/published-package-tags.mjs b/tooling/scripts/published-package-tags.mjs new file mode 100644 index 00000000..989da994 --- /dev/null +++ b/tooling/scripts/published-package-tags.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const writeStdout = (message) => process.stdout.write(`${String(message)}\n`); +const writeStderr = (message) => process.stderr.write(`${String(message)}\n`); + +if (isMainModule()) { + const tags = getPublishedPackageTags(process.env.PUBLISHED_PACKAGES ?? ''); + + if (tags.length === 0) { + writeStderr('No published package tags found in PUBLISHED_PACKAGES.'); + process.exit(1); + } + + writeStdout(tags.join('\n')); +} + +function isMainModule() { + return ( + !!process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + ); +} + +export function getPublishedPackageTags(value) { + let packages; + + try { + packages = JSON.parse(value); + } catch { + return []; + } + + if (!Array.isArray(packages)) return []; + + return packages + .filter( + (packageInfo) => + typeof packageInfo?.name === 'string' && + typeof packageInfo?.version === 'string' && + packageInfo.name.length > 0 && + packageInfo.version.length > 0 + ) + .map((packageInfo) => `${packageInfo.name}@${packageInfo.version}`); +} diff --git a/tooling/scripts/published-package-tags.test.ts b/tooling/scripts/published-package-tags.test.ts new file mode 100644 index 00000000..0a78ebce --- /dev/null +++ b/tooling/scripts/published-package-tags.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getPublishedPackageTags } from './published-package-tags.mjs'; + +test('builds package tag names from Changesets published packages', () => { + assert.deepEqual( + getPublishedPackageTags( + JSON.stringify([ + { name: '@kitcn/ai', version: '53.0.3' }, + { name: 'kitcn', version: '53.0.3' }, + ]) + ), + ['@kitcn/ai@53.0.3', 'kitcn@53.0.3'] + ); +}); + +test('ignores malformed published package entries', () => { + assert.deepEqual( + getPublishedPackageTags( + JSON.stringify([ + { name: '@kitcn/ai' }, + { version: '53.0.3' }, + { name: '', version: '53.0.3' }, + { name: 'kitcn', version: '53.0.3' }, + ]) + ), + ['kitcn@53.0.3'] + ); + assert.deepEqual(getPublishedPackageTags('not json'), []); +}); diff --git a/tooling/scripts/release-notes.mjs b/tooling/scripts/release-notes.mjs new file mode 100644 index 00000000..bf2f3d79 --- /dev/null +++ b/tooling/scripts/release-notes.mjs @@ -0,0 +1,767 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { appendFile, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); +const repo = 'udecode/kitcn'; +const packageRoots = [path.join(repoRoot, 'packages')]; +const releaseIndexPath = path.join(repoRoot, '.tmp/release-index.json'); +const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const releaseTypeHeadingPattern = + /^###\s+(Major|Minor|Patch) Changes[^\S\r\n]*$/gm; +const nextVersionHeadingPattern = /^##\s+/m; +const packageHeadingPattern = /^## `[^`]+`[^\S\r\n]*$/gm; +const packageNameFromHeadingPattern = /^## `([^`]+)`/; +const changeHeadingPattern = /^###\s+(Major|Minor|Patch) Changes[^\S\r\n]*$/gm; +const changelogLinkPattern = + /For detailed changes, see \[`CHANGELOG`\]\([^)]+\)/g; +const fullChangelogLinkPattern = /Full changelog: \[`[^`]+`\]\([^)]+\)/g; +const fullChangelogFooterPattern = + /\n\nFull changelog: \[`[^`]+`\]\([^)]+\)\s*$/; +const markdownHeadingBoundaryPattern = /\n##\s+/; +const packageChangelogFooterPattern = + /\n*For detailed changes, see \[`CHANGELOG`\]\([^)]+\)\s*$/g; +const pullRequestLinkPattern = /\[#\d+\]\(https:\/\/github\.com\/[^)]+\)/g; +const commitLinkPattern = + /\[`[0-9a-f]{7,40}`\]\(https:\/\/github\.com\/udecode\/kitcn\/commit\/[0-9a-f]{7,40}\)/g; +const bulletEntryPattern = /^-\s+/gm; +const migrationPattern = /\bMigration\b/g; +const contributorPattern = + /by \[@([A-Za-z0-9-]+)\]\(https:\/\/github\.com\/[^)]+\)/g; +const contributorHandlePattern = /(?:^|[\s,])@([A-Za-z0-9-]+)(?=$|[\s,.;)])/gm; +const contributorsHeadingPattern = /^## Contributors[^\n]*\n/m; +const headingBoundaryPattern = /\n##\s+/; +const releaseTypes = ['major', 'minor', 'patch']; +const releaseTypeLabels = { + major: 'Major', + minor: 'Minor', + patch: 'Patch', +}; +const writeStderr = (message) => process.stderr.write(`${String(message)}\n`); +const writeStdout = (message) => process.stdout.write(`${String(message)}\n`); + +if (isMainModule()) { + try { + await main(process.argv.slice(2)); + } catch (error) { + writeStderr(error?.message ?? error); + process.exit(1); + } +} + +function isMainModule() { + const entrypoint = process.argv[1]; + + return ( + !!entrypoint && path.resolve(entrypoint) === fileURLToPath(import.meta.url) + ); +} + +async function main(args) { + if (args[0] === 'validate') { + const [, rawPath, finalPath] = args; + + if (!rawPath || !finalPath) { + throw new Error('Usage: release-notes.mjs validate '); + } + + const result = await validateAiReleaseNotesFiles(rawPath, finalPath); + + if (!result.valid) { + for (const error of result.errors) { + writeStderr(`::warning::${error}`); + } + + await rm(finalPath, { force: true }); + return; + } + + writeStdout('AI release notes passed validation.'); + return; + } + + if (args[0] === 'add-package-changelogs') { + const [, releaseNotesPath] = args; + + if (!releaseNotesPath) { + throw new Error( + 'Usage: release-notes.mjs add-package-changelogs ' + ); + } + + const publishedPackages = parsePublishedPackages( + process.env.PUBLISHED_PACKAGES ?? + process.env.PUBLISHED_PACKAGES_JSON ?? + '' + ); + const workspacePackages = await getWorkspacePackages(); + const content = await readFile(releaseNotesPath, 'utf8'); + const updatedContent = addPackageChangelogLinks(content, { + commitRef: process.env.GITHUB_SHA || 'main', + publishedPackages, + workspacePackages, + }); + + await writeFile(releaseNotesPath, updatedContent); + + writeStdout(`Added package changelog links to ${releaseNotesPath}.`); + return; + } + + const publishedPackages = parsePublishedPackages( + process.env.PUBLISHED_PACKAGES ?? process.env.PUBLISHED_PACKAGES_JSON ?? '' + ); + const version = getGlobalReleaseVersion(publishedPackages); + + if (!version) { + throw new Error('No published package version found.'); + } + + const workspacePackages = await getWorkspacePackages(); + const fullChangelog = await getFullChangelog({ + publishedPackages, + version, + }); + const body = await generateRawReleaseNotes({ + fullChangelog, + publishedPackages, + workspacePackages, + }); + const rawFile = path.join(repoRoot, `.release-notes-raw-${version}.md`); + + await writeFile(rawFile, body); + await setOutput('version', version); + await setOutput('raw_changelog_path', rawFile); + + writeStdout(`Wrote raw release notes to ${rawFile}`); +} + +export function parsePublishedPackages(publishedPackagesJson) { + try { + const publishedPackages = JSON.parse(publishedPackagesJson || '[]'); + + return Array.isArray(publishedPackages) ? publishedPackages : []; + } catch { + return []; + } +} + +export function getGlobalReleaseVersion(publishedPackages) { + return publishedPackages + .map((publishedPackage) => publishedPackage?.version) + .filter((version) => typeof version === 'string') + .filter((version) => semverPattern.test(version)) + .sort(compareVersionsDesc)[0]; +} + +export async function getFullChangelog({ + globalReleaseTags, + publishedPackages, + releaseIndexFile = releaseIndexPath, + version, +}) { + const currentTag = `v${version}`; + const previousGlobalVersion = getPreviousGlobalReleaseVersion( + version, + globalReleaseTags + ); + const previousEntry = getPreviousReleaseIndexEntry( + await readReleaseIndex(releaseIndexFile), + version + ); + + if ( + previousGlobalVersion && + (!previousEntry?.version || + compareVersionsDesc(previousGlobalVersion, previousEntry.version) <= 0) + ) { + const previousGlobalTag = `v${previousGlobalVersion}`; + + return { + label: `${previousGlobalTag}...${currentTag}`, + url: compareUrl(previousGlobalTag, currentTag), + }; + } + + const currentPackageTag = getPreferredPackageTag(publishedPackages, version); + + if (!currentPackageTag) return null; + + if (!previousEntry?.packageTag || !previousEntry?.tag) return null; + + return { + label: `${previousEntry.tag}...${currentTag}`, + url: compareUrl(previousEntry.packageTag, currentPackageTag), + }; +} + +export function getPackageChangelogUrls({ + commitRef = 'main', + publishedPackages, + repoRootDirectory = repoRoot, + workspacePackages, +}) { + const changelogUrls = new Map(); + + for (const publishedPackage of publishedPackages) { + if (typeof publishedPackage?.name !== 'string') continue; + + const workspacePackage = workspacePackages.get(publishedPackage.name); + + if (!workspacePackage?.directory) continue; + + const packageDirectory = normalizePath( + path.relative(repoRootDirectory, workspacePackage.directory) + ); + + if (!packageDirectory || packageDirectory.startsWith('..')) continue; + + changelogUrls.set( + publishedPackage.name, + `https://github.com/${repo}/blob/${commitRef}/${packageDirectory}/CHANGELOG.md` + ); + } + + return changelogUrls; +} + +export async function getWorkspacePackages(roots = packageRoots) { + const workspacePackages = new Map(); + + for (const root of roots) { + let entries; + + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') continue; + throw error; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const directory = path.join(root, entry.name); + const packageJson = await readPackageJson(directory); + + if (packageJson?.name) { + workspacePackages.set(packageJson.name, { + directory, + packageJson, + }); + } + } + } + + return workspacePackages; +} + +export async function generateRawReleaseNotes({ + fullChangelog, + publishedPackages, + workspacePackages, +}) { + const lines = []; + const contributors = new Set(); + const packages = publishedPackages + .filter( + (publishedPackage) => + typeof publishedPackage?.name === 'string' && + typeof publishedPackage?.version === 'string' + ) + .sort( + (a, b) => + compareVersionsDesc(a.version, b.version) || + a.name.localeCompare(b.name) + ); + + for (const publishedPackage of packages) { + const workspacePackage = workspacePackages.get(publishedPackage.name); + const changelog = workspacePackage + ? await readOptionalFile( + path.join(workspacePackage.directory, 'CHANGELOG.md') + ) + : null; + const releaseChanges = changelog + ? extractReleaseChanges(changelog, publishedPackage.version) + : null; + + lines.push(`## \`${publishedPackage.name}\``); + lines.push(''); + + if (releaseChanges) { + lines.push(releaseChanges.body); + collectContributors(contributors, releaseChanges.body); + } else { + lines.push( + `Published \`${publishedPackage.name}@${publishedPackage.version}\`.` + ); + } + + lines.push(''); + } + + if (contributors.size > 0) { + lines.push('## Contributors'); + lines.push(''); + lines.push('Thanks to everyone who contributed to this release:'); + lines.push(''); + lines.push( + [...contributors] + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + .map((contributor) => `@${contributor}`) + .join(', ') + ); + lines.push(''); + } + + if (fullChangelog) { + lines.push( + `Full changelog: [\`${fullChangelog.label}\`](${fullChangelog.url})` + ); + lines.push(''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +export function addPackageChangelogLinks( + content, + { + commitRef = 'main', + publishedPackages, + repoRootDirectory, + workspacePackages, + } +) { + const changelogUrls = getPackageChangelogUrls({ + commitRef, + publishedPackages, + repoRootDirectory, + workspacePackages, + }); + let output = ''; + let cursor = 0; + + for (const match of content.matchAll(packageHeadingPattern)) { + const headingStart = match.index; + + if (headingStart < cursor) continue; + + const heading = match[0]; + const packageName = packageNameFromHeadingPattern.exec(heading)?.[1]; + const bodyStart = headingStart + heading.length; + const nextHeadingMatch = markdownHeadingBoundaryPattern.exec( + content.slice(bodyStart) + ); + const sectionEnd = + nextHeadingMatch?.index === undefined + ? content.length + : bodyStart + nextHeadingMatch.index; + const changelogUrl = packageName ? changelogUrls.get(packageName) : null; + let sectionBody = content.slice(bodyStart, sectionEnd); + + output += content.slice(cursor, bodyStart); + + if (changelogUrl) { + sectionBody = appendPackageChangelogLink(sectionBody, changelogUrl); + } + + output += sectionBody; + cursor = sectionEnd; + } + + output += content.slice(cursor); + + return output.endsWith('\n') ? output : `${output}\n`; +} + +export function extractReleaseChanges(changelog, version) { + const versionSection = extractVersionSection(changelog, version); + + if (!versionSection) return null; + + const sections = extractReleaseTypeSections(versionSection); + + if (sections.length === 0) return null; + + return { + body: sections + .map( + (section) => + `### ${releaseTypeLabels[section.type]} Changes\n\n${section.body}` + ) + .join('\n\n'), + type: sections[0].type, + }; +} + +export function validateAiReleaseNotes(raw, final) { + const errors = []; + const rawPackageHeadings = matchAll(raw, packageHeadingPattern); + const finalPackageHeadings = matchAll(final, packageHeadingPattern); + const rawChangeHeadings = matchAll(raw, changeHeadingPattern); + const finalChangeHeadings = matchAll(final, changeHeadingPattern); + const rawChangelogLinks = matchAll(raw, changelogLinkPattern); + const finalChangelogLinks = matchAll(final, changelogLinkPattern); + const rawFullChangelogLinks = matchAll(raw, fullChangelogLinkPattern); + const finalFullChangelogLinks = matchAll(final, fullChangelogLinkPattern); + const rawPullRequestLinks = matchAll(raw, pullRequestLinkPattern); + const finalPullRequestLinks = matchAll(final, pullRequestLinkPattern); + const rawCommitLinks = matchAll(raw, commitLinkPattern); + const finalCommitLinks = matchAll(final, commitLinkPattern); + const rawContributorsSection = getContributorsSection(raw); + const finalContributorsSection = getContributorsSection(final); + const didDropContributorsSection = + rawContributorsSection.trim().length > 0 && + finalContributorsSection.trim().length === 0; + const missingSectionContributors = didDropContributorsSection + ? [] + : extractContributorSectionHandles(rawContributorsSection).filter( + (handle) => !hasContributorHandle(finalContributorsSection, handle) + ); + const missingContributors = extractContributorHandles(raw).filter( + (handle) => !hasContributorHandle(final, handle) + ); + + if (final.trim().length === 0) { + errors.push('AI output is empty.'); + } + + if (!sameList(rawPackageHeadings, finalPackageHeadings)) { + errors.push('AI output changed package headings.'); + } + + if (!sameList(rawChangeHeadings, finalChangeHeadings)) { + errors.push('AI output changed change-type headings.'); + } + + if (!sameList(rawChangelogLinks, finalChangelogLinks)) { + errors.push('AI output changed package changelog links.'); + } + + if (!sameList(rawFullChangelogLinks, finalFullChangelogLinks)) { + errors.push('AI output changed full changelog links.'); + } + + if (!sameList(rawPullRequestLinks, finalPullRequestLinks)) { + errors.push('AI output changed PR links.'); + } + + if (!sameList(rawCommitLinks, finalCommitLinks)) { + errors.push('AI output changed commit links.'); + } + + if ( + countMatches(final, bulletEntryPattern) !== + countMatches(raw, bulletEntryPattern) + ) { + errors.push('AI output changed release entry count.'); + } + + if ( + countMatches(final, migrationPattern) < countMatches(raw, migrationPattern) + ) { + errors.push('AI output dropped migration notes.'); + } + + if (didDropContributorsSection) { + errors.push('AI output dropped Contributors section.'); + } + + if (missingContributors.length > 0 || missingSectionContributors.length > 0) { + errors.push('AI output dropped contributors.'); + } + + return { + errors, + valid: errors.length === 0, + }; +} + +async function validateAiReleaseNotesFiles(rawPath, finalPath) { + const [raw, final] = await Promise.all([ + readFile(rawPath, 'utf8'), + readFile(finalPath, 'utf8'), + ]); + + return validateAiReleaseNotes(raw, final); +} + +function extractVersionSection(changelog, version) { + const versionHeadingPattern = new RegExp( + `^##\\s+${escapeRegExp(version)}(?:\\s|$).*`, + 'm' + ); + const match = versionHeadingPattern.exec(changelog); + + if (!match) return null; + + const bodyStart = match.index + match[0].length; + const rest = changelog.slice(bodyStart); + const nextMatch = rest.match(nextVersionHeadingPattern); + const bodyEnd = + nextMatch?.index === undefined + ? changelog.length + : bodyStart + nextMatch.index; + + return changelog.slice(bodyStart, bodyEnd); +} + +function extractReleaseTypeSections(content) { + const matches = [...content.matchAll(releaseTypeHeadingPattern)]; + + return matches + .map((match, index) => { + const bodyStart = match.index + match[0].length; + const nextMatch = matches[index + 1]; + const bodyEnd = nextMatch?.index ?? content.length; + const body = content.slice(bodyStart, bodyEnd).trim(); + + if (!body) return null; + + return { + body, + type: match[1].toLowerCase(), + }; + }) + .filter(Boolean) + .sort((a, b) => compareReleaseTypes(a.type, b.type)); +} + +function appendPackageChangelogLink(sectionBody, changelogUrl) { + const fullChangelogMatch = fullChangelogFooterPattern.exec(sectionBody); + + if (fullChangelogMatch) { + const body = sectionBody.slice(0, fullChangelogMatch.index); + const footer = sectionBody.slice(fullChangelogMatch.index); + + return `${appendPackageChangelogLink(body, changelogUrl)}${footer}`; + } + + return `${sectionBody + .replace(packageChangelogFooterPattern, '') + .trimEnd()}\n\nFor detailed changes, see [\`CHANGELOG\`](${changelogUrl})`; +} + +async function readPackageJson(directory) { + const content = await readOptionalFile(path.join(directory, 'package.json')); + + return content ? JSON.parse(content) : null; +} + +async function readReleaseIndex(filePath) { + const content = await readOptionalFile(filePath); + + if (!content) return []; + + try { + const releases = JSON.parse(content); + + return Array.isArray(releases) ? releases : []; + } catch { + return []; + } +} + +function getPreviousReleaseIndexEntry(releases, version) { + return releases + .map((release) => ({ + ...release, + version: tagToVersion(release?.tag), + })) + .filter( + (release) => release.version && isBeforeVersion(release.version, version) + ) + .sort((a, b) => compareVersionsDesc(a.version, b.version))[0]; +} + +function getPreferredPackageTag(publishedPackages, version) { + const preferredPackage = getPreferredPublishedPackage( + publishedPackages, + version + ); + + return preferredPackage + ? `${preferredPackage.name}@${preferredPackage.version}` + : null; +} + +function getPreferredPublishedPackage(publishedPackages, version) { + const matchingPackages = publishedPackages.filter( + (publishedPackage) => + typeof publishedPackage?.name === 'string' && + publishedPackage.version === version + ); + + return ( + matchingPackages.find( + (publishedPackage) => publishedPackage.name === 'kitcn' + ) ?? matchingPackages[0] + ); +} + +function getPreviousGlobalReleaseVersion(version, globalReleaseTags) { + const previousVersion = (globalReleaseTags ?? getGitGlobalReleaseTags()) + .map(tagToVersion) + .filter(Boolean) + .filter((tagVersion) => isBeforeVersion(tagVersion, version)) + .sort(compareVersionsDesc)[0]; + + return previousVersion ?? null; +} + +function getGitGlobalReleaseTags() { + try { + return execFileSync('git', ['tag', '--list', 'v*'], { + cwd: repoRoot, + encoding: 'utf8', + }) + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag) => semverPattern.test(tagToVersion(tag) ?? '')); + } catch { + return []; + } +} + +function compareUrl(fromTag, toTag) { + return `https://github.com/${repo}/compare/${encodeURIComponent(fromTag)}...${encodeURIComponent(toTag)}`; +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function tagToVersion(tag) { + if (typeof tag !== 'string' || !tag.startsWith('v')) return null; + + const version = tag.slice(1); + + return semverPattern.test(version) ? version : null; +} + +function isBeforeVersion(version, referenceVersion) { + return compareVersionsDesc(version, referenceVersion) > 0; +} + +async function readOptionalFile(filePath) { + try { + return await readFile(filePath, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function collectContributors(contributors, content) { + for (const match of content.matchAll(contributorPattern)) { + contributors.add(match[1]); + } +} + +function collectContributorSectionHandles(contributors, content) { + for (const match of content.matchAll(contributorHandlePattern)) { + contributors.add(match[1]); + } +} + +function extractContributorHandles(content) { + const contributors = new Set(); + + collectContributors(contributors, content); + collectContributorSectionHandles( + contributors, + getContributorsSection(content) + ); + + return [...contributors]; +} + +function extractContributorSectionHandles(content) { + const contributors = new Set(); + + collectContributorSectionHandles(contributors, content); + + return [...contributors]; +} + +function getContributorsSection(content) { + const match = contributorsHeadingPattern.exec(content); + + if (!match) return ''; + + const sectionStart = match.index + match[0].length; + const rest = content.slice(sectionStart); + const nextHeading = headingBoundaryPattern.exec(rest); + + return rest.slice(0, nextHeading?.index ?? rest.length); +} + +function hasContributorHandle(content, handle) { + const escapedHandle = escapeRegExp(handle); + + return new RegExp( + `(?:^|[^A-Za-z0-9_/-])@${escapedHandle}\\b|github\\.com/${escapedHandle}\\b`, + 'm' + ).test(content); +} + +function compareVersionsDesc(a, b) { + const parsedA = parseVersion(a); + const parsedB = parseVersion(b); + + for (let index = 0; index < 3; index++) { + const delta = parsedB.parts[index] - parsedA.parts[index]; + + if (delta !== 0) return delta; + } + + if (parsedA.prerelease && !parsedB.prerelease) return 1; + if (!parsedA.prerelease && parsedB.prerelease) return -1; + + return parsedB.prerelease.localeCompare(parsedA.prerelease); +} + +function parseVersion(version) { + const [core, prerelease = ''] = version.split('-'); + + return { + parts: core.split('.').map(Number), + prerelease, + }; +} + +function compareReleaseTypes(a, b) { + return releaseTypes.indexOf(a) - releaseTypes.indexOf(b); +} + +function matchAll(content, pattern) { + return [...content.matchAll(pattern)].map((match) => match[0]); +} + +function countMatches(content, pattern) { + return matchAll(content, pattern).length; +} + +function sameList(left, right) { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function setOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) return; + + await appendFile(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} diff --git a/tooling/scripts/release-notes.test.ts b/tooling/scripts/release-notes.test.ts new file mode 100644 index 00000000..43065444 --- /dev/null +++ b/tooling/scripts/release-notes.test.ts @@ -0,0 +1,410 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + addPackageChangelogLinks, + extractReleaseChanges, + generateRawReleaseNotes, + getFullChangelog, + getGlobalReleaseVersion, + getPackageChangelogUrls, + getWorkspacePackages, + parsePublishedPackages, + validateAiReleaseNotes, +} from './release-notes.mjs'; + +test('selects the highest published package version for the global release', () => { + assert.equal( + getGlobalReleaseVersion([ + { name: '@kitcn/list', version: '53.0.2' }, + { name: 'depset', version: '0.1.2' }, + { name: '@kitcn/table', version: '53.0.1' }, + ]), + '53.0.2' + ); +}); + +test('parses changesets published package output safely', () => { + assert.deepEqual( + parsePublishedPackages('[{"name":"@kitcn/list","version":"53.0.2"}]'), + [{ name: '@kitcn/list', version: '53.0.2' }] + ); + assert.deepEqual(parsePublishedPackages('not json'), []); +}); + +test('extracts exact package changelog sections and preserves change types', () => { + const changelog = `# @kitcn/table + +## 54.0.0 + +### Major Changes + +- Remove \`oldApi\`. + +### Minor Changes + +- Add \`newApi\`. + +### Patch Changes + +- Fix docs. + +## 53.0.0 + +### Major Changes + +- Older major. +`; + + assert.deepEqual(extractReleaseChanges(changelog, '54.0.0'), { + body: '### Major Changes\n\n- Remove `oldApi`.\n\n### Minor Changes\n\n- Add `newApi`.\n\n### Patch Changes\n\n- Fix docs.', + type: 'major', + }); +}); + +test('generates raw release notes from published package changelogs', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'kitcn-release-notes-')); + const packageRoot = path.join(root, 'packages'); + const packageDirectory = path.join(packageRoot, 'list'); + + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + path.join(packageDirectory, 'package.json'), + JSON.stringify({ name: '@kitcn/list', version: '53.0.2' }) + ); + await writeFile( + path.join(packageDirectory, 'CHANGELOG.md'), + `# @kitcn/list + +## 53.0.2 + +### Patch Changes + +- [#4954](https://github.com/udecode/kitcn/pull/4954) by [@dylans](https://github.com/dylans) – Fix ordered list numbering. +` + ); + + const body = await generateRawReleaseNotes({ + fullChangelog: { + label: 'v53.0.1...v53.0.2', + url: 'https://github.com/udecode/kitcn/compare/v53.0.1...v53.0.2', + }, + publishedPackages: [{ name: '@kitcn/list', version: '53.0.2' }], + workspacePackages: await getWorkspacePackages([packageRoot]), + }); + + assert.match(body, /## `@kitcn\/list`/); + assert.match(body, /### Patch Changes/); + assert.match(body, /Fix ordered list numbering/); + assert.match(body, /## Contributors/); + assert.match(body, /@dylans/); + assert.match( + body, + /Full changelog: \[`v53\.0\.1\.\.\.v53\.0\.2`\]\(https:\/\/github\.com\/udecode\/kitcn\/compare\/v53\.0\.1\.\.\.v53\.0\.2\)/ + ); + assert.doesNotMatch(body, /For detailed changes/); +}); + +test('builds changelog URLs for every published workspace package', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'kitcn-detailed-changes-')); + const packageRoot = path.join(root, 'packages'); + const listDirectory = path.join(packageRoot, 'list'); + const kitcnDirectory = path.join(packageRoot, 'kitcn'); + + await mkdir(listDirectory, { recursive: true }); + await mkdir(kitcnDirectory, { recursive: true }); + await writeFile( + path.join(listDirectory, 'package.json'), + JSON.stringify({ name: '@kitcn/list', version: '53.0.5' }) + ); + await writeFile( + path.join(kitcnDirectory, 'package.json'), + JSON.stringify({ name: 'kitcn', version: '53.0.5' }) + ); + + assert.deepEqual( + getPackageChangelogUrls({ + commitRef: 'abc123', + publishedPackages: [ + { name: '@kitcn/list', version: '53.0.5' }, + { name: 'kitcn', version: '53.0.5' }, + ], + repoRootDirectory: root, + workspacePackages: await getWorkspacePackages([packageRoot]), + }), + new Map([ + [ + '@kitcn/list', + 'https://github.com/udecode/kitcn/blob/abc123/packages/list/CHANGELOG.md', + ], + [ + 'kitcn', + 'https://github.com/udecode/kitcn/blob/abc123/packages/kitcn/CHANGELOG.md', + ], + ]) + ); +}); + +test('adds package changelog links only during AI finalization', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'kitcn-package-links-')); + const packageRoot = path.join(root, 'packages'); + const listDirectory = path.join(packageRoot, 'list'); + const kitcnDirectory = path.join(packageRoot, 'kitcn'); + + await mkdir(listDirectory, { recursive: true }); + await mkdir(kitcnDirectory, { recursive: true }); + await writeFile( + path.join(listDirectory, 'package.json'), + JSON.stringify({ name: '@kitcn/list', version: '53.0.5' }) + ); + await writeFile( + path.join(kitcnDirectory, 'package.json'), + JSON.stringify({ name: 'kitcn', version: '53.0.5' }) + ); + + const content = `## \`@kitcn/list\` + +### Patch Changes + +- Fix lists. + +## \`kitcn\` + +### Patch Changes + +- Update wrapper. + +## Contributors + +Thanks to everyone who contributed to this release: + +@alice + +Full changelog: [\`v53.0.4...v53.0.5\`](https://github.com/udecode/kitcn/compare/v53.0.4...v53.0.5) +`; + + const linkedContent = addPackageChangelogLinks(content, { + commitRef: 'abc123', + publishedPackages: [ + { name: '@kitcn/list', version: '53.0.5' }, + { name: 'kitcn', version: '53.0.5' }, + ], + repoRootDirectory: root, + workspacePackages: await getWorkspacePackages([packageRoot]), + }); + + assert.match( + linkedContent, + /## `@kitcn\/list`[\s\S]*For detailed changes, see \[`CHANGELOG`\]\(https:\/\/github\.com\/udecode\/kitcn\/blob\/abc123\/packages\/list\/CHANGELOG\.md\)[\s\S]*## `kitcn`/ + ); + assert.match( + linkedContent, + /## `kitcn`[\s\S]*For detailed changes, see \[`CHANGELOG`\]\(https:\/\/github\.com\/udecode\/kitcn\/blob\/abc123\/packages\/kitcn\/CHANGELOG\.md\)[\s\S]*## Contributors/ + ); +}); + +test('uses release index package tags for full changelog fallback', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'kitcn-release-index-')); + const releaseIndexFile = path.join(root, 'release-index.json'); + + await writeFile( + releaseIndexFile, + JSON.stringify([ + { + packageTag: '@kitcn/ai@98.0.0', + tag: 'v98.0.0', + }, + ]) + ); + + assert.deepEqual( + await getFullChangelog({ + globalReleaseTags: ['v1.0.0'], + publishedPackages: [{ name: 'kitcn', version: '99.0.0' }], + releaseIndexFile, + version: '99.0.0', + }), + { + label: 'v98.0.0...v99.0.0', + url: 'https://github.com/udecode/kitcn/compare/%40kitcn%2Fai%4098.0.0...kitcn%4099.0.0', + } + ); +}); + +test('uses previous global tag when it matches the release index version', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'kitcn-release-index-')); + const releaseIndexFile = path.join(root, 'release-index.json'); + + await writeFile( + releaseIndexFile, + JSON.stringify([ + { + packageTag: 'kitcn@99.0.0', + tag: 'v99.0.0', + }, + ]) + ); + + assert.deepEqual( + await getFullChangelog({ + globalReleaseTags: ['v99.0.0'], + publishedPackages: [{ name: 'kitcn', version: '99.0.1' }], + releaseIndexFile, + version: '99.0.1', + }), + { + label: 'v99.0.0...v99.0.1', + url: 'https://github.com/udecode/kitcn/compare/v99.0.0...v99.0.1', + } + ); +}); + +test('validates AI release notes preserve deterministic structure', () => { + const raw = `## \`@kitcn/table\` + +### Major Changes + +- Removed \`oldApi\` ([#5000](https://github.com/udecode/kitcn/pull/5000)) +> **Migration:** Use \`newApi\`. + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) + +## Contributors + +Thanks to everyone who contributed to this release: + +@alice +`; + const good = raw.replace('Removed `oldApi`', 'Removed `oldApi` from tables'); + const bad = raw + .replace('## `@kitcn/table`', '## `@kitcn/core`') + .replace('[#5000](https://github.com/udecode/kitcn/pull/5000)', '') + .replace('> **Migration:** Use `newApi`.\n', '') + .replace( + '\n## Contributors\n\nThanks to everyone who contributed to this release:\n\n@alice\n', + '' + ); + + assert.deepEqual(validateAiReleaseNotes(raw, good), { + errors: [], + valid: true, + }); + assert.equal(validateAiReleaseNotes(raw, bad).valid, false); + assert.match( + validateAiReleaseNotes(raw, bad).errors.join('\n'), + /AI output dropped Contributors section\./ + ); +}); + +test('validates AI release notes preserve the Contributors section itself', () => { + const raw = `## \`@kitcn/table\` + +### Patch Changes + +- [#5000](https://github.com/udecode/kitcn/pull/5000) by [@alice](https://github.com/alice) – Fix table. + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) + +## Contributors + +Thanks to everyone who contributed to this release: + +@alice +`; + const withoutContributors = raw.replace( + '\n## Contributors\n\nThanks to everyone who contributed to this release:\n\n@alice\n', + '\n' + ); + + assert.deepEqual(validateAiReleaseNotes(raw, withoutContributors), { + errors: ['AI output dropped Contributors section.'], + valid: false, + }); +}); + +test('validates AI release notes preserve comma-separated contributor handles', () => { + const raw = `## \`@kitcn/table\` + +### Patch Changes + +- [#5000](https://github.com/udecode/kitcn/pull/5000) by [@alice](https://github.com/alice) – Fix table. +- [#5001](https://github.com/udecode/kitcn/pull/5001) by [@bob](https://github.com/bob) – Fix more. + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) + +## Contributors + +Thanks to everyone who contributed to this release: + +@alice, @bob +`; + const missingBob = raw.replace('@alice, @bob', '@alice'); + + assert.deepEqual(validateAiReleaseNotes(raw, missingBob), { + errors: ['AI output dropped contributors.'], + valid: false, + }); +}); + +test('validates AI release notes preserve exact PR links', () => { + const raw = `## \`@kitcn/table\` + +### Patch Changes + +- Fix table ([#5000](https://github.com/udecode/kitcn/pull/5000)) + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) +`; + const wrongPullRequest = raw.replace( + '[#5000](https://github.com/udecode/kitcn/pull/5000)', + '[#5999](https://github.com/udecode/kitcn/pull/5999)' + ); + + assert.deepEqual(validateAiReleaseNotes(raw, wrongPullRequest), { + errors: ['AI output changed PR links.'], + valid: false, + }); +}); + +test('validates AI release notes preserve exact commit links', () => { + const raw = `## \`@kitcn/resend\` + +### Patch Changes + +- Updated \`svix\`. ([\`ce9ec87\`](https://github.com/udecode/kitcn/commit/ce9ec871c9547a8a3c78ded13a93049ef9fe049c)) + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/resend/CHANGELOG.md) +`; + const withoutCommit = raw.replace( + '([`ce9ec87`](https://github.com/udecode/kitcn/commit/ce9ec871c9547a8a3c78ded13a93049ef9fe049c))', + '' + ); + + assert.deepEqual(validateAiReleaseNotes(raw, withoutCommit), { + errors: ['AI output changed commit links.'], + valid: false, + }); +}); + +test('validates AI release notes reject added release entries', () => { + const raw = `## \`@kitcn/table\` + +### Patch Changes + +- Fix table ([#5000](https://github.com/udecode/kitcn/pull/5000)) + +For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) +`; + const withAddedEntry = raw.replace( + 'For detailed changes', + '- Invent unrelated change\n\nFor detailed changes' + ); + + assert.deepEqual(validateAiReleaseNotes(raw, withAddedEntry), { + errors: ['AI output changed release entry count.'], + valid: false, + }); +}); diff --git a/tooling/scripts/release-workflow.test.ts b/tooling/scripts/release-workflow.test.ts new file mode 100644 index 00000000..ca7135fb --- /dev/null +++ b/tooling/scripts/release-workflow.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const releaseWorkflowPath = new URL( + '../../.github/workflows/release.yml', + import.meta.url +); +const autoReleaseWorkflowPath = new URL( + '../../.github/workflows/changeset-auto-release.yml', + import.meta.url +); +const packageJsonPath = new URL('../../package.json', import.meta.url); + +test('release workflow uses the copied GitHub Release path', async () => { + const workflow = await readFile(releaseWorkflowPath, 'utf8'); + + assert.match(workflow, /branches:\s*\[main\]/); + assert.match(workflow, /github\.repository == 'udecode\/kitcn'/); + assert.match( + workflow, + /!contains\(github\.event\.head_commit\.message, '\[skip release\]'\)/ + ); + assert.match(workflow, /actions\/create-github-app-token/); + assert.match(workflow, /tooling\/scripts\/auto-release-pr\.mjs/); + assert.match(workflow, /tooling\/scripts\/prepare-release-changesets\.mjs/); + assert.match(workflow, /createGithubReleases:\s*false/); + assert.match(workflow, /version:\s*bun run ci:version/); + assert.match(workflow, /publish:\s*bun run ci:release/); + assert.match(workflow, /node tooling\/scripts\/published-package-tags\.mjs/); + assert.match(workflow, /refs\/tags\/\$\{tag\}:refs\/tags\/\$\{tag\}/); + assert.match(workflow, /node tooling\/scripts\/release-notes\.mjs/); + assert.match(workflow, /anthropics\/claude-code-action\/base-action/); + assert.match( + workflow, + /node tooling\/scripts\/release-notes\.mjs add-package-changelogs "\$\{RAW_PATH\}\.final"/ + ); + assert.match(workflow, /touch "\$\{RAW_PATH\}\.final\.validated"/); + assert.match( + workflow, + /-f "\$\{RAW_PATH\}\.final" && -f "\$\{RAW_PATH\}\.final\.validated"/ + ); + assert.match(workflow, /Ignoring unvalidated AI-rewritten release notes/); + assert.match(workflow, /gh release (create|edit)/); + assert.match( + workflow, + /gh pr merge "\$RELEASE_PR" --squash --delete-branch --admin/ + ); + assert.doesNotMatch(workflow, /sync-version-package-releases/); + assert.doesNotMatch(workflow, /sync-release-artifacts/); + assert.doesNotMatch(workflow, /templates\/release-sync-failure/); + assert.doesNotMatch(workflow, /branches:\s*[\s\S]*-\s*next/); +}); + +test('auto-release checkbox workflow imports the canonical helper', async () => { + const workflow = await readFile(autoReleaseWorkflowPath, 'utf8'); + + assert.match(workflow, /github\.repository == 'udecode\/kitcn'/); + assert.match(workflow, /tooling\/scripts\/auto-release-pr\.mjs/); +}); + +test('package scripts expose CI version and release commands only', async () => { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + + assert.equal( + packageJson.scripts['ci:version'], + 'bun changeset version && bun install --no-frozen-lockfile' + ); + assert.equal(packageJson.scripts['ci:release'], 'bun run release'); + assert.equal(packageJson.scripts['release:releases'], undefined); +}); From ce33d3c104758b0a3910d85819dba15ff04ba044 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Mon, 15 Jun 2026 20:29:27 +0200 Subject: [PATCH 2/3] chore trim release note footer --- .github/prompts/release-notes-rewrite.md | 7 +- tooling/scripts/release-notes.mjs | 254 ++--------------------- tooling/scripts/release-notes.test.ts | 113 ++-------- 3 files changed, 42 insertions(+), 332 deletions(-) diff --git a/.github/prompts/release-notes-rewrite.md b/.github/prompts/release-notes-rewrite.md index 9d7ec912..cd91ffca 100644 --- a/.github/prompts/release-notes-rewrite.md +++ b/.github/prompts/release-notes-rewrite.md @@ -21,8 +21,7 @@ internal implementation. - Keep every entry as one clear sentence unless the raw entry already contains a migration block or code example. - Keep code identifiers in backticks. -- Keep PR links, author links, package names, and the final `Full changelog` - link. +- Keep PR links, author links, and package names. - Keep migration notes, especially under `### Major Changes`. - Do not add `CHANGELOG` links. The workflow injects per-package links after validation. @@ -35,8 +34,8 @@ internal implementation. - Do not modify `## \`package-name\`` headings or their order. - Do not modify `### Major Changes`, `### Minor Changes`, or `### Patch Changes` headings or their order. -- Do not modify `Full changelog: [\`...\`](...)` links. -- Do not remove `## Contributors` when it exists. +- Do not add standalone `## Contributors` sections. +- Do not add standalone `Full changelog: [\`...\`](...)` links. - Preserve all PR links in the raw changelog. - Preserve all migration-note blocks. diff --git a/tooling/scripts/release-notes.mjs b/tooling/scripts/release-notes.mjs index bf2f3d79..1b1a8094 100644 --- a/tooling/scripts/release-notes.mjs +++ b/tooling/scripts/release-notes.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { execFileSync } from 'node:child_process'; import { appendFile, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,7 +8,6 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..', '..'); const repo = 'udecode/kitcn'; const packageRoots = [path.join(repoRoot, 'packages')]; -const releaseIndexPath = path.join(repoRoot, '.tmp/release-index.json'); const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const releaseTypeHeadingPattern = /^###\s+(Major|Minor|Patch) Changes[^\S\r\n]*$/gm; @@ -19,9 +17,11 @@ const packageNameFromHeadingPattern = /^## `([^`]+)`/; const changeHeadingPattern = /^###\s+(Major|Minor|Patch) Changes[^\S\r\n]*$/gm; const changelogLinkPattern = /For detailed changes, see \[`CHANGELOG`\]\([^)]+\)/g; -const fullChangelogLinkPattern = /Full changelog: \[`[^`]+`\]\([^)]+\)/g; const fullChangelogFooterPattern = /\n\nFull changelog: \[`[^`]+`\]\([^)]+\)\s*$/; +const contributorsFooterPattern = /\n## Contributors\b[\s\S]*$/; +const releaseFooterPattern = + /(?:^|\n)(?:## Contributors\b|Full changelog: \[`[^`]+`\]\([^)]+\))/; const markdownHeadingBoundaryPattern = /\n##\s+/; const packageChangelogFooterPattern = /\n*For detailed changes, see \[`CHANGELOG`\]\([^)]+\)\s*$/g; @@ -32,9 +32,6 @@ const bulletEntryPattern = /^-\s+/gm; const migrationPattern = /\bMigration\b/g; const contributorPattern = /by \[@([A-Za-z0-9-]+)\]\(https:\/\/github\.com\/[^)]+\)/g; -const contributorHandlePattern = /(?:^|[\s,])@([A-Za-z0-9-]+)(?=$|[\s,.;)])/gm; -const contributorsHeadingPattern = /^## Contributors[^\n]*\n/m; -const headingBoundaryPattern = /\n##\s+/; const releaseTypes = ['major', 'minor', 'patch']; const releaseTypeLabels = { major: 'Major', @@ -122,12 +119,7 @@ async function main(args) { } const workspacePackages = await getWorkspacePackages(); - const fullChangelog = await getFullChangelog({ - publishedPackages, - version, - }); const body = await generateRawReleaseNotes({ - fullChangelog, publishedPackages, workspacePackages, }); @@ -158,47 +150,6 @@ export function getGlobalReleaseVersion(publishedPackages) { .sort(compareVersionsDesc)[0]; } -export async function getFullChangelog({ - globalReleaseTags, - publishedPackages, - releaseIndexFile = releaseIndexPath, - version, -}) { - const currentTag = `v${version}`; - const previousGlobalVersion = getPreviousGlobalReleaseVersion( - version, - globalReleaseTags - ); - const previousEntry = getPreviousReleaseIndexEntry( - await readReleaseIndex(releaseIndexFile), - version - ); - - if ( - previousGlobalVersion && - (!previousEntry?.version || - compareVersionsDesc(previousGlobalVersion, previousEntry.version) <= 0) - ) { - const previousGlobalTag = `v${previousGlobalVersion}`; - - return { - label: `${previousGlobalTag}...${currentTag}`, - url: compareUrl(previousGlobalTag, currentTag), - }; - } - - const currentPackageTag = getPreferredPackageTag(publishedPackages, version); - - if (!currentPackageTag) return null; - - if (!previousEntry?.packageTag || !previousEntry?.tag) return null; - - return { - label: `${previousEntry.tag}...${currentTag}`, - url: compareUrl(previousEntry.packageTag, currentPackageTag), - }; -} - export function getPackageChangelogUrls({ commitRef = 'main', publishedPackages, @@ -261,12 +212,10 @@ export async function getWorkspacePackages(roots = packageRoots) { } export async function generateRawReleaseNotes({ - fullChangelog, publishedPackages, workspacePackages, }) { const lines = []; - const contributors = new Set(); const packages = publishedPackages .filter( (publishedPackage) => @@ -295,7 +244,6 @@ export async function generateRawReleaseNotes({ if (releaseChanges) { lines.push(releaseChanges.body); - collectContributors(contributors, releaseChanges.body); } else { lines.push( `Published \`${publishedPackage.name}@${publishedPackage.version}\`.` @@ -305,27 +253,6 @@ export async function generateRawReleaseNotes({ lines.push(''); } - if (contributors.size > 0) { - lines.push('## Contributors'); - lines.push(''); - lines.push('Thanks to everyone who contributed to this release:'); - lines.push(''); - lines.push( - [...contributors] - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) - .map((contributor) => `@${contributor}`) - .join(', ') - ); - lines.push(''); - } - - if (fullChangelog) { - lines.push( - `Full changelog: [\`${fullChangelog.label}\`](${fullChangelog.url})` - ); - lines.push(''); - } - return `${lines.join('\n').trimEnd()}\n`; } @@ -338,6 +265,7 @@ export function addPackageChangelogLinks( workspacePackages, } ) { + const contentWithoutReleaseFooter = stripReleaseFooter(content); const changelogUrls = getPackageChangelogUrls({ commitRef, publishedPackages, @@ -347,7 +275,9 @@ export function addPackageChangelogLinks( let output = ''; let cursor = 0; - for (const match of content.matchAll(packageHeadingPattern)) { + for (const match of contentWithoutReleaseFooter.matchAll( + packageHeadingPattern + )) { const headingStart = match.index; if (headingStart < cursor) continue; @@ -356,16 +286,16 @@ export function addPackageChangelogLinks( const packageName = packageNameFromHeadingPattern.exec(heading)?.[1]; const bodyStart = headingStart + heading.length; const nextHeadingMatch = markdownHeadingBoundaryPattern.exec( - content.slice(bodyStart) + contentWithoutReleaseFooter.slice(bodyStart) ); const sectionEnd = nextHeadingMatch?.index === undefined - ? content.length + ? contentWithoutReleaseFooter.length : bodyStart + nextHeadingMatch.index; const changelogUrl = packageName ? changelogUrls.get(packageName) : null; - let sectionBody = content.slice(bodyStart, sectionEnd); + let sectionBody = contentWithoutReleaseFooter.slice(bodyStart, sectionEnd); - output += content.slice(cursor, bodyStart); + output += contentWithoutReleaseFooter.slice(cursor, bodyStart); if (changelogUrl) { sectionBody = appendPackageChangelogLink(sectionBody, changelogUrl); @@ -375,7 +305,7 @@ export function addPackageChangelogLinks( cursor = sectionEnd; } - output += content.slice(cursor); + output += contentWithoutReleaseFooter.slice(cursor); return output.endsWith('\n') ? output : `${output}\n`; } @@ -408,22 +338,10 @@ export function validateAiReleaseNotes(raw, final) { const finalChangeHeadings = matchAll(final, changeHeadingPattern); const rawChangelogLinks = matchAll(raw, changelogLinkPattern); const finalChangelogLinks = matchAll(final, changelogLinkPattern); - const rawFullChangelogLinks = matchAll(raw, fullChangelogLinkPattern); - const finalFullChangelogLinks = matchAll(final, fullChangelogLinkPattern); const rawPullRequestLinks = matchAll(raw, pullRequestLinkPattern); const finalPullRequestLinks = matchAll(final, pullRequestLinkPattern); const rawCommitLinks = matchAll(raw, commitLinkPattern); const finalCommitLinks = matchAll(final, commitLinkPattern); - const rawContributorsSection = getContributorsSection(raw); - const finalContributorsSection = getContributorsSection(final); - const didDropContributorsSection = - rawContributorsSection.trim().length > 0 && - finalContributorsSection.trim().length === 0; - const missingSectionContributors = didDropContributorsSection - ? [] - : extractContributorSectionHandles(rawContributorsSection).filter( - (handle) => !hasContributorHandle(finalContributorsSection, handle) - ); const missingContributors = extractContributorHandles(raw).filter( (handle) => !hasContributorHandle(final, handle) ); @@ -444,10 +362,6 @@ export function validateAiReleaseNotes(raw, final) { errors.push('AI output changed package changelog links.'); } - if (!sameList(rawFullChangelogLinks, finalFullChangelogLinks)) { - errors.push('AI output changed full changelog links.'); - } - if (!sameList(rawPullRequestLinks, finalPullRequestLinks)) { errors.push('AI output changed PR links.'); } @@ -469,11 +383,11 @@ export function validateAiReleaseNotes(raw, final) { errors.push('AI output dropped migration notes.'); } - if (didDropContributorsSection) { - errors.push('AI output dropped Contributors section.'); + if (releaseFooterPattern.test(final)) { + errors.push('AI output added release footer.'); } - if (missingContributors.length > 0 || missingSectionContributors.length > 0) { + if (missingContributors.length > 0) { errors.push('AI output dropped contributors.'); } @@ -534,122 +448,28 @@ function extractReleaseTypeSections(content) { } function appendPackageChangelogLink(sectionBody, changelogUrl) { - const fullChangelogMatch = fullChangelogFooterPattern.exec(sectionBody); - - if (fullChangelogMatch) { - const body = sectionBody.slice(0, fullChangelogMatch.index); - const footer = sectionBody.slice(fullChangelogMatch.index); - - return `${appendPackageChangelogLink(body, changelogUrl)}${footer}`; - } - return `${sectionBody .replace(packageChangelogFooterPattern, '') .trimEnd()}\n\nFor detailed changes, see [\`CHANGELOG\`](${changelogUrl})`; } +function stripReleaseFooter(content) { + return content + .replace(contributorsFooterPattern, '') + .replace(fullChangelogFooterPattern, '') + .trimEnd(); +} + async function readPackageJson(directory) { const content = await readOptionalFile(path.join(directory, 'package.json')); return content ? JSON.parse(content) : null; } -async function readReleaseIndex(filePath) { - const content = await readOptionalFile(filePath); - - if (!content) return []; - - try { - const releases = JSON.parse(content); - - return Array.isArray(releases) ? releases : []; - } catch { - return []; - } -} - -function getPreviousReleaseIndexEntry(releases, version) { - return releases - .map((release) => ({ - ...release, - version: tagToVersion(release?.tag), - })) - .filter( - (release) => release.version && isBeforeVersion(release.version, version) - ) - .sort((a, b) => compareVersionsDesc(a.version, b.version))[0]; -} - -function getPreferredPackageTag(publishedPackages, version) { - const preferredPackage = getPreferredPublishedPackage( - publishedPackages, - version - ); - - return preferredPackage - ? `${preferredPackage.name}@${preferredPackage.version}` - : null; -} - -function getPreferredPublishedPackage(publishedPackages, version) { - const matchingPackages = publishedPackages.filter( - (publishedPackage) => - typeof publishedPackage?.name === 'string' && - publishedPackage.version === version - ); - - return ( - matchingPackages.find( - (publishedPackage) => publishedPackage.name === 'kitcn' - ) ?? matchingPackages[0] - ); -} - -function getPreviousGlobalReleaseVersion(version, globalReleaseTags) { - const previousVersion = (globalReleaseTags ?? getGitGlobalReleaseTags()) - .map(tagToVersion) - .filter(Boolean) - .filter((tagVersion) => isBeforeVersion(tagVersion, version)) - .sort(compareVersionsDesc)[0]; - - return previousVersion ?? null; -} - -function getGitGlobalReleaseTags() { - try { - return execFileSync('git', ['tag', '--list', 'v*'], { - cwd: repoRoot, - encoding: 'utf8', - }) - .split('\n') - .map((tag) => tag.trim()) - .filter(Boolean) - .filter((tag) => semverPattern.test(tagToVersion(tag) ?? '')); - } catch { - return []; - } -} - -function compareUrl(fromTag, toTag) { - return `https://github.com/${repo}/compare/${encodeURIComponent(fromTag)}...${encodeURIComponent(toTag)}`; -} - function normalizePath(filePath) { return filePath.split(path.sep).join('/'); } -function tagToVersion(tag) { - if (typeof tag !== 'string' || !tag.startsWith('v')) return null; - - const version = tag.slice(1); - - return semverPattern.test(version) ? version : null; -} - -function isBeforeVersion(version, referenceVersion) { - return compareVersionsDesc(version, referenceVersion) > 0; -} - async function readOptionalFile(filePath) { try { return await readFile(filePath, 'utf8'); @@ -665,44 +485,14 @@ function collectContributors(contributors, content) { } } -function collectContributorSectionHandles(contributors, content) { - for (const match of content.matchAll(contributorHandlePattern)) { - contributors.add(match[1]); - } -} - function extractContributorHandles(content) { const contributors = new Set(); collectContributors(contributors, content); - collectContributorSectionHandles( - contributors, - getContributorsSection(content) - ); - - return [...contributors]; -} - -function extractContributorSectionHandles(content) { - const contributors = new Set(); - - collectContributorSectionHandles(contributors, content); return [...contributors]; } -function getContributorsSection(content) { - const match = contributorsHeadingPattern.exec(content); - - if (!match) return ''; - - const sectionStart = match.index + match[0].length; - const rest = content.slice(sectionStart); - const nextHeading = headingBoundaryPattern.exec(rest); - - return rest.slice(0, nextHeading?.index ?? rest.length); -} - function hasContributorHandle(content, handle) { const escapedHandle = escapeRegExp(handle); diff --git a/tooling/scripts/release-notes.test.ts b/tooling/scripts/release-notes.test.ts index 43065444..549a4c94 100644 --- a/tooling/scripts/release-notes.test.ts +++ b/tooling/scripts/release-notes.test.ts @@ -8,7 +8,6 @@ import { addPackageChangelogLinks, extractReleaseChanges, generateRawReleaseNotes, - getFullChangelog, getGlobalReleaseVersion, getPackageChangelogUrls, getWorkspacePackages, @@ -88,10 +87,6 @@ test('generates raw release notes from published package changelogs', async () = ); const body = await generateRawReleaseNotes({ - fullChangelog: { - label: 'v53.0.1...v53.0.2', - url: 'https://github.com/udecode/kitcn/compare/v53.0.1...v53.0.2', - }, publishedPackages: [{ name: '@kitcn/list', version: '53.0.2' }], workspacePackages: await getWorkspacePackages([packageRoot]), }); @@ -99,12 +94,9 @@ test('generates raw release notes from published package changelogs', async () = assert.match(body, /## `@kitcn\/list`/); assert.match(body, /### Patch Changes/); assert.match(body, /Fix ordered list numbering/); - assert.match(body, /## Contributors/); + assert.doesNotMatch(body, /## Contributors/); assert.match(body, /@dylans/); - assert.match( - body, - /Full changelog: \[`v53\.0\.1\.\.\.v53\.0\.2`\]\(https:\/\/github\.com\/udecode\/kitcn\/compare\/v53\.0\.1\.\.\.v53\.0\.2\)/ - ); + assert.doesNotMatch(body, /Full changelog:/); assert.doesNotMatch(body, /For detailed changes/); }); @@ -202,64 +194,10 @@ Full changelog: [\`v53.0.4...v53.0.5\`](https://github.com/udecode/kitcn/compare ); assert.match( linkedContent, - /## `kitcn`[\s\S]*For detailed changes, see \[`CHANGELOG`\]\(https:\/\/github\.com\/udecode\/kitcn\/blob\/abc123\/packages\/kitcn\/CHANGELOG\.md\)[\s\S]*## Contributors/ - ); -}); - -test('uses release index package tags for full changelog fallback', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'kitcn-release-index-')); - const releaseIndexFile = path.join(root, 'release-index.json'); - - await writeFile( - releaseIndexFile, - JSON.stringify([ - { - packageTag: '@kitcn/ai@98.0.0', - tag: 'v98.0.0', - }, - ]) - ); - - assert.deepEqual( - await getFullChangelog({ - globalReleaseTags: ['v1.0.0'], - publishedPackages: [{ name: 'kitcn', version: '99.0.0' }], - releaseIndexFile, - version: '99.0.0', - }), - { - label: 'v98.0.0...v99.0.0', - url: 'https://github.com/udecode/kitcn/compare/%40kitcn%2Fai%4098.0.0...kitcn%4099.0.0', - } - ); -}); - -test('uses previous global tag when it matches the release index version', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'kitcn-release-index-')); - const releaseIndexFile = path.join(root, 'release-index.json'); - - await writeFile( - releaseIndexFile, - JSON.stringify([ - { - packageTag: 'kitcn@99.0.0', - tag: 'v99.0.0', - }, - ]) - ); - - assert.deepEqual( - await getFullChangelog({ - globalReleaseTags: ['v99.0.0'], - publishedPackages: [{ name: 'kitcn', version: '99.0.1' }], - releaseIndexFile, - version: '99.0.1', - }), - { - label: 'v99.0.0...v99.0.1', - url: 'https://github.com/udecode/kitcn/compare/v99.0.0...v99.0.1', - } + /## `kitcn`[\s\S]*For detailed changes, see \[`CHANGELOG`\]\(https:\/\/github\.com\/udecode\/kitcn\/blob\/abc123\/packages\/kitcn\/CHANGELOG\.md\)/ ); + assert.doesNotMatch(linkedContent, /## Contributors/); + assert.doesNotMatch(linkedContent, /Full changelog:/); }); test('validates AI release notes preserve deterministic structure', () => { @@ -271,22 +209,12 @@ test('validates AI release notes preserve deterministic structure', () => { > **Migration:** Use \`newApi\`. For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) - -## Contributors - -Thanks to everyone who contributed to this release: - -@alice `; const good = raw.replace('Removed `oldApi`', 'Removed `oldApi` from tables'); const bad = raw .replace('## `@kitcn/table`', '## `@kitcn/core`') .replace('[#5000](https://github.com/udecode/kitcn/pull/5000)', '') - .replace('> **Migration:** Use `newApi`.\n', '') - .replace( - '\n## Contributors\n\nThanks to everyone who contributed to this release:\n\n@alice\n', - '' - ); + .replace('> **Migration:** Use `newApi`.\n', ''); assert.deepEqual(validateAiReleaseNotes(raw, good), { errors: [], @@ -295,11 +223,11 @@ Thanks to everyone who contributed to this release: assert.equal(validateAiReleaseNotes(raw, bad).valid, false); assert.match( validateAiReleaseNotes(raw, bad).errors.join('\n'), - /AI output dropped Contributors section\./ + /AI output changed package headings\./ ); }); -test('validates AI release notes preserve the Contributors section itself', () => { +test('validates AI release notes reject standalone release footers', () => { const raw = `## \`@kitcn/table\` ### Patch Changes @@ -307,25 +235,24 @@ test('validates AI release notes preserve the Contributors section itself', () = - [#5000](https://github.com/udecode/kitcn/pull/5000) by [@alice](https://github.com/alice) – Fix table. For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) - +`; + const withFooter = `${raw} ## Contributors Thanks to everyone who contributed to this release: -@alice +[@alice](https://github.com/alice) + +Full changelog: [\`v53.0.4...v53.0.5\`](https://github.com/udecode/kitcn/compare/v53.0.4...v53.0.5) `; - const withoutContributors = raw.replace( - '\n## Contributors\n\nThanks to everyone who contributed to this release:\n\n@alice\n', - '\n' - ); - assert.deepEqual(validateAiReleaseNotes(raw, withoutContributors), { - errors: ['AI output dropped Contributors section.'], + assert.deepEqual(validateAiReleaseNotes(raw, withFooter), { + errors: ['AI output added release footer.'], valid: false, }); }); -test('validates AI release notes preserve comma-separated contributor handles', () => { +test('validates AI release notes preserve author links in entries', () => { const raw = `## \`@kitcn/table\` ### Patch Changes @@ -334,14 +261,8 @@ test('validates AI release notes preserve comma-separated contributor handles', - [#5001](https://github.com/udecode/kitcn/pull/5001) by [@bob](https://github.com/bob) – Fix more. For detailed changes, see [\`CHANGELOG\`](https://github.com/udecode/kitcn/blob/abc/packages/table/CHANGELOG.md) - -## Contributors - -Thanks to everyone who contributed to this release: - -@alice, @bob `; - const missingBob = raw.replace('@alice, @bob', '@alice'); + const missingBob = raw.replace(' by [@bob](https://github.com/bob)', ''); assert.deepEqual(validateAiReleaseNotes(raw, missingBob), { errors: ['AI output dropped contributors.'], From f0d732532aa016045ef05ad37a16a166d149c841 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Mon, 15 Jun 2026 20:49:22 +0200 Subject: [PATCH 3/3] ci fix release checkbox gate --- .github/workflows/changeset-auto-release.yml | 10 ++- .../src/solid/auth-mutations.types.vitest.ts | 64 +++++++++--------- .../convex-auth-provider.types.vitest.ts | 66 ++++++++++--------- tooling/scripts/release-workflow.test.ts | 2 + 4 files changed, 80 insertions(+), 62 deletions(-) diff --git a/.github/workflows/changeset-auto-release.yml b/.github/workflows/changeset-auto-release.yml index efcbe8ee..246626b1 100644 --- a/.github/workflows/changeset-auto-release.yml +++ b/.github/workflows/changeset-auto-release.yml @@ -24,7 +24,15 @@ jobs: ) steps: - - name: 📥 Checkout workflow helper + - name: 📥 Checkout same-repo workflow helper + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: 📥 Checkout default-branch workflow helper + if: ${{ github.event_name == 'pull_request_target' }} uses: actions/checkout@v4 with: ref: ${{ github.event.repository.default_branch }} diff --git a/packages/kitcn/src/solid/auth-mutations.types.vitest.ts b/packages/kitcn/src/solid/auth-mutations.types.vitest.ts index acc12d12..db5ad177 100644 --- a/packages/kitcn/src/solid/auth-mutations.types.vitest.ts +++ b/packages/kitcn/src/solid/auth-mutations.types.vitest.ts @@ -10,19 +10,22 @@ const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]) => }); describe('createAuthMutations solid types', () => { - test('accepts a username sign-in method from Better Auth plugins', () => { - const rootDir = process.cwd(); - const tmpRoot = path.join(rootDir, 'tmp'); - fs.mkdirSync(tmpRoot, { recursive: true }); - const fixtureDir = fs.mkdtempSync( - path.join(tmpRoot, 'kitcn-solid-auth-mutations-types-') - ); - const fixtureFile = path.join(fixtureDir, 'repro.ts'); + test( + 'accepts a username sign-in method from Better Auth plugins', + { timeout: 15_000 }, + () => { + const rootDir = process.cwd(); + const tmpRoot = path.join(rootDir, 'tmp'); + fs.mkdirSync(tmpRoot, { recursive: true }); + const fixtureDir = fs.mkdtempSync( + path.join(tmpRoot, 'kitcn-solid-auth-mutations-types-') + ); + const fixtureFile = path.join(fixtureDir, 'repro.ts'); - try { - fs.writeFileSync( - fixtureFile, - `import { createAuthClient } from "better-auth/solid"; + try { + fs.writeFileSync( + fixtureFile, + `import { createAuthClient } from "better-auth/solid"; import { usernameClient } from "better-auth/client/plugins"; import { convexClient } from "../../packages/kitcn/src/auth-client/index"; import { createAuthMutations } from "../../packages/kitcn/src/solid/auth-mutations"; @@ -35,25 +38,26 @@ const authClient = createAuthClient({ const { useSignInMutationOptions } = createAuthMutations(authClient); useSignInMutationOptions({ signInMethod: "username" }); ` - ); + ); - const program = ts.createProgram([fixtureFile], { - allowImportingTsExtensions: true, - jsx: ts.JsxEmit.ReactJSX, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - noEmit: true, - skipLibCheck: true, - strict: true, - strictFunctionTypes: true, - target: ts.ScriptTarget.ES2022, - types: ['bun-types'], - }); - const diagnostics = ts.getPreEmitDiagnostics(program); + const program = ts.createProgram([fixtureFile], { + allowImportingTsExtensions: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + strictFunctionTypes: true, + target: ts.ScriptTarget.ES2022, + types: ['bun-types'], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); - expect(formatDiagnostics(diagnostics)).toBe(''); - } finally { - fs.rmSync(fixtureDir, { force: true, recursive: true }); + expect(formatDiagnostics(diagnostics)).toBe(''); + } finally { + fs.rmSync(fixtureDir, { force: true, recursive: true }); + } } - }); + ); }); diff --git a/packages/kitcn/src/solid/convex-auth-provider.types.vitest.ts b/packages/kitcn/src/solid/convex-auth-provider.types.vitest.ts index 6be9d231..b38649ca 100644 --- a/packages/kitcn/src/solid/convex-auth-provider.types.vitest.ts +++ b/packages/kitcn/src/solid/convex-auth-provider.types.vitest.ts @@ -10,19 +10,22 @@ const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]) => }); describe('Solid ConvexAuthProvider types', () => { - test('accepts Better Auth and structural auth clients', () => { - const rootDir = process.cwd(); - const tmpRoot = path.join(rootDir, 'tmp'); - fs.mkdirSync(tmpRoot, { recursive: true }); - const fixtureDir = fs.mkdtempSync( - path.join(tmpRoot, 'kitcn-solid-convex-auth-provider-types-') - ); - const fixtureFile = path.join(fixtureDir, 'repro.ts'); + test( + 'accepts Better Auth and structural auth clients', + { timeout: 15_000 }, + () => { + const rootDir = process.cwd(); + const tmpRoot = path.join(rootDir, 'tmp'); + fs.mkdirSync(tmpRoot, { recursive: true }); + const fixtureDir = fs.mkdtempSync( + path.join(tmpRoot, 'kitcn-solid-convex-auth-provider-types-') + ); + const fixtureFile = path.join(fixtureDir, 'repro.ts'); - try { - fs.writeFileSync( - fixtureFile, - `import type { ConvexClient } from "convex/browser"; + try { + fs.writeFileSync( + fixtureFile, + `import type { ConvexClient } from "convex/browser"; import { createAuthClient } from "better-auth/solid"; import { inferAdditionalFields, @@ -99,26 +102,27 @@ ConvexAuthProvider({ client, }); ` - ); + ); - const program = ts.createProgram([fixtureFile], { - allowImportingTsExtensions: true, - jsx: ts.JsxEmit.ReactJSX, - jsxImportSource: 'solid-js', - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - noEmit: true, - skipLibCheck: true, - strict: true, - strictFunctionTypes: true, - target: ts.ScriptTarget.ES2022, - types: ['bun-types'], - }); - const diagnostics = ts.getPreEmitDiagnostics(program); + const program = ts.createProgram([fixtureFile], { + allowImportingTsExtensions: true, + jsx: ts.JsxEmit.ReactJSX, + jsxImportSource: 'solid-js', + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + strictFunctionTypes: true, + target: ts.ScriptTarget.ES2022, + types: ['bun-types'], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); - expect(formatDiagnostics(diagnostics)).toBe(''); - } finally { - fs.rmSync(fixtureDir, { force: true, recursive: true }); + expect(formatDiagnostics(diagnostics)).toBe(''); + } finally { + fs.rmSync(fixtureDir, { force: true, recursive: true }); + } } - }); + ); }); diff --git a/tooling/scripts/release-workflow.test.ts b/tooling/scripts/release-workflow.test.ts index ca7135fb..19cebaae 100644 --- a/tooling/scripts/release-workflow.test.ts +++ b/tooling/scripts/release-workflow.test.ts @@ -56,6 +56,8 @@ test('auto-release checkbox workflow imports the canonical helper', async () => const workflow = await readFile(autoReleaseWorkflowPath, 'utf8'); assert.match(workflow, /github\.repository == 'udecode\/kitcn'/); + assert.match(workflow, /github\.event\.pull_request\.head\.sha/); + assert.match(workflow, /github\.event\.repository\.default_branch/); assert.match(workflow, /tooling\/scripts\/auto-release-pr\.mjs/); });