Skip to content

Commit 29d7e8d

Browse files
committed
add changelog extraction and signature computation for provenance
1 parent a301b7e commit 29d7e8d

5 files changed

Lines changed: 376 additions & 54 deletions

File tree

packages/skillc/src/buildLib.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
3-
import { fetchDocsFromRepo } from './fetchDocs.js'
3+
import { ensureRepoCheckout, fetchDocsFromRepo } from './fetchDocs.js'
44
import { indexDocsSnapshot } from './indexDocs.js'
55
import { buildTopicsIndex } from './buildTopics.js'
66
import { renderSkillMd } from './renderSkill.js'
77
import { renderLibraryAgent } from './renderLibraryAgent.js'
88
import { renderTanstackAgent } from './renderTanstackAgent.js'
9-
import { computeTemplatesHash, writeProvenance } from './provenance.js'
9+
import {
10+
computeSignature,
11+
computeTemplatesHash,
12+
writeProvenance,
13+
} from './provenance.js'
1014
import { readCatalog } from './catalog.js'
1115
import { git } from './git.js'
16+
import { extractRecentChanges } from './changelog.js'
1217

1318
type CatalogRepo = {
1419
repo: string
@@ -103,10 +108,18 @@ function writeProvenanceFromExisting(args: {
103108
commitSha: string
104109
templatesHash: string
105110
compilerVersion: string
111+
changelogHash: string
106112
}) {
107113
const provenancePath = path.join(args.outDir, 'provenance.json')
108114
const prev = readJsonIfExists<any>(provenancePath) ?? {}
109115

116+
const signature = computeSignature({
117+
docsHash: prev.docs_hash ?? 'sha256:0',
118+
templatesHash: args.templatesHash,
119+
changelogHash: args.changelogHash,
120+
compilerVersion: args.compilerVersion,
121+
})
122+
110123
const provenance = {
111124
schema: 1,
112125
lib: prev.lib,
@@ -116,8 +129,9 @@ function writeProvenanceFromExisting(args: {
116129
commitSha: args.commitSha,
117130
docs_hash: prev.docs_hash,
118131
templates_hash: args.templatesHash,
132+
changelog_hash: args.changelogHash,
119133
compiler_version: args.compilerVersion,
120-
signature: prev.signature,
134+
signature,
121135
generated_at: new Date().toISOString(),
122136
}
123137

@@ -221,21 +235,33 @@ export async function buildLib(args: {
221235
throw err
222236
}
223237

238+
const { worktree } = ensureRepoCheckout({ repoUrl, ref })
239+
const { lines: recentChanges, hash: changelogHash } =
240+
extractRecentChanges({
241+
worktree,
242+
version: args.version,
243+
tagPattern: pattern,
244+
})
245+
224246
const existingBuild = readJsonIfExists<any>(
225247
path.join(outAbs, 'build.json'),
226248
)
227249
const existingProv = readJsonIfExists<any>(
228250
path.join(outAbs, 'provenance.json'),
229251
)
230252

231-
if (
253+
const matchesExistingCore =
232254
existingBuild &&
233255
existingProv &&
234256
existingBuild.repo === repoUrl &&
235257
existingBuild.ref === ref &&
236258
existingBuild.commitSha === commitSha &&
237259
existingProv.templates_hash === templatesHash &&
238260
existingProv.compiler_version === compilerVersion
261+
262+
if (
263+
matchesExistingCore &&
264+
existingProv.changelog_hash === changelogHash
239265
) {
240266
console.log(`Skill build result: skipped (${args.lib}@${args.version})`)
241267
console.log(
@@ -244,9 +270,53 @@ export async function buildLib(args: {
244270
return
245271
}
246272

273+
if (matchesExistingCore) {
274+
console.log(
275+
`Skill build result: updated-changelog (${args.lib}@${args.version})`,
276+
)
277+
console.log(
278+
`Updating changelog for ${args.lib}@${args.version} from ${repoUrl}@${ref} (${commitSha}).`,
279+
)
280+
281+
renderSkillMd({
282+
library: args.lib,
283+
version: args.version,
284+
outDir: outAbs,
285+
recentChanges,
286+
})
287+
288+
writeProvenanceFromExisting({
289+
outDir: outAbs,
290+
version: args.version,
291+
repoUrl,
292+
ref,
293+
commitSha,
294+
templatesHash,
295+
compilerVersion,
296+
changelogHash,
297+
})
298+
299+
const buildJson = {
300+
lib: args.lib,
301+
version: args.version,
302+
repo: repoUrl,
303+
ref,
304+
commitSha,
305+
generatedAt: new Date().toISOString(),
306+
}
307+
308+
fs.writeFileSync(
309+
path.join(outAbs, 'build.json'),
310+
JSON.stringify(buildJson, null, 2) + '\n',
311+
)
312+
313+
return
314+
}
315+
247316
const latest = findLatestBuild({ repoRoot: args.repoRoot, lib: args.lib })
248317
const canReuseLatest =
249318
latest &&
319+
latest.dir !== outAbs &&
250320
latest.build?.repo === repoUrl &&
251321
latest.build?.commitSha === commitSha &&
252322
latest.provenance?.templates_hash === templatesHash &&
@@ -263,6 +333,12 @@ export async function buildLib(args: {
263333
cloneDirWithHardlinks(latest.dir, outAbs)
264334

265335
updateSkillVersion(outAbs, args.version)
336+
renderSkillMd({
337+
library: args.lib,
338+
version: args.version,
339+
outDir: outAbs,
340+
recentChanges,
341+
})
266342
writeProvenanceFromExisting({
267343
outDir: outAbs,
268344
version: args.version,
@@ -271,6 +347,7 @@ export async function buildLib(args: {
271347
commitSha,
272348
templatesHash,
273349
compilerVersion,
350+
changelogHash,
274351
})
275352

276353
const buildJson = {
@@ -319,7 +396,12 @@ export async function buildLib(args: {
319396
version: args.version,
320397
outDir,
321398
})
322-
renderSkillMd({ library: args.lib, version: args.version, outDir })
399+
renderSkillMd({
400+
library: args.lib,
401+
version: args.version,
402+
outDir,
403+
recentChanges,
404+
})
323405

324406
writeProvenance({
325407
lib: args.lib,
@@ -329,6 +411,7 @@ export async function buildLib(args: {
329411
indexRoot,
330412
compiler: { name: '@tanstack/skillc', version: compilerVersion },
331413
templatesRoot,
414+
changelogHash,
332415
})
333416

334417
const revisionPath = path.join(sourceRoot, 'REVISION.json')

packages/skillc/src/changelog.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import crypto from 'node:crypto'
2+
import path from 'node:path'
3+
import { git } from './git.js'
4+
5+
const keywordRegex =
6+
/\b(breaking|migration|migrations|deprecat|fix|bug|security)\b/i
7+
8+
function sha256(input: string | Buffer) {
9+
return 'sha256:' + crypto.createHash('sha256').update(input).digest('hex')
10+
}
11+
12+
function parsePackageNameFromTagPattern(pattern?: string) {
13+
if (!pattern) return undefined
14+
const cleaned = pattern
15+
.replace('{version}', '')
16+
.replace(/[@._-]+$/, '')
17+
.trim()
18+
19+
if (/^@[^/]+\/[^@]+$/.test(cleaned)) return cleaned
20+
if (/^[a-z0-9][\w.-]*$/i.test(cleaned)) return cleaned
21+
return undefined
22+
}
23+
24+
function listRepoFiles(worktree: string) {
25+
const res = git(['ls-tree', '-r', '--name-only', 'HEAD'], worktree)
26+
return res.stdout.split(/\r?\n/).filter(Boolean)
27+
}
28+
29+
function readGitFile(worktree: string, filePath: string) {
30+
const res = git(['show', `HEAD:${filePath}`], worktree)
31+
return res.stdout
32+
}
33+
34+
function findPackageDir(args: {
35+
worktree: string
36+
packageName: string
37+
repoFiles: string[]
38+
}) {
39+
const packageJsonPaths = args.repoFiles.filter(
40+
(file) => file.startsWith('packages/') && file.endsWith('/package.json'),
41+
)
42+
43+
for (const pkgPath of packageJsonPaths) {
44+
try {
45+
const pkgJson = JSON.parse(readGitFile(args.worktree, pkgPath))
46+
if (pkgJson?.name === args.packageName) {
47+
return path.posix.dirname(pkgPath)
48+
}
49+
} catch (error) {
50+
continue
51+
}
52+
}
53+
54+
return undefined
55+
}
56+
57+
function findChangelogPath(args: {
58+
worktree: string
59+
repoFiles: string[]
60+
packageDir?: string
61+
}) {
62+
const candidates: string[] = []
63+
64+
if (args.packageDir) {
65+
candidates.push(
66+
path.posix.join(args.packageDir, 'CHANGELOG.md'),
67+
path.posix.join(args.packageDir, 'CHANGELOG.mdx'),
68+
)
69+
}
70+
71+
candidates.push('CHANGELOG.md', 'CHANGELOG.mdx')
72+
73+
for (const candidate of candidates) {
74+
if (args.repoFiles.includes(candidate)) return candidate
75+
}
76+
77+
return undefined
78+
}
79+
80+
function extractVersionSection(args: {
81+
markdown: string
82+
version: string
83+
packageName?: string
84+
}) {
85+
const lines = args.markdown.split(/\r?\n/)
86+
const patterns = [
87+
args.packageName ? `${args.packageName}@${args.version}` : undefined,
88+
`v${args.version}`,
89+
args.version,
90+
].filter(Boolean) as string[]
91+
92+
let startIndex = -1
93+
let headingLevel = 0
94+
95+
for (let i = 0; i < lines.length; i += 1) {
96+
const line = lines[i]
97+
const match = line.match(/^(#{2,4})\s+(.*)$/)
98+
if (!match) continue
99+
const title = match[2]
100+
if (patterns.some((p) => title.includes(p))) {
101+
startIndex = i + 1
102+
headingLevel = match[1].length
103+
break
104+
}
105+
}
106+
107+
if (startIndex === -1) return []
108+
109+
const section: string[] = []
110+
for (let i = startIndex; i < lines.length; i += 1) {
111+
const line = lines[i]
112+
const match = line.match(/^(#{1,6})\s+/)
113+
if (match && match[1].length <= headingLevel) break
114+
section.push(line)
115+
}
116+
117+
return section
118+
}
119+
120+
function filterUserFacing(lines: string[]) {
121+
const out: string[] = []
122+
let pendingHeadings: string[] = []
123+
124+
for (const line of lines) {
125+
if (/^#{2,6}\s+/.test(line)) {
126+
pendingHeadings = [line]
127+
if (keywordRegex.test(line)) {
128+
out.push(...pendingHeadings)
129+
pendingHeadings = []
130+
}
131+
continue
132+
}
133+
134+
if (!keywordRegex.test(line)) continue
135+
136+
if (pendingHeadings.length) {
137+
out.push(...pendingHeadings)
138+
pendingHeadings = []
139+
}
140+
141+
out.push(line)
142+
}
143+
144+
return out
145+
}
146+
147+
export function extractRecentChanges(args: {
148+
worktree: string
149+
version: string
150+
tagPattern?: string
151+
}) {
152+
const repoFiles = listRepoFiles(args.worktree)
153+
const packageName = parsePackageNameFromTagPattern(args.tagPattern)
154+
const packageDir = packageName
155+
? findPackageDir({
156+
worktree: args.worktree,
157+
packageName,
158+
repoFiles,
159+
})
160+
: undefined
161+
162+
const changelogPath = findChangelogPath({
163+
worktree: args.worktree,
164+
repoFiles,
165+
packageDir,
166+
})
167+
168+
if (!changelogPath) {
169+
return { lines: [], hash: sha256('') }
170+
}
171+
172+
const markdown = readGitFile(args.worktree, changelogPath)
173+
const section = extractVersionSection({
174+
markdown,
175+
version: args.version,
176+
packageName,
177+
})
178+
const lines = filterUserFacing(section)
179+
const hash = sha256(lines.join('\n'))
180+
181+
return { lines, hash }
182+
}

0 commit comments

Comments
 (0)