Skip to content

Commit 4dee64d

Browse files
authored
feat(workflow): generate diff-accurate upgrade-deps PR descriptions via Claude (#1402)
Previously the `Upgrade Upstream Dependencies` workflow shipped a generic template commit message and PR body (see #1401) that didn't reflect what actually changed. This PR rewires the workflow so the commit and PR description are generated from the real diff on every run. ## What's new ### Diff-accurate descriptions - `.github/scripts/upgrade-deps.mjs` records old → new for every dep it touches (including rolldown/vite tag + short SHA) and writes `versions.json`, `commit-message.txt`, and `pr-body.md` to `$UPGRADE_DEPS_META_DIR` (in `$RUNNER_TEMP`, so they aren't committed). - `.github/workflows/upgrade-deps.yml`: - New `Set up metadata directory` step exports `UPGRADE_DEPS_META_DIR` via `$GITHUB_ENV`. - New `Enhance PR description with Claude` step reads the baseline files plus `git diff` and overwrites them with a Summary, a dependency table, a Code-changes list, and a Build-status block. - New `Read generated PR content` step exposes the files as multi-line step outputs (with a trailing-newline guard so the heredoc terminator always lands on its own line). - `peter-evans/create-pull-request` now consumes those outputs instead of a static template body. - If the enhancement step fails, `continue-on-error: true` keeps the workflow going and the baseline content from the Node script ships instead of a generic message. ### Tightened `Check upgrade dependencies` prompt - Single authoritative checklist: Background → Fixups (in order) → Final validation → Commit rule. The previous prompt had three overlapping sections describing the same checks. - Final validation requires BOTH `just build` AND `pnpm bootstrap-cli:ci && pnpm test` to pass, plus a manual snap-test diff inspection (because `pnpm test` always exits 0 even on snapshot drift, so the agent has to look at the diff itself). - New `Running long commands` rule forbids backgrounding (`&`, `nohup`, `disown`, …) and polling (`ps`, `pgrep`, `sleep` loops, repeated `ls` on build artifacts). Run 24545325671 spent 30+ minutes spinning in a `ps aux | grep "just build"` loop before this rule landed. ### Safety bounds on the Claude session - `--max-turns 200` caps total tool calls so a runaway agent can't consume an entire job budget. - `timeout-minutes: 180` on the step is a belt-and-suspenders cap on wall-clock time. - Both `anthropics/claude-code-action` pins bumped to v1.0.99 (Claude Code 2.1.112). ### Script hygiene - `getLatestTag` uses `?per_page=1` and the rolldown + vite fetches run in parallel via `Promise.all`. - `updatePnpmWorkspace` is now a single-pass `String.replace` callback with one capture group per pattern (avoids the foot-gun where the replace callback's positional `suffix` arg silently received the match offset and corrupted versions). Throws explicitly if a pattern goes stale instead of silently recording a no-op change. - `updateCorePackage` early-exits when `@vitejs/devtools` isn't present, skipping the no-op JSON rewrite.
1 parent ee4e19d commit 4dee64d

File tree

2 files changed

+347
-87
lines changed

2 files changed

+347
-87
lines changed

.github/scripts/upgrade-deps.mjs

Lines changed: 177 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,29 @@ import fs from 'node:fs';
22
import path from 'node:path';
33

44
const ROOT = process.cwd();
5+
const META_DIR = process.env.UPGRADE_DEPS_META_DIR;
6+
7+
const isFullSha = (s) => /^[0-9a-f]{40}$/.test(s);
8+
9+
/** @type {Map<string, { old: string | null, new: string, tag?: string }>} */
10+
const changes = new Map();
11+
12+
function recordChange(name, oldValue, newValue, tag) {
13+
const entry = { old: oldValue ?? null, new: newValue };
14+
if (tag) {
15+
entry.tag = tag;
16+
}
17+
changes.set(name, entry);
18+
if (oldValue !== newValue) {
19+
console.log(` ${name}: ${oldValue ?? '(unset)'} -> ${newValue}`);
20+
} else {
21+
console.log(` ${name}: ${newValue} (unchanged)`);
22+
}
23+
}
524

625
// ============ GitHub API ============
7-
async function getLatestTagCommit(owner, repo) {
8-
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/tags`, {
26+
async function getLatestTag(owner, repo) {
27+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/tags?per_page=1`, {
928
headers: {
1029
Authorization: `token ${process.env.GITHUB_TOKEN}`,
1130
Accept: 'application/vnd.github.v3+json',
@@ -18,11 +37,11 @@ async function getLatestTagCommit(owner, repo) {
1837
if (!Array.isArray(tags) || !tags.length) {
1938
throw new Error(`No tags found for ${owner}/${repo}`);
2039
}
21-
if (!tags[0]?.commit?.sha) {
22-
throw new Error(`Invalid tag structure for ${owner}/${repo}: missing commit SHA`);
40+
if (!tags[0]?.commit?.sha || !tags[0]?.name) {
41+
throw new Error(`Invalid tag structure for ${owner}/${repo}: missing SHA or name`);
2342
}
24-
console.log(`${repo} -> ${tags[0].name}`);
25-
return tags[0].commit.sha;
43+
console.log(`${repo} -> ${tags[0].name} (${tags[0].commit.sha.slice(0, 7)})`);
44+
return { sha: tags[0].commit.sha, tag: tags[0].name };
2645
}
2746

2847
// ============ npm Registry ============
@@ -45,11 +64,16 @@ async function updateUpstreamVersions() {
4564
const filePath = path.join(ROOT, 'packages/tools/.upstream-versions.json');
4665
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
4766

48-
// rolldown -> rolldown/rolldown
49-
data.rolldown.hash = await getLatestTagCommit('rolldown', 'rolldown');
50-
51-
// vite -> vitejs/vite
52-
data['vite'].hash = await getLatestTagCommit('vitejs', 'vite');
67+
const oldRolldownHash = data.rolldown.hash;
68+
const oldViteHash = data['vite'].hash;
69+
const [rolldown, vite] = await Promise.all([
70+
getLatestTag('rolldown', 'rolldown'),
71+
getLatestTag('vitejs', 'vite'),
72+
]);
73+
data.rolldown.hash = rolldown.sha;
74+
data['vite'].hash = vite.sha;
75+
recordChange('rolldown', oldRolldownHash, rolldown.sha, rolldown.tag);
76+
recordChange('vite', oldViteHash, vite.sha, vite.tag);
5377

5478
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
5579
console.log('Updated .upstream-versions.json');
@@ -60,38 +84,66 @@ async function updatePnpmWorkspace(versions) {
6084
const filePath = path.join(ROOT, 'pnpm-workspace.yaml');
6185
let content = fs.readFileSync(filePath, 'utf8');
6286

63-
// Update vitest-dev override (handle pre-release versions like -beta.1, -rc.0)
64-
content = content.replace(
65-
/vitest-dev: npm:vitest@\^[\d.]+(-[\w.]+)?/,
66-
`vitest-dev: npm:vitest@^${versions.vitest}`,
67-
);
68-
69-
// Update tsdown in catalog (handle pre-release versions)
70-
content = content.replace(/tsdown: \^[\d.]+(-[\w.]+)?/, `tsdown: ^${versions.tsdown}`);
71-
72-
// Update @oxc-node/cli in catalog
73-
content = content.replace(
74-
/'@oxc-node\/cli': \^[\d.]+(-[\w.]+)?/,
75-
`'@oxc-node/cli': ^${versions.oxcNodeCli}`,
76-
);
77-
78-
// Update @oxc-node/core in catalog
79-
content = content.replace(
80-
/'@oxc-node\/core': \^[\d.]+(-[\w.]+)?/,
81-
`'@oxc-node/core': ^${versions.oxcNodeCore}`,
82-
);
83-
84-
// Update oxfmt in catalog
85-
content = content.replace(/oxfmt: =[\d.]+(-[\w.]+)?/, `oxfmt: =${versions.oxfmt}`);
86-
87-
// Update oxlint in catalog (but not oxlint-tsgolint)
88-
content = content.replace(/oxlint: =[\d.]+(-[\w.]+)?\n/, `oxlint: =${versions.oxlint}\n`);
87+
// oxlint's trailing \n in the pattern disambiguates from oxlint-tsgolint.
88+
const entries = [
89+
{
90+
name: 'vitest',
91+
pattern: /vitest-dev: npm:vitest@\^([\d.]+(?:-[\w.]+)?)/,
92+
replacement: `vitest-dev: npm:vitest@^${versions.vitest}`,
93+
newVersion: versions.vitest,
94+
},
95+
{
96+
name: 'tsdown',
97+
pattern: /tsdown: \^([\d.]+(?:-[\w.]+)?)/,
98+
replacement: `tsdown: ^${versions.tsdown}`,
99+
newVersion: versions.tsdown,
100+
},
101+
{
102+
name: '@oxc-node/cli',
103+
pattern: /'@oxc-node\/cli': \^([\d.]+(?:-[\w.]+)?)/,
104+
replacement: `'@oxc-node/cli': ^${versions.oxcNodeCli}`,
105+
newVersion: versions.oxcNodeCli,
106+
},
107+
{
108+
name: '@oxc-node/core',
109+
pattern: /'@oxc-node\/core': \^([\d.]+(?:-[\w.]+)?)/,
110+
replacement: `'@oxc-node/core': ^${versions.oxcNodeCore}`,
111+
newVersion: versions.oxcNodeCore,
112+
},
113+
{
114+
name: 'oxfmt',
115+
pattern: /oxfmt: =([\d.]+(?:-[\w.]+)?)/,
116+
replacement: `oxfmt: =${versions.oxfmt}`,
117+
newVersion: versions.oxfmt,
118+
},
119+
{
120+
name: 'oxlint',
121+
pattern: /oxlint: =([\d.]+(?:-[\w.]+)?)\n/,
122+
replacement: `oxlint: =${versions.oxlint}\n`,
123+
newVersion: versions.oxlint,
124+
},
125+
{
126+
name: 'oxlint-tsgolint',
127+
pattern: /oxlint-tsgolint: =([\d.]+(?:-[\w.]+)?)/,
128+
replacement: `oxlint-tsgolint: =${versions.oxlintTsgolint}`,
129+
newVersion: versions.oxlintTsgolint,
130+
},
131+
];
89132

90-
// Update oxlint-tsgolint in catalog
91-
content = content.replace(
92-
/oxlint-tsgolint: =[\d.]+(-[\w.]+)?/,
93-
`oxlint-tsgolint: =${versions.oxlintTsgolint}`,
94-
);
133+
for (const { name, pattern, replacement, newVersion } of entries) {
134+
let oldVersion;
135+
content = content.replace(pattern, (_match, captured) => {
136+
oldVersion = captured;
137+
return replacement;
138+
});
139+
if (oldVersion === undefined) {
140+
throw new Error(
141+
`Failed to match ${name} in pnpm-workspace.yaml — the pattern ${pattern} is stale, ` +
142+
`please update it in .github/scripts/upgrade-deps.mjs`,
143+
);
144+
}
145+
recordChange(name, oldVersion, newVersion);
146+
}
95147

96148
fs.writeFileSync(filePath, content);
97149
console.log('Updated pnpm-workspace.yaml');
@@ -128,15 +180,93 @@ async function updateCorePackage(devtoolsVersion) {
128180
const filePath = path.join(ROOT, 'packages/core/package.json');
129181
const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
130182

131-
// Update @vitejs/devtools in devDependencies
132-
if (pkg.devDependencies?.['@vitejs/devtools']) {
133-
pkg.devDependencies['@vitejs/devtools'] = `^${devtoolsVersion}`;
183+
const currentDevtools = pkg.devDependencies?.['@vitejs/devtools'];
184+
if (!currentDevtools) {
185+
return;
134186
}
187+
pkg.devDependencies['@vitejs/devtools'] = `^${devtoolsVersion}`;
188+
recordChange('@vitejs/devtools', currentDevtools.replace(/^[\^~]/, ''), devtoolsVersion);
135189

136190
fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n');
137191
console.log('Updated packages/core/package.json');
138192
}
139193

194+
// ============ Write metadata files for PR description ============
195+
function writeMetaFiles() {
196+
if (!META_DIR) {
197+
return;
198+
}
199+
200+
fs.mkdirSync(META_DIR, { recursive: true });
201+
202+
const versionsObj = Object.fromEntries(changes);
203+
fs.writeFileSync(
204+
path.join(META_DIR, 'versions.json'),
205+
JSON.stringify(versionsObj, null, 2) + '\n',
206+
);
207+
208+
const changed = [...changes.entries()].filter(([, v]) => v.old !== v.new);
209+
const unchanged = [...changes.entries()].filter(([, v]) => v.old === v.new);
210+
211+
const formatVersion = (v) => {
212+
if (v.tag) {
213+
return `${v.tag} (${v.new.slice(0, 7)})`;
214+
}
215+
if (isFullSha(v.new)) {
216+
return v.new.slice(0, 7);
217+
}
218+
return v.new;
219+
};
220+
const formatOld = (v) => {
221+
if (!v.old) {
222+
return '(unset)';
223+
}
224+
if (isFullSha(v.old)) {
225+
return v.old.slice(0, 7);
226+
}
227+
return v.old;
228+
};
229+
230+
const commitLines = ['feat(deps): upgrade upstream dependencies', ''];
231+
if (changed.length) {
232+
for (const [name, v] of changed) {
233+
commitLines.push(`- ${name}: ${formatOld(v)} -> ${formatVersion(v)}`);
234+
}
235+
} else {
236+
commitLines.push('- no version changes detected');
237+
}
238+
commitLines.push('');
239+
fs.writeFileSync(path.join(META_DIR, 'commit-message.txt'), commitLines.join('\n'));
240+
241+
const bodyLines = ['## Summary', ''];
242+
if (changed.length) {
243+
bodyLines.push('Automated daily upgrade of upstream dependencies.');
244+
} else {
245+
bodyLines.push('Automated daily upgrade run — no upstream version changes detected.');
246+
}
247+
bodyLines.push('', '## Dependency updates', '');
248+
if (changed.length) {
249+
bodyLines.push('| Package | From | To |');
250+
bodyLines.push('| --- | --- | --- |');
251+
for (const [name, v] of changed) {
252+
bodyLines.push(`| \`${name}\` | \`${formatOld(v)}\` | \`${formatVersion(v)}\` |`);
253+
}
254+
} else {
255+
bodyLines.push('_No version changes._');
256+
}
257+
if (unchanged.length) {
258+
bodyLines.push('', '<details><summary>Unchanged dependencies</summary>', '');
259+
for (const [name, v] of unchanged) {
260+
bodyLines.push(`- \`${name}\`: \`${formatVersion(v)}\``);
261+
}
262+
bodyLines.push('', '</details>');
263+
}
264+
bodyLines.push('', '## Code changes', '', '_No additional code changes recorded._', '');
265+
fs.writeFileSync(path.join(META_DIR, 'pr-body.md'), bodyLines.join('\n'));
266+
267+
console.log(`Wrote metadata files to ${META_DIR}`);
268+
}
269+
140270
console.log('Fetching latest versions…');
141271

142272
const [
@@ -181,4 +311,6 @@ await updatePnpmWorkspace({
181311
await updateTestPackage(vitestVersion);
182312
await updateCorePackage(devtoolsVersion);
183313

314+
writeMetaFiles();
315+
184316
console.log('Done!');

0 commit comments

Comments
 (0)