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
188 changes: 102 additions & 86 deletions .github/scripts/generate-changeset.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
/* eslint-disable no-console */
import {execFileSync, spawnSync} from 'node:child_process'
import {existsSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'
import {join, resolve} from 'node:path'
import {randomUUID} from 'node:crypto'
import {appendFileSync} from 'node:fs'

// --- Env vars ---
const {GH_TOKEN, GITHUB_REPOSITORY, PR_BODY = '', PR_NUMBER, PR_REPO, PR_TITLE} = process.env

if (!GH_TOKEN || !GITHUB_REPOSITORY || !PR_NUMBER || !PR_TITLE || !PR_REPO) {
const {
GH_TOKEN,
GITHUB_OUTPUT,
GITHUB_REPOSITORY,
PR_BODY = '',
PR_HEAD_SHA,
PR_NUMBER,
PR_REPO,
PR_TITLE,
} = process.env

if (
!GH_TOKEN ||
!GITHUB_REPOSITORY ||
!GITHUB_OUTPUT ||
!PR_HEAD_SHA ||
!PR_NUMBER ||
!PR_TITLE ||
!PR_REPO
) {
throw new Error(
'Missing required env vars: GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER, PR_TITLE, PR_REPO',
'Missing required env vars: GH_TOKEN, GITHUB_REPOSITORY, GITHUB_OUTPUT, PR_HEAD_SHA, PR_NUMBER, PR_TITLE, PR_REPO',
)
}

Expand All @@ -17,27 +33,17 @@ const AUTO_GENERATED_MARKER = '<!-- auto-generated -->'

// --- Helpers ---

// Use execFileSync to avoid shell interpretation of arguments
function git(...args) {
return execFileSync('git', args, {encoding: 'utf8'}).trim()
}

let gitConfigured = false
function ensureGitConfigured() {
if (gitConfigured) return
git('config', 'user.name', 'squiggler-app[bot]')
git('config', 'user.email', '265501495+squiggler-app[bot]@users.noreply.github.com')
git('remote', 'set-url', 'origin', `https://x-access-token:${GH_TOKEN}@github.com/${PR_REPO}.git`)
gitConfigured = true
async function ghApi(path) {
const url = path.startsWith('https://') ? path : `https://api.github.com${path}`
const res = await fetch(url, {
headers: {Accept: 'application/vnd.github+json', Authorization: `Bearer ${GH_TOKEN}`},
})
if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${await res.text()}`)
return res.json()
}

function removeChangeset() {
if (existsSync(CHANGESET_FILE)) {
ensureGitConfigured()
git('rm', CHANGESET_FILE)
git('commit', '-m', `chore: remove auto-generated changeset for PR #${PR_NUMBER}`)
git('push', '--force-with-lease')
}
function setOutput(key, value) {
appendFileSync(GITHUB_OUTPUT, `${key}=${value}\n`)
}

function parseConventionalCommit(title) {
Expand All @@ -60,12 +66,9 @@ async function getChangedFiles() {
let page = 1

while (true) {
const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${page}`
const res = await fetch(url, {
headers: {Accept: 'application/vnd.github+json', Authorization: `Bearer ${GH_TOKEN}`},
})
if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${await res.text()}`)
const data = await res.json()
const data = await ghApi(
`/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${page}`,
)
if (data.length === 0) break
files.push(...data.map((f) => f.filename))
page++
Expand All @@ -74,58 +77,56 @@ async function getChangedFiles() {
return files
}

// Discover non-private workspace packages.
// Discover non-private workspace packages using the GitHub API.
// Reads the PR head tree to find packages/*/package.json files.
// Returns a Map of dirPrefix -> packageName.
function getWorkspacePackages() {
async function getWorkspacePackages() {
const pkgMap = new Map()
const parentDirs = ['packages']

// Auto-discover scoped package dirs
if (existsSync('packages')) {
for (const entry of readdirSync('packages', {withFileTypes: true})) {
if (entry.isDirectory() && entry.name.startsWith('@')) {
parentDirs.push(`packages/${entry.name}`)
}
}
}

for (const parent of parentDirs) {
const parentPath = resolve(parent)
if (!existsSync(parentPath)) continue

for (const entry of readdirSync(parentPath, {withFileTypes: true})) {
if (!entry.isDirectory()) continue
if (parent === 'packages' && entry.name.startsWith('@')) continue
// Fetch the full tree for the PR head commit
const tree = await ghApi(`/repos/${GITHUB_REPOSITORY}/git/trees/${PR_HEAD_SHA}?recursive=1`)
if (tree.truncated) throw new Error('Git tree was truncated; cannot reliably detect packages')

const pkgJsonPath = join(parentPath, entry.name, 'package.json')
if (!existsSync(pkgJsonPath)) continue
// Match package.json files under packages/ (including scoped dirs like packages/@scope/name/)
const pkgJsonEntries = tree.tree.filter(
(entry) =>
entry.type === 'blob' &&
(entry.path.match(/^packages\/[^@][^/]*\/package\.json$/) ||
entry.path.match(/^packages\/@[^/]+\/[^/]+\/package\.json$/)),
)

const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
if (pkg.private) continue
for (const entry of pkgJsonEntries) {
const blob = await ghApi(`/repos/${GITHUB_REPOSITORY}/git/blobs/${entry.sha}`)
const pkg = JSON.parse(Buffer.from(blob.content, 'base64').toString())
if (pkg.private) continue

const dirPrefix = `${parent}/${entry.name}/`
pkgMap.set(dirPrefix, pkg.name)
}
const dirPrefix = entry.path.replace('package.json', '')
pkgMap.set(dirPrefix, pkg.name)
}

return pkgMap
}

// Check if the auto-generated changeset file exists on the PR branch via API.
// Returns its content if it exists, or null.
async function getExistingChangeset() {
const url = `https://api.github.com/repos/${PR_REPO}/contents/${CHANGESET_FILE}?ref=${PR_HEAD_SHA}`
const res = await fetch(url, {
headers: {Accept: 'application/vnd.github+json', Authorization: `Bearer ${GH_TOKEN}`},
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${await res.text()}`)
const data = await res.json()
return Buffer.from(data.content, 'base64').toString()
}
Comment thread
cursor[bot] marked this conversation as resolved.

// --- Main ---

// 0. Check for existing changesets using marker-based logic
// Only consider changesets that are part of this PR's changed files,
// not pre-existing ones inherited from the base branch.
const prChangedFiles = await getChangedFiles()
const existingChangeset = await getExistingChangeset()

if (existsSync(CHANGESET_FILE)) {
const content = readFileSync(CHANGESET_FILE, 'utf8')
if (!content.startsWith(AUTO_GENERATED_MARKER)) {
console.log('Skipping: changeset was manually edited (marker removed)')
process.exit(0)
}
// Marker present — bot still owns the file, will overwrite below
} else {
if (existingChangeset === null) {
const manualChangesets = prChangedFiles.filter(
(f) =>
f.startsWith('.changeset/') &&
Expand All @@ -136,31 +137,49 @@ if (existsSync(CHANGESET_FILE)) {
if (manualChangesets.length > 0) {
const names = manualChangesets.map((f) => f.replace('.changeset/', ''))
console.log(`Skipping: found manual changeset(s) in PR: ${names.join(', ')}`)
setOutput('action', 'skip')
process.exit(0)
}
} else {
if (!existingChangeset.startsWith(AUTO_GENERATED_MARKER)) {
console.log('Skipping: changeset was manually edited (marker removed)')
setOutput('action', 'skip')
process.exit(0)
}
// Marker present — bot still owns the file, will overwrite below
}

// 1. Parse conventional commit
const parsed = parseConventionalCommit(PR_TITLE)
if (!parsed) {
console.log('::warning::PR title does not match conventional commit format')
removeChangeset()
if (existingChangeset === null) {
setOutput('action', 'skip')
} else {
setOutput('action', 'remove')
setOutput('changeset_file', CHANGESET_FILE)
}
process.exit(0)
}

// 2. Determine bump
const bump = determineBump(parsed.type, parsed.breaking, PR_BODY)
if (!bump) {
console.log(`PR type '${parsed.type}' does not require a changeset`)
removeChangeset()
if (existingChangeset === null) {
setOutput('action', 'skip')
} else {
setOutput('action', 'remove')
setOutput('changeset_file', CHANGESET_FILE)
}
process.exit(0)
}

// 3. Preserve the full conventional commit title so the changelog function can parse type and scope
const releaseNotes = PR_TITLE

// 4. Detect affected packages
const pkgMap = getWorkspacePackages()
const pkgMap = await getWorkspacePackages()
const affected = new Set()

for (const file of prChangedFiles) {
Expand All @@ -173,27 +192,24 @@ for (const file of prChangedFiles) {

if (affected.size === 0) {
console.log('No public packages affected by changed files')
removeChangeset()
if (existingChangeset === null) {
setOutput('action', 'skip')
} else {
setOutput('action', 'remove')
setOutput('changeset_file', CHANGESET_FILE)
}
process.exit(0)
}

// 5. Write changeset
// 5. Output changeset content for the workflow to commit
const frontmatter = [...affected].map((pkg) => `'${pkg}': ${bump}`).join('\n')
const changesetContent = `${AUTO_GENERATED_MARKER}\n---\n${frontmatter}\n---\n\n${releaseNotes}\n`

writeFileSync(CHANGESET_FILE, changesetContent)
console.log('Generated changeset:')
console.log(changesetContent)

// 6. Commit and push
ensureGitConfigured()
git('add', CHANGESET_FILE)

const {status} = spawnSync('git', ['diff', '--cached', '--quiet'], {stdio: 'ignore'})
if (status !== 1) {
console.log('No changes to changeset file')
process.exit(status ?? 1)
}

git('commit', '-m', `chore: update auto-generated changeset for PR #${PR_NUMBER}`)
git('push', '--force-with-lease')
setOutput('action', 'write')
setOutput('changeset_file', CHANGESET_FILE)
// Use delimiter syntax with a random delimiter to prevent output injection
const delimiter = `CHANGESET_EOF_${randomUUID().replaceAll('-', '')}`
appendFileSync(GITHUB_OUTPUT, `changeset_content<<${delimiter}\n${changesetContent}${delimiter}\n`)
62 changes: 52 additions & 10 deletions .github/workflows/generate-changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ jobs:
app-id: ${{ secrets.ECOSPARK_APP_ID }}
private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }}

- name: Checkout PR branch
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 2
persist-credentials: false

- name: Checkout base branch scripts
uses: actions/checkout@v6
with:
Expand All @@ -46,11 +37,62 @@ jobs:
with:
node-version: 24

- name: Generate or remove changeset
- name: Analyze PR and generate changeset
id: analyze
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node base-scripts/.github/scripts/generate-changeset.mjs

- name: Checkout PR branch
if: steps.analyze.outputs.action == 'write' || steps.analyze.outputs.action == 'remove'
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
Comment thread
cursor[bot] marked this conversation as resolved.
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 1
persist-credentials: false

- name: Configure git
if: steps.analyze.outputs.action == 'write' || steps.analyze.outputs.action == 'remove'
env:
GH_APP_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
run: |
git config user.name 'squiggler-app[bot]'
git config user.email '265501495+squiggler-app[bot]@users.noreply.github.com'
git remote set-url origin "https://x-access-token:${GH_APP_TOKEN}@github.com/${PR_HEAD_REPO}.git"

- name: Write changeset and push
if: steps.analyze.outputs.action == 'write'
env:
CHANGESET_CONTENT: ${{ steps.analyze.outputs.changeset_content }}
CHANGESET_FILE: ${{ steps.analyze.outputs.changeset_file }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
mkdir -p "$(dirname "$CHANGESET_FILE")"
printf '%s' "$CHANGESET_CONTENT" > "$CHANGESET_FILE"
git add "$CHANGESET_FILE"
if ! git diff --cached --quiet; then
git commit -m "chore: update auto-generated changeset for PR #${{ github.event.pull_request.number }}"
git push --force-with-lease origin "HEAD:${PR_HEAD_REF}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--force-with-lease ineffective with SHA-based detached HEAD checkout

Medium Severity

The checkout now uses head.sha (line 55) which creates a detached HEAD with no remote-tracking branches. --force-with-lease relies on comparing the remote ref against a local remote-tracking branch. Without one, --force-with-lease silently degrades — either rejecting all pushes or behaving like --force depending on git version — losing the concurrency safety it's meant to provide. The old workflow checked out by head.ref, which established proper tracking. Consider specifying an explicit lease value like --force-with-lease=${PR_HEAD_REF}:${PR_HEAD_SHA} so the expected remote state is known.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1ac0ef3. Configure here.

else
echo "No changes to changeset file"
fi

- name: Remove changeset and push
if: steps.analyze.outputs.action == 'remove'
env:
CHANGESET_FILE: ${{ steps.analyze.outputs.changeset_file }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ -f "$CHANGESET_FILE" ]; then
git rm "$CHANGESET_FILE"
git commit -m "chore: remove auto-generated changeset for PR #${{ github.event.pull_request.number }}"
git push --force-with-lease origin "HEAD:${PR_HEAD_REF}"
fi
Loading