kaloko 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ // `kaloko changelog draft` and `kaloko release` (docs/DOCS-CHANGELOG.md, phase 1): products from kaloko.config.yml,
2
+ // the version source the repository already uses (never fighting a release tool), merged pull requests and commits
3
+ // since the last release, the steps the accepted runs show differently (from the service), the draft entry and the
4
+ // release record. Parsing and the draft itself are in changelog-core.mjs, shared with the service.
5
+ import { readFile, writeFile, access, readdir } from 'node:fs/promises'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { join, resolve, relative, dirname, basename } from 'node:path'
9
+ import { UserError, sha256 } from './util.mjs'
10
+ import { repoSlug } from './git.mjs'
11
+ import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
12
+
13
+ const run = promisify(execFile)
14
+ async function out (cmd, args, cwd, timeout = 20000) {
15
+ try { return (await run(cmd, args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024 })).stdout } catch { return null }
16
+ }
17
+ const exists = async (f) => { try { await access(f); return true } catch { return false } }
18
+ const readJson = async (f) => { try { return JSON.parse(await readFile(f, 'utf8')) } catch { return null } }
19
+ const readText = async (f) => { try { return await readFile(f, 'utf8') } catch { return null } }
20
+ export const today = () => new Date().toISOString().slice(0, 10)
21
+
22
+ // ---------- products ----------
23
+
24
+ const slugOf = (s) => String(s || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80)
25
+
26
+ /**
27
+ * Products of the configuration, normalized. Without `products:` the project is the product (implicit): its slug is
28
+ * the config's project, else the directory name.
29
+ */
30
+ export function productsOf (config, root = process.cwd()) {
31
+ const docs = config.docs || {}
32
+ const list = Object.entries(config.products || {}).map(([slug, p]) => ({
33
+ slug, implicit: false, name: p.name || slug,
34
+ projects: [...new Set([p.project, ...(p.projects || [])].filter(Boolean))],
35
+ modules: p.modules || [], reference_env: p.reference_env || null, default_scenario: p.default_scenario || null,
36
+ version: p.version || null, docs: { ...docs, ...(p.docs || {}) }, publish: p.publish || docs.publish || null
37
+ }))
38
+ if (list.length) return list
39
+ const slug = slugOf(config.project || basename(root)) || 'app'
40
+ return [{ slug, implicit: true, name: config.project || slug, projects: [slug], modules: [], reference_env: null, default_scenario: null, version: null, docs, publish: docs.publish || null }]
41
+ }
42
+
43
+ /** The product a command works on: --product, else the only one. */
44
+ export function pickProduct (config, root, name) {
45
+ const all = productsOf(config, root)
46
+ if (typeof name === 'string' && name) {
47
+ const p = all.find((x) => x.slug === name)
48
+ if (!p) throw new UserError(`No product ${name} in the config (${all.map((x) => x.slug).join(', ')}).`)
49
+ return p
50
+ }
51
+ if (all.length > 1) throw new UserError(`The config has several products; pass --product (${all.map((x) => x.slug).join(', ')}).`)
52
+ return all[0]
53
+ }
54
+
55
+ export const changelogFile = (root, product) => resolve(root, product.docs?.changelog || 'CHANGELOG.md')
56
+
57
+ // ---------- the version source ----------
58
+
59
+ const SEMREL = ['.releaserc', '.releaserc.json', '.releaserc.yml', '.releaserc.yaml', '.releaserc.js', '.releaserc.cjs', 'release.config.js', 'release.config.cjs', 'release.config.mjs']
60
+
61
+ /**
62
+ * Where the product's version comes from, as the repository already decides it: { source, path, pattern, version,
63
+ * owns_changelog, tool }. The config wins (products.<p>.version or docs.version_source); otherwise release-please,
64
+ * changesets, semantic-release, package.json, pyproject.toml, Cargo.toml, a gemspec, else git tags (v*).
65
+ */
66
+ export async function detectVersionSource (root, product = {}) {
67
+ const v = product.version || {}
68
+ const wanted = v.source && v.source !== 'auto' ? v.source : product.docs?.version_source && product.docs.version_source !== 'auto' ? product.docs.version_source : null
69
+ const pattern = v.pattern || 'v*'
70
+ const pkgPath = v.path && /package\.json$/.test(v.path) ? v.path : 'package.json'
71
+ const dir = v.path ? dirname(v.path) : '.'
72
+ const found = async (source) => {
73
+ if (source === 'release-please') {
74
+ const manifest = await readJson(join(root, '.release-please-manifest.json'))
75
+ const key = dir === '.' ? '.' : dir
76
+ return { source, path: '.release-please-manifest.json', pattern, version: manifest ? cleanVersion(manifest[key] ?? manifest['.'] ?? '') || null : null, owns_changelog: true, tool: 'release-please' }
77
+ }
78
+ if (source === 'changesets') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: true, tool: 'changesets' }
79
+ if (source === 'semantic-release') {
80
+ let conf = ''
81
+ for (const f of SEMREL) conf += (await readText(join(root, f))) || ''
82
+ conf += JSON.stringify((await readJson(join(root, 'package.json')))?.release || '')
83
+ return { source, path: null, pattern, version: await latestTag(root, pattern), owns_changelog: conf.includes('@semantic-release/changelog'), tool: 'semantic-release' }
84
+ }
85
+ if (source === 'package.json') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: false }
86
+ if (source === 'pyproject') { const t = await readText(join(root, v.path || 'pyproject.toml')); return { source, path: v.path || 'pyproject.toml', pattern, version: (t?.match(/^version\s*=\s*["']([^"']+)["']/m) || [])[1] || null, owns_changelog: false } }
87
+ if (source === 'cargo') { const t = await readText(join(root, v.path || 'Cargo.toml')); return { source, path: v.path || 'Cargo.toml', pattern, version: (t?.match(/^\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m) || [])[1] || null, owns_changelog: false } }
88
+ if (source === 'gemspec') {
89
+ const f = v.path || (await readdir(root).catch(() => [])).find((x) => x.endsWith('.gemspec'))
90
+ const t = f ? await readText(join(root, f)) : null
91
+ return { source, path: f || null, pattern, version: (t?.match(/\.version\s*=\s*["']([^"']+)["']/) || [])[1] || null, owns_changelog: false }
92
+ }
93
+ if (source === 'manual') return { source, path: null, pattern, version: null, owns_changelog: false }
94
+ return { source: 'git-tag', path: null, pattern, version: await latestTag(root, pattern), owns_changelog: false }
95
+ }
96
+ if (wanted) return found(wanted)
97
+ if (await exists(join(root, '.release-please-manifest.json')) || await exists(join(root, 'release-please-config.json'))) return found('release-please')
98
+ if (await exists(join(root, '.changeset', 'config.json'))) return found('changesets')
99
+ for (const f of SEMREL) if (await exists(join(root, f))) return found('semantic-release')
100
+ const pkg = await readJson(join(root, pkgPath))
101
+ if (pkg?.release) return found('semantic-release')
102
+ if (pkg?.version && !pkg.private) return found('package.json')
103
+ if (pkg?.version && pkg.version !== '0.0.0') return found('package.json')
104
+ if (await exists(join(root, 'pyproject.toml')) && /^version\s*=/m.test(await readText(join(root, 'pyproject.toml')) || '')) return found('pyproject')
105
+ if (await exists(join(root, 'Cargo.toml'))) return found('cargo')
106
+ if ((await readdir(root).catch(() => [])).some((x) => x.endsWith('.gemspec'))) return found('gemspec')
107
+ return found('git-tag')
108
+ }
109
+
110
+ /** The newest tag of the pattern (version order), or null. */
111
+ async function latestTag (root, pattern) {
112
+ const tags = ((await out('git', ['tag', '--list', pattern], root)) || '').split('\n').map((t) => t.trim()).filter(Boolean)
113
+ const versions = tags.map((t) => versionOfTag(pattern, t)).filter(Boolean)
114
+ return versions.sort(compareVersions).pop() || null
115
+ }
116
+ const tagExists = async (root, tag) => !!(await out('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], root))
117
+
118
+ /**
119
+ * Where the draft starts: --since (a tag, or a version that has a tag), else the newest tag of the pattern reachable
120
+ * from HEAD, else the newest release written in the changelog when it has a tag; null = the whole history.
121
+ */
122
+ export async function sinceRef (root, product, src, since, changelogText = '') {
123
+ const pattern = src.pattern || 'v*'
124
+ if (typeof since === 'string' && since) {
125
+ if (await tagExists(root, since)) return { ref: since, version: versionOfTag(pattern, since) || cleanVersion(since) }
126
+ const tag = tagFor(pattern, since)
127
+ if (await tagExists(root, tag)) return { ref: tag, version: cleanVersion(since) }
128
+ if (await out('git', ['rev-parse', '--verify', '--quiet', `${since}^{commit}`], root)) return { ref: since, version: null }
129
+ throw new UserError(`--since ${since}: no tag ${since} or ${tag} and no such commit.`)
130
+ }
131
+ const described = ((await out('git', ['describe', '--tags', '--abbrev=0', '--match', pattern, 'HEAD'], root)) || '').trim()
132
+ if (described) return { ref: described, version: versionOfTag(pattern, described) }
133
+ const written = latestVersionIn(changelogText)
134
+ if (written && await tagExists(root, tagFor(pattern, written))) return { ref: tagFor(pattern, written), version: written }
135
+ return { ref: null, version: written || null }
136
+ }
137
+
138
+ // ---------- pull requests and commits ----------
139
+
140
+ /**
141
+ * Merged work since `ref` along the first-parent history: a merge of a pull request is one item (its title), other
142
+ * merges are opened up into their commits, plain commits are items. PR numbers come from merge subjects and squash
143
+ * suffixes ("(#12)"); titles and links from gh when it is there (KALOKO_NO_GH=1 skips it). `dir` limits the history
144
+ * to a product's folder in a monorepo.
145
+ */
146
+ export async function collectCommits (root, ref, { dir = null, limit = 400 } = {}) {
147
+ const range = ref ? `${ref}..HEAD` : 'HEAD'
148
+ const SEP = '\u001f'; const END = '\u001e'
149
+ const fmt = `--format=%H${SEP}%P${SEP}%s${SEP}%b${END}`
150
+ const log = async (args) => ((await out('git', ['log', fmt, ...args], root)) || '').split(END).map((x) => x.trim()).filter(Boolean).map((x) => { const [sha, parents, subject, body = ''] = x.split(SEP); return { sha, parents: parents.split(' ').filter(Boolean), subject, body: body.trim() } })
151
+ const scope = dir && dir !== '.' ? ['--', dir] : []
152
+ const first = await log(['--first-parent', `-n${limit}`, range, ...scope])
153
+ const items = []
154
+ for (const c of first) {
155
+ if (c.parents.length > 1) {
156
+ const pr = c.subject.match(/^Merge pull request #(\d+)/) || c.subject.match(/^Merge branch .* into .*\(#(\d+)\)/) || c.subject.match(/\(#(\d+)\)\s*$/)
157
+ if (pr) { items.push({ sha: c.sha, subject: c.body.split('\n')[0] || c.subject, body: c.body.split('\n').slice(1).join('\n'), pr: { number: Number(pr[1]), url: null } }); continue }
158
+ for (const inner of await log(['--no-merges', `-n${limit}`, `${c.parents[0]}..${c.sha}`, ...scope])) items.push({ sha: inner.sha, subject: inner.subject, body: inner.body, pr: null })
159
+ continue
160
+ }
161
+ const sq = c.subject.match(/\(#(\d+)\)\s*$/)
162
+ items.push({ sha: c.sha, subject: c.subject, body: c.body, pr: sq ? { number: Number(sq[1]), url: null } : null })
163
+ }
164
+ // links and titles of pull requests (GitHub)
165
+ const repo = await repoSlug(root)
166
+ const numbers = [...new Set(items.map((i) => i.pr?.number).filter(Boolean))]
167
+ if (repo?.host === 'github') {
168
+ let titles = new Map()
169
+ if (numbers.length && process.env.KALOKO_NO_GH !== '1') {
170
+ const json = await out('gh', ['pr', 'list', '--state', 'merged', '--limit', '200', '--json', 'number,title,url'], root, 15000)
171
+ try { titles = new Map(JSON.parse(json || '[]').map((p) => [p.number, p])) } catch {}
172
+ }
173
+ for (const i of items) {
174
+ if (!i.pr) continue
175
+ const p = titles.get(i.pr.number)
176
+ i.pr.url = p?.url || `https://github.com/${repo.slug}/pull/${i.pr.number}`
177
+ // a squash commit or merge keeps its own title unless it is GitHub's default "Merge pull request" line
178
+ if (p?.title && /^Merge pull request/.test(i.subject)) i.subject = p.title
179
+ }
180
+ }
181
+ return items
182
+ }
183
+
184
+ // ---------- the draft ----------
185
+
186
+ /**
187
+ * The draft entry of a product: commits and pull requests since the last release plus the steps that accepted runs
188
+ * show differently (when a service client is given). Returns { product, since, source, commits, changes, groups,
189
+ * markdown, internal, service, file }.
190
+ */
191
+ export async function draftChangelog ({ root, config, product, since = null, client = null }) {
192
+ const file = changelogFile(root, product)
193
+ const text = (await readText(file)) || ''
194
+ const src = await detectVersionSource(root, product)
195
+ const from = await sinceRef(root, product, src, since, text)
196
+ const dir = product.version?.path ? dirname(product.version.path) : null
197
+ const commits = await collectCommits(root, from.ref, { dir })
198
+ let changes = []; let service = 'offline'; let previous = null
199
+ if (client && config.org) {
200
+ try {
201
+ const q = new URLSearchParams({ product: product.slug, ...(from.version && since ? { since: from.version } : {}) })
202
+ const r = await client.request('GET', `/orgs/${config.org}/changelog/changes?${q}`)
203
+ changes = (r.scenarios || []).map((s) => ({ scenario: s.scenario, title: s.title, steps: (s.steps || []).map((st) => ({ ...st, pr: st.pr ?? s.run?.pr?.number ?? null })) }))
204
+ previous = r.previous || null
205
+ service = 'ok'
206
+ } catch (e) { service = `error: ${String(e.message || e).split('\n')[0].slice(0, 200)}` }
207
+ }
208
+ const d = draftEntry({ commits, changes })
209
+ return { product: product.slug, since: from, previous, source: src, commits: commits.length, changes, groups: d.groups, markdown: d.markdown, internal: d.internal, service, file }
210
+ }
211
+
212
+ /** The draft as an Unreleased section, with a note for whoever edits it. */
213
+ export function draftText (d) {
214
+ if (!d.markdown) return '## [Unreleased]\n\n<!-- Nothing user-facing since the last release: no merged changes and no changed screens. -->\n'
215
+ return `## [Unreleased]\n\n<!-- Draft by kaloko changelog draft: write each line for the people who use the product (what they can do now,\n what looks different), one change per line; delete what is internal; keep the step references for the pictures. -->\n\n${d.markdown}\n`
216
+ }
217
+
218
+ /** Writes the draft into the changelog's Unreleased section (creating the file with the skeleton when missing). */
219
+ export async function applyToChangelog (d) {
220
+ if (d.source.owns_changelog) throw new UserError(`${d.source.tool} writes ${relative(process.cwd(), d.file) || 'CHANGELOG.md'}; kaloko leaves it to it. Use the printed draft in your pull request or release notes, and kaloko release reads the entry ${d.source.tool} writes.`)
221
+ const before = (await readText(d.file)) || ''
222
+ const { text, added } = applyDraft(before || CHANGELOG_SKELETON, d.groups)
223
+ if (text !== before) await writeFile(d.file, text)
224
+ return { added, created: !before }
225
+ }
226
+
227
+ // ---------- the release ----------
228
+
229
+ /**
230
+ * Records a release on the service: the product (from the config), the version (given, else the version source's),
231
+ * the entry of that version in the changelog (an Unreleased section becomes the release unless a release tool owns
232
+ * the file; --no-write leaves the file alone) and the runs, which the service picks (newest accepted run per flow)
233
+ * unless `runs` names them. Returns what happened, for the CLI to say.
234
+ */
235
+ export async function releaseProduct ({ root, config, product, version = null, date = null, client, write = true, runs = null, dryRun = false }) {
236
+ const src = await detectVersionSource(root, product)
237
+ const v = cleanVersion(version || src.version || '')
238
+ if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
239
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`Not a version: ${v}`)
240
+ const notes = []
241
+ if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
242
+ const file = changelogFile(root, product)
243
+ let text = (await readText(file)) || ''
244
+ let section = sectionOf(text, v)
245
+ let finalized = false
246
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(date || '') ? date : section?.date || today()
247
+ if (!section && write && !src.owns_changelog && sectionOf(text, null)?.body?.trim()) {
248
+ const f = finalizeRelease(text, v, day, { tagPattern: src.pattern })
249
+ if (f.changed) {
250
+ if (!dryRun) await writeFile(file, f.text)
251
+ text = f.text; section = sectionOf(text, v); finalized = true
252
+ }
253
+ }
254
+ if (!section) notes.push(`${relative(root, file) || 'CHANGELOG.md'} has no entry for ${v}${src.owns_changelog ? ` (${src.tool} writes it)` : ''}; the release is recorded without text.`)
255
+ const md = section?.body || ''
256
+ const refs = findStepRefs(md)
257
+ const broken = refs.filter((r) => !r.parsed).map((r) => r.ref)
258
+ if (broken.length) notes.push(`Broken step references: ${broken.join(', ')}`)
259
+ const tag = tagFor(src.pattern, v)
260
+ const commit = (await tagExists(root, tag)) ? ((await out('git', ['rev-list', '-n', '1', tag], root)) || '').trim() : ((await out('git', ['rev-parse', 'HEAD'], root)) || '').trim()
261
+ const payload = {
262
+ product: await productPayload(root, product, src),
263
+ release: { product: product.slug, version: v, date: section?.date || day, changelog: md, source_hash: sha256(md), tag: (await tagExists(root, tag)) ? tag : null, commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, ...(runs ? { runs } : {}) }
264
+ }
265
+ if (dryRun) return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, payload, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
266
+ const saved = await client.request('POST', `/orgs/${config.org}/products`, payload.product)
267
+ notes.push(...(saved.notes || []))
268
+ const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
269
+ return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, release: rec, product: saved.product, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
270
+ }
271
+ /** The product as the service records it (POST /products): from the config, with the version source found. */
272
+ export async function productPayload (root, product, src) {
273
+ return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
274
+ }
275
+
276
+ async function repoUrl (root) {
277
+ const r = await repoSlug(root)
278
+ return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
279
+ }
280
+
281
+ // ---------- init and doctor ----------
282
+
283
+ /** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
284
+ export function docsConfigBlock ({ products = null, full = false } = {}) {
285
+ const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
286
+ if (full) lines.push(' dir: docs # guides with step references; kaloko docs publish sends them, kaloko docs check checks them', ' # languages: [en, cs] # the first is the originals\' language; translations sit next to them (guide.cs.md)')
287
+ if (products?.length) {
288
+ lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
289
+ for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
290
+ }
291
+ return `${lines.join('\n')}\n`
292
+ }
293
+
294
+ /** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
295
+ export async function detectProducts (root) {
296
+ const out = []
297
+ for (const base of ['apps']) {
298
+ const names = await readdir(join(root, base)).catch(() => [])
299
+ for (const n of names.sort()) {
300
+ const pkg = await readJson(join(root, base, n, 'package.json'))
301
+ if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
302
+ }
303
+ }
304
+ return out.length > 1 ? out : []
305
+ }
306
+
307
+ /**
308
+ * Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). Each folder's README says what
309
+ * belongs there and carries a template in a comment (comments never show on Kaloko or in exports).
310
+ */
311
+ export const DOCS_SKELETON = {
312
+ 'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(`![](kaloko:<scenario>/<step>)`) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/README.md): learn by doing, start to finish\n- [How-to guides](how-to/README.md): one task each\n- [Reference](reference/README.md): the facts to look up\n- [Explanation](explanation/README.md): why it works this way\n',
313
+ 'docs/tutorials/README.md': `# Tutorials
314
+
315
+ A tutorial takes a newcomer through a first success, start to finish, on a path that always works.
316
+
317
+ <!-- Template (copy into a new file, e.g. first-order.md):
318
+ ---
319
+ title: Your first order
320
+ order: 1
321
+ ---
322
+ # Your first order
323
+
324
+ In ten minutes you will have placed an order and seen it in your account.
325
+
326
+ 1. Open the shop and put a tea in the cart.
327
+ ![Cart](kaloko:checkout/cart)
328
+ 2. Choose **Checkout** and pay with a test card.
329
+ ![Payment](kaloko:checkout/payment)
330
+
331
+ You have placed your first order. Next: [track it](../how-to/track-an-order.md).
332
+ -->
333
+ `,
334
+ 'docs/how-to/README.md': `# How-to guides
335
+
336
+ One task per page, for someone who already knows what they want to do.
337
+
338
+ <!-- Template (copy into a new file, e.g. pay-with-apple-pay.md):
339
+ # Pay with Apple Pay
340
+
341
+ 1. Open the cart and choose **Checkout**.
342
+ ![Cart](kaloko:checkout/cart)
343
+ 2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
344
+ ![Payment](kaloko:checkout/payment)
345
+
346
+ If the button is missing, your browser has no Apple Pay set up.
347
+ -->
348
+ `,
349
+ 'docs/reference/README.md': `# Reference
350
+
351
+ Facts to look up: fields, limits, settings, keyboard shortcuts. Tables and lists, no story.
352
+
353
+ <!-- Template (copy into a new file, e.g. order-states.md):
354
+ # Order states
355
+
356
+ | State | What it means |
357
+ |---|---|
358
+ | Paid | The payment went through; the order waits for packing. |
359
+ | Shipped | The parcel left the warehouse. |
360
+
361
+ ![Order detail](kaloko:account/order-detail)
362
+ -->
363
+ `,
364
+ 'docs/explanation/README.md': `# Explanation
365
+
366
+ Why things work the way they do: background, choices, trade-offs.
367
+
368
+ <!-- Template (copy into a new file, e.g. why-we-hold-payments.md):
369
+ # Why a payment waits a day
370
+
371
+ Card payments are confirmed the next morning, so a cancelled order costs the customer nothing.
372
+ -->
373
+ `
374
+ }
375
+
376
+ /**
377
+ * Doctor checks for products: each product's version resolves from its source, scenarios name a known product
378
+ * (and module), and with several products every scenario says which one it shows. [{ name, ok, detail, hint }]
379
+ */
380
+ export async function productChecks ({ config, root, scenarios = [] }) {
381
+ const checks = []
382
+ const products = Object.keys(config.products || {})
383
+ if (!products.length) return checks
384
+ for (const p of productsOf(config, root)) {
385
+ const src = await detectVersionSource(root, p)
386
+ const ok = !!src.version || src.source === 'manual'
387
+ checks.push({ name: `product ${p.slug}`, ok, detail: ok ? `version ${src.version || '(set by kaloko release)'} from ${src.source}${src.path ? ` (${src.path})` : src.source === 'git-tag' ? ` (${src.pattern})` : ''}` : `no version from ${src.source}${src.path ? ` (${src.path})` : ` (${src.pattern})`}`, hint: ok ? '' : src.source === 'git-tag' ? `Tag a release (git tag ${tagFor(src.pattern, '1.0.0')}) or set products.${p.slug}.version.` : `Set the version in ${src.path || src.source}, or products.${p.slug}.version in the config.` })
388
+ }
389
+ const untagged = []; const unknown = []
390
+ for (const s of scenarios) {
391
+ if (!s.product) { untagged.push(s.file); continue }
392
+ if (!products.includes(s.product)) unknown.push(`${s.file}: product ${s.product}`)
393
+ else if (s.module && !(config.products[s.product].modules || []).includes(s.module)) unknown.push(`${s.file}: module ${s.module} of ${s.product}`)
394
+ }
395
+ if (unknown.length) checks.push({ name: 'scenario products', ok: false, detail: unknown.slice(0, 5).join('; '), hint: 'Use a key of products: (and one of its modules:) in the scenario.' })
396
+ if (untagged.length && products.length > 1) checks.push({ name: 'scenario products', ok: false, detail: `${untagged.length} scenario(s) without product: ${untagged.slice(0, 4).join(', ')}${untagged.length > 4 ? ' …' : ''}`, hint: `Add product: <${products.join('|')}> to each, so their pictures go to the right changelog.` })
397
+ else if (!unknown.length) checks.push({ name: 'scenario products', ok: true, detail: products.length === 1 ? `untagged scenarios belong to ${products[0]}` : 'every scenario names its product' })
398
+ return checks
399
+ }
package/src/lib/ci.mjs CHANGED
@@ -209,3 +209,74 @@ export function addPreviewEnvironment (text, name = 'preview') {
209
209
  const at = m.index + m[0].length
210
210
  return text.slice(0, at) + '\n' + block + text.slice(at)
211
211
  }
212
+
213
+ // ---------- releases (docs/DOCS-CHANGELOG.md → release-tool integrations) ----------
214
+
215
+ const TOOL_SNIPPETS = {
216
+ 'release-please': { after: 'googleapis/release-please-action (id: release)', when: '${{ steps.release.outputs.release_created }}', version: '${{ steps.release.outputs.version }}' },
217
+ changesets: { after: 'changesets/action (id: changesets)', when: "${{ steps.changesets.outputs.published == 'true' }}", version: '' },
218
+ 'semantic-release': { after: 'npx semantic-release', when: null, version: '' }
219
+ }
220
+
221
+ /**
222
+ * .github/workflows/kaloko-release.yml: when a GitHub release is published (release-please, semantic-release with
223
+ * @semantic-release/github and changesets all publish one) or by hand, `kaloko release <version> --no-write` records
224
+ * it on Kaloko: the entry the release tool wrote, the accepted runs that are its pictures, and the docs of that
225
+ * version. `products`: [{ slug, pattern }] (tag patterns such as v* or shop-v*); several products get one step each.
226
+ */
227
+ export function releaseWorkflow ({ products = [{ slug: null, pattern: 'v*' }], pm = 'npm', dependency = true, version = 'latest', node = 22, tool = null }) {
228
+ const { install, kaloko: k } = runner({ pm, dependency, version })
229
+ const several = products.length > 1
230
+ const steps = products.map((p) => {
231
+ const prefix = String(p.pattern || 'v*').split('*')[0]
232
+ const flag = several || p.slug ? ` --product ${q(p.slug)}` : ''
233
+ return ` - name: Record ${p.slug || 'the release'} on Kaloko
234
+ ${several ? ` if: startsWith(env.TAG, '${prefix}')\n` : ''} run: ${k} release "\${TAG#${prefix}}"${flag} --no-write`
235
+ }).join('\n')
236
+ return `# Kaloko: record every release on Kaloko once your release tool has published it: its changelog entry with
237
+ # before/after pictures, the accepted runs that are its reference, and the docs of that version.
238
+ # Written by kaloko ci github --release${tool ? ` (${tool} found)` : ''}. Secret: KALOKO_TOKEN (tester role, upload scope).
239
+ # kaloko release --no-write only reads CHANGELOG.md; the release tool keeps writing it.
240
+ name: Kaloko release
241
+ on:
242
+ release:
243
+ types: [published]
244
+ workflow_dispatch:
245
+ inputs:
246
+ tag:
247
+ description: Tag of the release to record (e.g. v2.4.0)
248
+ required: true
249
+ permissions:
250
+ contents: read
251
+ jobs:
252
+ record:
253
+ runs-on: ubuntu-latest
254
+ timeout-minutes: 10
255
+ env:
256
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
257
+ GH_TOKEN: \${{ github.token }}
258
+ KALOKO_AGENT: github-actions
259
+ TAG: \${{ github.event.release.tag_name || inputs.tag }}
260
+ steps:
261
+ - uses: actions/checkout@v4
262
+ with:
263
+ ref: \${{ github.event.release.tag_name || inputs.tag }}
264
+ fetch-depth: 0 # the tags and the history since the previous release
265
+ - uses: actions/setup-node@v4
266
+ with:
267
+ node-version: ${node}
268
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''}${steps}
269
+ `
270
+ }
271
+
272
+ /** The step to paste into an existing release workflow, right after the release tool's own step. */
273
+ export function releaseSnippet ({ tool = null, pm = 'npm', dependency = true, version = 'latest', product = null }) {
274
+ const { kaloko: k } = runner({ pm, dependency, version })
275
+ const s = TOOL_SNIPPETS[tool] || { after: 'the step that publishes the release', when: null, version: '' }
276
+ return `# after ${s.after}:
277
+ - name: Record the release on Kaloko
278
+ ${s.when ? ` if: ${s.when}\n` : ''} run: ${k} release${s.version ? ` ${s.version}` : ''}${product ? ` --product ${q(product)}` : ''} --no-write
279
+ env:
280
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
281
+ `
282
+ }
@@ -297,11 +297,31 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
297
297
  return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
298
298
  }
299
299
 
300
- /** Publishes the draft head as a version (a run of the design environment). */
300
+ /** The notes an agent or person wrote for a version: --notes <file> or --notes - (stdin); null without the option. */
301
+ export async function readNotesArg (value, stdin = process.stdin) {
302
+ if (value == null || value === false) return null
303
+ if (value === true) throw new UserError('--notes takes a file (Markdown, text or JSON) or - for standard input.')
304
+ if (value === '-') { const chunks = []; for await (const c of stdin) chunks.push(c); return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') }
305
+ try { return await readFile(value, 'utf8') } catch { throw new UserError(`--notes: cannot read ${value}.`) }
306
+ }
307
+
308
+ /** What publishing the draft now would write as "what changed" (kaloko share --notes-preview). */
309
+ export async function previewNotes ({ config, scenario, client, args, say }) {
310
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
311
+ const r = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions/draft/notes?branch=${enc(branch)}`)
312
+ if (args.json) { say(JSON.stringify(r, null, 2)); return r }
313
+ const { notesText } = await import('./design-notes.mjs')
314
+ say(`What publishing now would say (${r.changed} changed, ${r.unchanged} unchanged${r.notes?.from ? ` since v${r.notes.from.version}` : ''}):\n\n${notesText(r.notes, { lang: args.lang === 'cs' ? 'cs' : 'en' })}\n\nWrite your own in a file (a summary, then "step-id: what changed" lines) and publish with kaloko share --notes <file>.`)
315
+ return r
316
+ }
317
+
318
+ /** Publishes the draft head as a version (a run of the design environment), with "what changed" notes. */
301
319
  export async function publishVersion ({ config, scenario, client, args, say }) {
302
320
  const branch = typeof args.branch === 'string' ? args.branch : 'main'
303
- const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}) })
321
+ const notes = await readNotesArg(args.notes)
322
+ const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(notes ? { notes } : {}) })
304
323
  say(`Version v${r.version}${r.name ? ` "${r.name}"` : ''} · ${r.changed} changed, ${r.unchanged} unchanged${r.changed_steps.length ? ` (${r.changed_steps.join(', ')})` : ''}\n${r.url}`)
324
+ if (r.notes) { const { notesText } = await import('./design-notes.mjs'); say(`\nWhat changed${notes ? ' (your notes over the generated ones)' : ''}:\n${notesText(r.notes)}`) }
305
325
  return r
306
326
  }
307
327