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..cd91ffca --- /dev/null +++ b/.github/prompts/release-notes-rewrite.md @@ -0,0 +1,42 @@ + + +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, and package names. +- 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 add standalone `## Contributors` sections. +- Do not add standalone `Full changelog: [\`...\`](...)` links. +- 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..246626b1 100644 --- a/.github/workflows/changeset-auto-release.yml +++ b/.github/workflows/changeset-auto-release.yml @@ -24,24 +24,28 @@ 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_name == 'pull_request' - && github.event.pull_request.head.sha - || github.event.repository.default_branch - }} + 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 }} 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/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/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..1b1a8094 --- /dev/null +++ b/tooling/scripts/release-notes.mjs @@ -0,0 +1,557 @@ +#!/usr/bin/env node + +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 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 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; +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 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 body = await generateRawReleaseNotes({ + 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 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({ + publishedPackages, + workspacePackages, +}) { + const lines = []; + 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); + } else { + lines.push( + `Published \`${publishedPackage.name}@${publishedPackage.version}\`.` + ); + } + + lines.push(''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +export function addPackageChangelogLinks( + content, + { + commitRef = 'main', + publishedPackages, + repoRootDirectory, + workspacePackages, + } +) { + const contentWithoutReleaseFooter = stripReleaseFooter(content); + const changelogUrls = getPackageChangelogUrls({ + commitRef, + publishedPackages, + repoRootDirectory, + workspacePackages, + }); + let output = ''; + let cursor = 0; + + for (const match of contentWithoutReleaseFooter.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( + contentWithoutReleaseFooter.slice(bodyStart) + ); + const sectionEnd = + nextHeadingMatch?.index === undefined + ? contentWithoutReleaseFooter.length + : bodyStart + nextHeadingMatch.index; + const changelogUrl = packageName ? changelogUrls.get(packageName) : null; + let sectionBody = contentWithoutReleaseFooter.slice(bodyStart, sectionEnd); + + output += contentWithoutReleaseFooter.slice(cursor, bodyStart); + + if (changelogUrl) { + sectionBody = appendPackageChangelogLink(sectionBody, changelogUrl); + } + + output += sectionBody; + cursor = sectionEnd; + } + + output += contentWithoutReleaseFooter.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 rawPullRequestLinks = matchAll(raw, pullRequestLinkPattern); + const finalPullRequestLinks = matchAll(final, pullRequestLinkPattern); + const rawCommitLinks = matchAll(raw, commitLinkPattern); + const finalCommitLinks = matchAll(final, commitLinkPattern); + 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(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 (releaseFooterPattern.test(final)) { + errors.push('AI output added release footer.'); + } + + if (missingContributors.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) { + 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; +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +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 extractContributorHandles(content) { + const contributors = new Set(); + + collectContributors(contributors, content); + + return [...contributors]; +} + +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..549a4c94 --- /dev/null +++ b/tooling/scripts/release-notes.test.ts @@ -0,0 +1,331 @@ +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, + 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({ + 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.doesNotMatch(body, /## Contributors/); + assert.match(body, /@dylans/); + assert.doesNotMatch(body, /Full changelog:/); + 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\)/ + ); + assert.doesNotMatch(linkedContent, /## Contributors/); + assert.doesNotMatch(linkedContent, /Full changelog:/); +}); + +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) +`; + 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', ''); + + 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 changed package headings\./ + ); +}); + +test('validates AI release notes reject standalone release footers', () => { + 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) +`; + const withFooter = `${raw} +## Contributors + +Thanks to everyone who contributed to this release: + +[@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) +`; + + assert.deepEqual(validateAiReleaseNotes(raw, withFooter), { + errors: ['AI output added release footer.'], + valid: false, + }); +}); + +test('validates AI release notes preserve author links in entries', () => { + 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) +`; + const missingBob = raw.replace(' by [@bob](https://github.com/bob)', ''); + + 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..19cebaae --- /dev/null +++ b/tooling/scripts/release-workflow.test.ts @@ -0,0 +1,73 @@ +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, /github\.event\.pull_request\.head\.sha/); + assert.match(workflow, /github\.event\.repository\.default_branch/); + 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); +});