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
4 changes: 4 additions & 0 deletions .github/changeset-preview/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ description: Generates comment on a PR showing expected version impact
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
run: npm install
working-directory: ${{ github.action_path }}
Comment on lines +6 to +9
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Lockfiles under .github/changeset-preview:"
fd '^(package-lock\.json|npm-shrinkwrap\.json)$' .github/changeset-preview -t f || true

echo
echo "Action package manifest:"
sed -n '1,120p' .github/changeset-preview/package.json

Repository: TanStack/config

Length of output: 201


Add a lockfile and use npm ci for frozen installs in CI.

The action currently uses npm install, which can mutate package.json and is not deterministic across runs. npm ci is the npm command designed for automated environments and, paired with a committed lockfile, guarantees reproducible installs. Additionally, --ignore-scripts suppresses pre/postinstall hooks, reducing attack surface.

However, .github/changeset-preview/ does not currently have a committed package-lock.json or npm-shrinkwrap.json. Generate the lockfile first by running npm install locally, then commit it:

  1. Run npm install locally in .github/changeset-preview/ to generate package-lock.json
  2. Commit package-lock.json to the repository
  3. Update the action to use:
Suggested change
-      run: npm install
+      run: npm ci --ignore-scripts
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/changeset-preview/action.yml around lines 6 - 9, The workflow step
"Install dependencies" in .github/changeset-preview/action.yml should use a
frozen CI install and a committed lockfile: generate a package-lock.json (or
npm-shrinkwrap.json) for .github/changeset-preview by running npm install
locally and commit that lockfile, then update the "Install dependencies" step
(the one named "Install dependencies") to run npm ci --ignore-scripts in the
working directory instead of npm install to ensure deterministic, script-free
installs in CI.

- name: Preview version bumps
shell: bash
run: node ${{ github.action_path }}/preview-changeset-versions.mjs --output /tmp/changeset-preview.md
Expand Down
5 changes: 5 additions & 0 deletions .github/changeset-preview/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"@changesets/get-release-plan": "^4.0.15"
}
}
167 changes: 48 additions & 119 deletions .github/changeset-preview/preview-changeset-versions.mjs
Original file line number Diff line number Diff line change
@@ -1,81 +1,21 @@
#!/usr/bin/env node

/**
* Preview the version bumps that `changeset version` will produce.
*
* Workflow:
* 1. Snapshot every workspace package's current version
* 2. Run `changeset version` (mutates package.json files)
* 3. Diff against the snapshot
* 4. Print a markdown summary (or write to --output file)
*
* This script is meant to run in CI on a disposable checkout β€” it does NOT
* revert the changes it makes.
* Uses `@changesets/get-release-plan` to get the version bumps and formats it as markdown.
*/

import { execSync } from 'node:child_process'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import getReleasePlan from '@changesets/get-release-plan'

const ROOT = resolve(import.meta.dirname, '..', '..')

const PACKAGES_DIR = join(ROOT, 'packages')

function readPackageVersions() {
const versions = new Map()
for (const dir of readdirSync(PACKAGES_DIR, { withFileTypes: true })) {
if (!dir.isDirectory()) continue
const pkgPath = join(PACKAGES_DIR, dir.name, 'package.json')
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.name && pkg.version && pkg.private !== true) {
versions.set(pkg.name, pkg.version)
}
} catch {
// skip packages without a valid package.json
}
}
return versions
function reasonRank(reason) {
return reason === 'Changeset' ? 2 : 1
}

function readChangesetEntries() {
const changesetDir = join(ROOT, '.changeset')
const explicit = new Map()
for (const file of readdirSync(changesetDir)) {
if (file === 'config.json' || file === 'README.md' || !file.endsWith('.md'))
continue
const content = readFileSync(join(changesetDir, file), 'utf8')
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (!frontmatterMatch) continue
for (const line of frontmatterMatch[1].split('\n')) {
const match = line.match(/^['"]?([^'"]+)['"]?\s*:\s*(major|minor|patch)/)
if (match) {
const [, name, bump] = match
const existing = explicit.get(name)
// keep the highest bump if a package appears in multiple changesets
if (!existing || bumpRank(bump) > bumpRank(existing)) {
explicit.set(name, bump)
}
}
}
}
return explicit
}

function bumpRank(bump) {
return bump === 'major' ? 3 : bump === 'minor' ? 2 : 1
}

function bumpType(oldVersion, newVersion) {
const [oMaj, oMin] = oldVersion.split('.').map(Number)
const [nMaj, nMin] = newVersion.split('.').map(Number)
if (nMaj > oMaj) return 'major'
if (nMin > oMin) return 'minor'
return 'patch'
}

function main() {
async function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
Expand All @@ -85,10 +25,10 @@ function main() {
allowPositionals: false,
})

// 1. Read explicit changeset entries
const explicit = readChangesetEntries()
const releasePlan = await getReleasePlan(ROOT)
const releases = releasePlan.releases

