Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 51 additions & 31 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 22 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does actions/checkout with persist-credentials: true store 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:


🏁 Script executed:

cat -n .github/workflows/release.yml | head -60

Repository: 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/checkout v6.0.2 stores credentials in a secure file rather than .git/config, the token remains available to all intermediate steps; set persist-credentials: false and explicitly provide the token only to the changesets/action step via the GITHUB_TOKEN environment variable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 22 - 31, The checkout step
currently sets persist-credentials: true while the job has
contents/id-token/pull-requests write permissions, so change the
actions/checkout@... step to set persist-credentials: false to avoid exposing
write-capable creds to intermediate steps, and then remove global exposure by
explicitly injecting the GITHUB_TOKEN env only into the changesets/action step
(i.e., add env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} to the specific
changesets/action step) so write credentials are scoped to that release step;
reference the actions/checkout use block and the changesets/action step when
making these changes.

- name: Setup Tools
uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main
- name: Build
Expand All @@ -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' }}
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
264 changes: 264 additions & 0 deletions scripts/create-github-release.mjs
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

URL-encode the email search query.

This request builds q=... from the raw email string. Reserved URI characters are not escaped in that form, and MDN explicitly recommends encoding dynamic URL components with encodeURIComponent() instead of inserting them directly. That can make fallback attribution miss valid authors. (developer.mozilla.org)

💡 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(`https://api.github.com/search/users?q=${email}`, {
headers: { Authorization: `token ${ghToken}` },
})
const q = new URLSearchParams({ q: email })
const res = await fetch(`https://api.github.com/search/users?${q}`, {
headers: { Authorization: `token ${ghToken}` },
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/create-github-release.mjs` around lines 17 - 19, The fetch call
building the GitHub search URL uses the raw email variable and can produce
invalid/unescaped query strings; update the URL construction in the fetch that
assigns to res so the email is URL-encoded (e.g., use encodeURIComponent on the
email variable before inserting into `q=`) to ensure reserved characters are
escaped and the search works reliably, keeping the existing headers/ghToken
logic unchanged.

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)
}
Loading
Loading