-
-
Notifications
You must be signed in to change notification settings - Fork 175
fix(ci): restore changesets version PR flow #462
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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}` }, | ||||||||||||||||
| }) | ||||||||||||||||
|
Comment on lines
+17
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. URL-encode the email search query. This request builds 💡 Proposed fix- const res = await fetch(`https://api.github.com/search/users?q=${email}`, {
+ const q = new URLSearchParams({ q: email })
+ const res = await fetch(`https://api.github.com/search/users?${q}`, {
headers: { Authorization: `token ${ghToken}` },
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| 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) | ||||||||||||||||
| } | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Does actions/checkout withpersist-credentials: truestore the workflow token in local git config for later steps, and what do the changesets/action docs recommend when only the final push step needs git authentication?💡 Result:
The actions/checkout action, with persist-credentials set to true (the default), provides the workflow token (or a specified PAT/SSH key) to git so that later steps in the same job can perform authenticated git operations [1][2]. Regarding how this is implemented: - In older versions (v5 and earlier), the authentication token was directly stored in the local.git/config file [3][4]. - In v6 and later, the action was improved to store credentials in a separate, more secure file under $RUNNER_TEMP rather than directly in.git/config [5]. Git is configured to point to this secure file, maintaining compatibility with git commands like push and fetch without requiring changes to your workflow scripts [5]. Recommendation for workflows where only a final step needs git authentication: If you are concerned about security or want to follow the principle of least privilege, you can set persist-credentials: false to opt-out of this behavior [1][6]. If you do this, the git configuration will not be automatically populated with the workflow token. When you reach the step that requires authentication (e.g., a git push), you will need to manually authenticate the git client using the token or secret, typically by providing the credentials in the environment or using a command like git config to inject them only at the necessary time [1][2]. Many users prefer this approach to avoid having active credentials available throughout the entire job's duration [3][7].
Citations:
persist-credentialsor change the default tofalseactions/checkout#485GITHUB_TOKENwhen usingssh-keyactions/checkout#704persist-credentials=trueplease actions/checkout#2312🏁 Script executed:
cat -n .github/workflows/release.yml | head -60Repository: TanStack/cli
Length of output: 2111
🌐 Web query:
changesets/action github authentication requirements persist-credentials false💡 Result:
When using persist-credentials: false with the actions/checkout step in a workflow that includes changesets/action, you must manually provide the necessary GitHub authentication token to the changesets/action step [1][2][3]. By default, actions/checkout automatically persists a GitHub token in the local git configuration to allow authenticated git operations [4]. Setting persist-credentials: false disables this behavior, which is often done for security reasons or to use a specific Personal Access Token (PAT) that has elevated permissions (e.g., to trigger further workflow actions that the default GITHUB_TOKEN would ignore) [5][1][2]. If you disable credential persistence, changesets/action will fail to push changes or create release pull requests unless it has access to a valid authentication token [1][2][6]. You must explicitly provide this token by configuring the GITHUB_TOKEN environment variable within the changesets/action step, typically using a secret [1][2][3]. Example configuration: - name: Checkout Repo uses: actions/checkout@v4 with: persist-credentials: false - name: Run Changesets uses: changesets/action@v1 env: GITHUB_TOKEN: ${{ secrets.YOUR_PAT_SECRET }} Recent updates to changesets/action have introduced an explicit github-token input (defaulting to the GitHub-provided token), but if you have explicitly disabled persistence in your checkout step, ensuring that an appropriate token (like a PAT) is passed via the environment variable or input remains a critical requirement for successful git operations [7]. Additionally, ensure your workflow job has the necessary permissions (such as contents: write) to perform git pushes [1][8].
Citations:
Scope the write-capable git credentials to the release step.
Line 31 persists the job token before setup, build, and test commands run. Combined with the write permissions on lines 23–25, any compromised install or test script gains repo-write access throughout the entire job. Although
actions/checkoutv6.0.2 stores credentials in a secure file rather than.git/config, the token remains available to all intermediate steps; setpersist-credentials: falseand explicitly provide the token only to thechangesets/actionstep via theGITHUB_TOKENenvironment variable.🤖 Prompt for AI Agents