if (explicit.size === 0) {
if (releases.length === 0) {
const msg =
'No changeset entries found. Merging this PR will not cause a version bump for any packages.\n'
process.stdout.write(msg)
Expand All @@ -99,42 +39,19 @@ function main() {
return
}

// 2. Snapshot current versions
const before = readPackageVersions()

// 3. Temporarily swap changeset config to skip changelog generation
// (the GitHub changelog plugin requires a token we don't need for previews)
const configPath = join(ROOT, '.changeset', 'config.json')
const originalConfig = readFileSync(configPath, 'utf8')
try {
const config = JSON.parse(originalConfig)
config.changelog = false
writeFileSync(configPath, JSON.stringify(config, null, 2))

// 4. Run changeset version
execSync('pnpm changeset version', { cwd: ROOT, stdio: 'pipe' })
} finally {
// Always restore the original config
writeFileSync(configPath, originalConfig)
}

// 5. Read new versions
const after = readPackageVersions()

// 6. Diff
const bumps = []
for (const [name, newVersion] of after) {
const oldVersion = before.get(name)
if (!oldVersion || oldVersion === newVersion) continue
const bump = bumpType(oldVersion, newVersion)
const source = explicit.has(name) ? explicit.get(name) : 'dependency'
bumps.push({ name, oldVersion, newVersion, bump, source })
for (const release of releases) {
if (release.oldVersion === release.newVersion) continue
const reason = release.changesets.length !== 0 ? 'Changeset' : 'Dependent'
bumps.push({ ...release, reason })
}

// Sort: major first, then minor, then patch; within each group alphabetical
// Order by reason and name
bumps.sort(
(a, b) =>
bumpRank(b.bump) - bumpRank(a.bump) || a.name.localeCompare(b.name),
reasonRank(b.reason) - reasonRank(a.reason) ||
a.name.localeCompare(b.name),
)

// 7. Build markdown
Expand All @@ -145,41 +62,53 @@ function main() {
'No version changes detected. Merging this PR will not cause a version bump for any packages.',
)
} else {
const explicitBumps = bumps.filter((b) => b.source !== 'dependency')
const dependencyBumps = bumps.filter((b) => b.source === 'dependency')
const majorBumps = bumps.filter((b) => b.type === 'major')
const minorBumps = bumps.filter((b) => b.type === 'minor')
const patchBumps = bumps.filter((b) => b.type === 'patch')
const directBumps = bumps.filter((b) => b.reason === 'Changeset')
const indirectBumps = bumps.filter((b) => b.reason === 'Dependent')

lines.push(
`**${explicitBumps.length}** package(s) bumped directly, **${dependencyBumps.length}** bumped as dependents.`,
`**${directBumps.length}** package(s) bumped directly, **${indirectBumps.length}** bumped as dependents.`,
)
lines.push('')

if (explicitBumps.length > 0) {
lines.push('### Direct bumps')
if (majorBumps.length > 0) {
lines.push('### πŸŸ₯ Major bumps')
lines.push('')
lines.push('| Package | Version | Reason |')
lines.push('| --- | --- | --- |')
for (const b of majorBumps) {
lines.push(
`| \`${b.name}\` | ${b.oldVersion} β†’ ${b.newVersion} | ${b.reason} |`,
)
}
lines.push('')
}

if (minorBumps.length > 0) {
lines.push('### 🟨 Minor bumps')
lines.push('')
lines.push('| Package | Bump | Version |')
lines.push('| Package | Version | Reason |')
lines.push('| --- | --- | --- |')
for (const b of explicitBumps) {
for (const b of minorBumps) {
lines.push(
`| \`${b.name}\` | **${b.bump}** | ${b.oldVersion} β†’ ${b.newVersion} |`,
`| \`${b.name}\` | ${b.oldVersion} β†’ ${b.newVersion} | ${b.reason} |`,
)
}
lines.push('')
}

if (dependencyBumps.length > 0) {
lines.push(
'<details>',
`<summary>Dependency bumps (${dependencyBumps.length})</summary>`,
'',
'| Package | Bump | Version |',
'| --- | --- | --- |',
)
for (const b of dependencyBumps) {
if (patchBumps.length > 0) {
lines.push('### 🟩 Patch bumps')
lines.push('')
lines.push('| Package | Version | Reason |')
lines.push('| --- | --- | --- |')
for (const b of patchBumps) {
lines.push(
`| \`${b.name}\` | ${b.bump} | ${b.oldVersion} β†’ ${b.newVersion} |`,
`| \`${b.name}\` | ${b.oldVersion} β†’ ${b.newVersion} | ${b.reason} |`,
)
}
lines.push('', '</details>')
}
}

Expand Down
2 changes: 0 additions & 2 deletions .github/changeset-preview/upsert-pr-comment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ async function main() {
const rawBody = await fsp.readFile(bodyPath, 'utf8')
const body = `${args.marker}\n## πŸš€ Changeset Version Preview\n\n${rawBody}`

process.stdout.write(body)

const comments = await listIssueComments({
apiUrl: args.apiUrl,
token: args.token,
Expand Down
Loading