diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13afe5fd..074157f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,34 +2,33 @@ name: Release on: push: - branches: [main, alpha, beta, rc] + branches: [main, alpha, beta, rc, '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 }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -permissions: {} +permissions: + contents: read jobs: release: name: Release - if: ${{ github.repository_owner == 'TanStack' && !contains(github.event.head_commit.message, '[skip ci]') }} + if: ${{ github.repository_owner == 'TanStack' }} runs-on: ubuntu-latest permissions: contents: write id-token: write - pull-requests: read - statuses: read + pull-requests: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - persist-credentials: false + persist-credentials: true # changesets/action pushes version PR commits - name: Setup Tools uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main - name: Build @@ -40,28 +39,49 @@ jobs: run: pnpm --filter @tanstack/cli exec playwright install --with-deps chrome - name: E2E Blocking run: pnpm --filter @tanstack/cli test:e2e - - name: Generate Semantic Changeset Fallback - run: pnpm run changeset:generate - - name: Prepare Release Context - id: release - run: pnpm run release:prepare + - name: Enter Pre-Release Mode + if: "(contains(github.ref_name, '-pre') || github.ref_name == 'alpha' || github.ref_name == 'beta' || github.ref_name == 'rc') && !hashFiles('.changeset/pre.json')" + run: | + BRANCH="${GITHUB_REF_NAME}" + if [[ "$BRANCH" == "alpha" || "$BRANCH" == "beta" || "$BRANCH" == "rc" ]]; then + pnpm changeset pre enter "$BRANCH" + else + pnpm changeset pre enter pre + fi + - name: Determine dist-tag + id: dist-tag + run: | + BRANCH="${GITHUB_REF_NAME}" + if [[ "$BRANCH" == "alpha" || "$BRANCH" == "beta" || "$BRANCH" == "rc" ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "tag=$BRANCH" >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH" == *-pre ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "tag=pre" >> "$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: Version Packages - if: steps.release.outputs.has_changesets == 'true' - run: pnpm run changeset:version - - - name: Detect Versioning Changes - if: steps.release.outputs.has_changesets == 'true' - id: changes - run: pnpm run release:detect-changes - - - name: Commit Version Updates - if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true' - run: pnpm run release:commit-and-push - - - name: Publish Packages - if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true' + - name: Create Release Pull Request or Publish + id: changesets + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 + with: + version: pnpm run changeset:version + publish: pnpm run changeset:publish ${{ steps.dist-tag.outputs.tag && format('--tag {0}', steps.dist-tag.outputs.tag) }} + title: 'ci: Version Packages' + commit: 'ci: changeset release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create GitHub Release + if: steps.changesets.outputs.published == 'true' + run: node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG} env: - NPM_TAG: ${{ steps.release.outputs.npm_tag }} - run: pnpm changeset publish --tag "$NPM_TAG" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRERELEASE_FLAG: ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} + LATEST_FLAG: ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} diff --git a/package.json b/package.json index 5ac50bd2..e93d32c6 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,6 @@ "prepare": "husky install", "changeset": "changeset", "changeset:generate": "node scripts/generate-semantic-changeset.mjs", - "release:prepare": "node scripts/prepare-release.mjs", - "release:detect-changes": "node scripts/release-detect-changes.mjs", - "release:commit-and-push": "node scripts/release-commit-and-push.mjs", "changeset:publish": "changeset publish", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile" }, diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs new file mode 100644 index 00000000..7d45e3a2 --- /dev/null +++ b/scripts/create-github-release.mjs @@ -0,0 +1,264 @@ +// @ts-nocheck +import { execFileSync, execSync } from 'node:child_process' +import fs, { globSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const rootDir = path.join(import.meta.dirname, '..') +const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN +const workspaceDirs = ['packages', 'cli-aliases'] + +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 + } +} + +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/cli/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 + } +} + +// This runs after the "ci: Version Packages" PR is merged, so HEAD is the +// release commit. +const releaseLogs = execSync( + 'git log --oneline --grep="^ci: Version Packages" --grep="^ci: changeset release" --format=%H', +) + .toString() + .trim() + .split('\n') + .filter(Boolean) + +const currentRelease = releaseLogs[0] || 'HEAD' +const previousRelease = releaseLogs[1] + +const bumpedPackages = [] +for (const workspaceDir of workspaceDirs) { + const absWorkspaceDir = path.join(rootDir, workspaceDir) + const pkgJsonPaths = globSync('*/package.json', { cwd: absWorkspaceDir }) + + for (const relPath of pkgJsonPaths) { + const fullPath = path.join(absWorkspaceDir, relPath) + const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) + if (currentPkg.private) continue + + const repoRelPath = `${workspaceDir}/${relPath}` + if (previousRelease) { + try { + const prevContent = execFileSync( + 'git', + ['show', `${previousRelease}:${repoRelPath}`], + { 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(repoRelPath), + }) + } + } catch { + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: null, + dir: path.dirname(repoRelPath), + }) + } + } else { + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: null, + dir: path.dirname(repoRelPath), + }) + } + } +} + +bumpedPackages.sort((a, b) => a.name.localeCompare(b.name)) + +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 = (type) => { + const index = typeOrder.indexOf(type) + return index === -1 ? 99 : index +} + +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 + + if ( + subject.startsWith('ci: Version Packages') || + subject.startsWith('ci: changeset release') + ) { + continue + } + + const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/) + const type = conventionalMatch ? conventionalMatch[1] : 'other' + const isBreaking = conventionalMatch ? Boolean(conventionalMatch[3]) : false + const scope = conventionalMatch ? conventionalMatch[2] || '' : '' + const message = conventionalMatch ? conventionalMatch[4] : subject + + if (!['chore', 'feat', 'fix', 'perf', 'refactor', 'build'].includes(type)) { + continue + } + + 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 }) +} + +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((pkg) => `- ${pkg.name}@${pkg.version}`).join('\n')} +` + +let tagExists = false +try { + execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' }) + tagExists = true +} catch { + // Tag does not 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 (error) { + 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 error +} finally { + fs.unlinkSync(tmpFile) +} diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs deleted file mode 100644 index 4a6066cb..00000000 --- a/scripts/prepare-release.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { execSync } from 'node:child_process' -import { appendFileSync, existsSync, readdirSync, readFileSync } from 'node:fs' -import path from 'node:path' - -function run(command) { - return execSync(command, { encoding: 'utf8' }).trim() -} - -function getReleaseChannel(branch) { - if (branch === 'main') { - return { npmTag: 'latest', prerelease: false } - } - - if (branch === 'alpha' || branch === 'beta' || branch === 'rc') { - return { npmTag: branch, prerelease: true } - } - - throw new Error(`Unsupported release branch: ${branch}`) -} - -function getPendingChangesets() { - const changesetDir = path.resolve('.changeset') - if (!existsSync(changesetDir)) return [] - - return readdirSync(changesetDir).filter( - (name) => name.endsWith('.md') && name !== 'README.md', - ) -} - -function ensureReleaseMode({ prerelease, npmTag }) { - const prePath = path.resolve('.changeset/pre.json') - if (!existsSync(prePath)) { - if (prerelease) { - run(`pnpm changeset pre enter ${npmTag}`) - } - return - } - - const preState = JSON.parse(readFileSync(prePath, 'utf8')) - - if (prerelease) { - if (preState.tag !== npmTag) { - throw new Error( - `Expected prerelease tag '${npmTag}' but found '${preState.tag}' in .changeset/pre.json`, - ) - } - - if (preState.mode !== 'pre') { - run(`pnpm changeset pre enter ${npmTag}`) - } - return - } - - if (preState.mode === 'pre') { - throw new Error( - 'Main branch is in prerelease mode. Remove or exit prerelease state before stable releases.', - ) - } -} - -function setOutput(name, value) { - const outputPath = process.env.GITHUB_OUTPUT - if (!outputPath) return - appendFileSync(outputPath, `${name}=${value}\n`) -} - -function main() { - const branch = process.env.GITHUB_REF_NAME || run('git branch --show-current') - const channel = getReleaseChannel(branch) - const pending = getPendingChangesets() - - if (pending.length > 0) { - ensureReleaseMode(channel) - } - - setOutput('npm_tag', channel.npmTag) - setOutput('prerelease', String(channel.prerelease)) - setOutput('has_changesets', String(pending.length > 0)) - setOutput('pending_count', String(pending.length)) - - console.log( - `Release prep: branch=${branch} tag=${channel.npmTag} prerelease=${channel.prerelease} changesets=${pending.length}`, - ) -} - -main() diff --git a/scripts/release-commit-and-push.mjs b/scripts/release-commit-and-push.mjs deleted file mode 100644 index b63f78ff..00000000 --- a/scripts/release-commit-and-push.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { execSync } from 'node:child_process' - -function run(command) { - return execSync(command, { encoding: 'utf8' }).trim() -} - -function main() { - const branch = process.env.GITHUB_REF_NAME || run('git branch --show-current') - - run('git add -A') - run( - `git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "ci: Version Packages [skip ci]"`, - ) - run(`git push --follow-tags origin "HEAD:${branch}"`) - - console.log(`Committed and pushed release changes to ${branch}.`) -} - -main() diff --git a/scripts/release-detect-changes.mjs b/scripts/release-detect-changes.mjs deleted file mode 100644 index 30d5e9c5..00000000 --- a/scripts/release-detect-changes.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { execSync } from 'node:child_process' -import { appendFileSync } from 'node:fs' - -function run(command) { - return execSync(command, { encoding: 'utf8' }).trim() -} - -function setOutput(name, value) { - const outputPath = process.env.GITHUB_OUTPUT - if (!outputPath) return - appendFileSync(outputPath, `${name}=${value}\n`) -} - -function main() { - const hasChanges = run('git status --porcelain').length > 0 - setOutput('has_changes', String(hasChanges)) - - if (hasChanges) { - console.log('Detected versioning changes to commit and publish.') - } else { - console.log('No release changes detected.') - } -} - -main()