diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index e92492c7cad..fd98e9fb08b 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -3,7 +3,7 @@ name: autofix.ci # needed to securely identify the workflow on: pull_request: push: - branches: [main, alpha, beta, rc, v4] + branches: [main, 'v[0-9]', '*-pre', '*-maint'] concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af61b1a1711..08f17a0f040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,11 @@ name: Release on: push: - branches: [main, alpha, beta, rc, v4] - repository_dispatch: - types: [release] + branches: [main, 'v[0-9]', '*-pre', '*-maint'] concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} @@ -21,52 +19,72 @@ permissions: jobs: release: name: Release - if: github.repository_owner == 'TanStack' + if: "!contains(github.event.head_commit.message, 'ci: changeset release')" runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6.0.2 with: fetch-depth: 0 + - name: Check for changesets + id: changesets + run: | + CHANGESET_FILES=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true) + if [ -z "$CHANGESET_FILES" ]; then + echo "has_changesets=false" >> "$GITHUB_OUTPUT" + else + echo "has_changesets=true" >> "$GITHUB_OUTPUT" + fi - name: Start Nx Agents + if: steps.changesets.outputs.has_changesets == 'true' run: npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" - name: Setup Tools uses: TanStack/config/.github/setup@main - name: Run Tests + if: steps.changesets.outputs.has_changesets == 'true' run: pnpm run test:ci - name: Stop Nx Agents - if: ${{ always() }} + if: ${{ always() && steps.changesets.outputs.has_changesets == 'true' }} run: npx nx-cloud stop-all-agents - # - name: Check for Changesets marked as major - # id: major - # run: | - # echo "found=false" >> $GITHUB_OUTPUT - # regex="(major)" - # shopt -s nullglob - # for file in .changeset/*.md; do - # if [[ $(cat $file) =~ $regex ]]; then - # echo "found=true" >> $GITHUB_OUTPUT - # fi - # done - - name: Run Changesets (version or publish) - id: changesets - uses: changesets/action@v1.7.0 - with: - version: pnpm run changeset:version - publish: pnpm run changeset:publish - commit: 'ci: Version Packages' - title: 'ci: Version Packages' - # - name: Auto-merge Changesets PR - # if: steps.changesets.outputs.hasChangesets == 'true' && steps.major.outputs.found == 'false' - # run: | - # gh pr merge --squash "$PR_NUMBER" - # gh api --method POST /repos/$REPO/dispatches -f 'event_type=release' - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # REPO: ${{ github.repository }} - # PR_NUMBER: ${{ steps.changesets.outputs.pullRequestNumber }} - - name: Comment on PRs about release - if: steps.changesets.outputs.published == 'true' - uses: TanStack/config/.github/comment-on-release@main - with: - published-packages: ${{ steps.changesets.outputs.publishedPackages }} + - name: Enter Pre-Release Mode + if: "contains(github.ref_name, '-pre') && !hashFiles('.changeset/pre.json')" + run: pnpm changeset pre enter pre + - name: Version Packages + run: pnpm run changeset:version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Commit and Push Version Changes + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + if git commit -m "ci: changeset release"; then + git push + echo "committed=true" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Determine dist-tag + if: steps.commit.outputs.committed == 'true' + id: dist-tag + run: | + BRANCH="${GITHUB_REF_NAME}" + if [[ "$BRANCH" == *-pre ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH" == *-maint ]]; then + echo "tag=maint" >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH" =~ ^v[0-9]+$ ]]; then + echo "tag=$BRANCH" >> "$GITHUB_OUTPUT" + else + echo "latest=true" >> "$GITHUB_OUTPUT" + fi + - name: Publish Packages + if: steps.commit.outputs.committed == 'true' + run: pnpm run changeset:publish ${{ steps.dist-tag.outputs.tag && format('--tag {0}', steps.dist-tag.outputs.tag) }} + - name: Create GitHub Release + if: steps.commit.outputs.committed == 'true' + run: node scripts/create-github-release.mjs ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs new file mode 100644 index 00000000000..6ded1208aff --- /dev/null +++ b/scripts/create-github-release.mjs @@ -0,0 +1,272 @@ +// @ts-nocheck +import fs from 'fs' +import path from 'node:path' +import { globSync } from 'node:fs' +import { execSync, execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' + +const rootDir = path.join(import.meta.dirname, '..') +const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + +// Resolve GitHub usernames from commit author emails +const usernameCache = {} +async function resolveUsername(email) { + if (!ghToken || !email) return null + if (usernameCache[email] !== undefined) return usernameCache[email] + + try { + const res = await fetch(`https://api.github.com/search/users?q=${email}`, { + headers: { Authorization: `token ${ghToken}` }, + }) + const data = await res.json() + const login = data?.items?.[0]?.login || null + usernameCache[email] = login + return login + } catch { + usernameCache[email] = null + return null + } +} + +// Resolve author from a PR number via GitHub API +const prAuthorCache = {} +async function resolveAuthorForPR(prNumber) { + if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber] + + if (!ghToken) { + prAuthorCache[prNumber] = null + return null + } + + try { + const res = await fetch( + `https://api.github.com/repos/TanStack/query/pulls/${prNumber}`, + { headers: { Authorization: `token ${ghToken}` } }, + ) + const data = await res.json() + const login = data?.user?.login || null + prAuthorCache[prNumber] = login + return login + } catch { + prAuthorCache[prNumber] = null + return null + } +} + +// Get the previous release commit to diff against. +// This script runs right after the "ci: changeset release" commit is pushed, +// so HEAD is the release commit. +const releaseLogs = execSync( + 'git log --oneline --grep="ci: changeset release" --format=%H', +) + .toString() + .trim() + .split('\n') + .filter(Boolean) + +const currentRelease = releaseLogs[0] || 'HEAD' +const previousRelease = releaseLogs[1] + +// Find packages that were actually bumped by comparing versions +const packagesDir = path.join(rootDir, 'packages') +const allPkgJsonPaths = globSync('*/package.json', { cwd: packagesDir }) + +const bumpedPackages = [] +for (const relPath of allPkgJsonPaths) { + const fullPath = path.join(packagesDir, relPath) + const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) + if (currentPkg.private) continue + + // Get the version from the previous release commit + if (previousRelease) { + try { + const prevContent = execFileSync( + 'git', + ['show', `${previousRelease}:packages/${relPath}`], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, + ) + const prevPkg = JSON.parse(prevContent) + if (prevPkg.version !== currentPkg.version) { + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: prevPkg.version, + dir: path.dirname(relPath), + }) + } + } catch { + // Package didn't exist in previous release — it's new + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: null, + dir: path.dirname(relPath), + }) + } + } else { + // No previous release — include all non-private packages + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: null, + dir: path.dirname(relPath), + }) + } +} + +bumpedPackages.sort((a, b) => a.name.localeCompare(b.name)) + +// Build changelog from git log between releases (conventional commits) +const rangeFrom = previousRelease || `${currentRelease}~1` +const rawLog = execSync( + `git log ${rangeFrom}..${currentRelease} --pretty=format:"%h %ae %s" --no-merges`, + { encoding: 'utf-8' }, +).trim() + +const typeOrder = [ + 'breaking', + 'feat', + 'fix', + 'perf', + 'refactor', + 'docs', + 'chore', + 'test', + 'ci', +] +const typeLabels = { + breaking: '⚠️ Breaking Changes', + feat: 'Features', + fix: 'Fix', + perf: 'Performance', + refactor: 'Refactor', + docs: 'Documentation', + chore: 'Chore', + test: 'Tests', + ci: 'CI', +} +const typeIndex = (t) => { + const i = typeOrder.indexOf(t) + return i === -1 ? 99 : i +} + +const groups = {} +const commits = rawLog ? rawLog.split('\n') : [] + +for (const line of commits) { + const match = line.match(/^(\w+)\s+(\S+)\s+(.*)$/) + if (!match) continue + const [, hash, email, subject] = match + + // Skip release commits + if (subject.startsWith('ci: changeset release')) continue + + // Parse conventional commit: type(scope)!: message + const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/) + const type = conventionalMatch ? conventionalMatch[1] : 'other' + const isBreaking = conventionalMatch ? !!conventionalMatch[3] : false + const scope = conventionalMatch ? conventionalMatch[2] || '' : '' + const message = conventionalMatch ? conventionalMatch[4] : subject + + // Only include user-facing change types + if (!['feat', 'fix', 'perf', 'refactor', 'build', 'chore'].includes(type)) + continue + + // Extract PR number if present + const prMatch = message.match(/\(#(\d+)\)/) + const prNumber = prMatch ? prMatch[1] : null + + const bucket = isBreaking ? 'breaking' : type + if (!groups[bucket]) groups[bucket] = [] + groups[bucket].push({ hash, email, scope, message, prNumber }) +} + +// Build markdown grouped by conventional commit type +const sortedTypes = Object.keys(groups).sort( + (a, b) => typeIndex(a) - typeIndex(b), +) + +let changelogMd = '' +for (const type of sortedTypes) { + const label = typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1) + changelogMd += `### ${label}\n\n` + + for (const commit of groups[type]) { + const scopePrefix = commit.scope ? `${commit.scope}: ` : '' + const cleanMessage = commit.message.replace(/\s*\(#\d+\)/, '') + const prRef = commit.prNumber ? ` (#${commit.prNumber})` : '' + const username = commit.prNumber + ? await resolveAuthorForPR(commit.prNumber) + : await resolveUsername(commit.email) + const authorSuffix = username ? ` by @${username}` : '' + + changelogMd += `- ${scopePrefix}${cleanMessage}${prRef} (${commit.hash})${authorSuffix}\n` + } + changelogMd += '\n' +} + +if (!changelogMd.trim()) { + changelogMd = '- No changelog entries\n\n' +} + +const now = new Date() +const date = now.toISOString().slice(0, 10) +const time = now.toISOString().slice(11, 16).replace(':', '') +const tagName = `release-${date}-${time}` +const titleDate = `${date} ${now.toISOString().slice(11, 16)}` + +const isPrerelease = process.argv.includes('--prerelease') +const isLatest = process.argv.includes('--latest') + +const body = `Release ${titleDate} + +## Changes + +${changelogMd} +## Packages + +${bumpedPackages.map((p) => `- ${p.name}@${p.version}`).join('\n')} +` + +// Create the release +// Check if tag already exists — if so, try to create the release for it +// (handles retries where the tag was pushed but release creation failed) +let tagExists = false +try { + execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' }) + tagExists = true +} catch { + // Tag doesn't exist yet +} + +if (!tagExists) { + execSync(`git tag -a -m "${tagName}" ${tagName}`) + execSync('git push --tags') +} + +const prereleaseFlag = isPrerelease ? '--prerelease' : '' +const latestFlag = isLatest ? ' --latest' : '' +const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`) +fs.writeFileSync(tmpFile, body) + +try { + execSync( + `gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`, + { stdio: 'inherit' }, + ) + console.info(`GitHub release ${tagName} created.`) +} catch (err) { + // Clean up the tag if we created it but release failed + if (!tagExists) { + console.info(`Release creation failed, cleaning up tag ${tagName}...`) + try { + execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' }) + execSync(`git tag -d ${tagName}`, { stdio: 'ignore' }) + } catch { + // Best effort cleanup + } + } + throw err +} finally { + fs.unlinkSync(tmpFile) +}