@iodes/releasekit 0.1.2 → 0.1.4
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.
- package/README.md +92 -36
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +61 -0
- package/dist/cli.js +17 -9
- package/dist/content.d.ts +7 -0
- package/dist/content.js +104 -13
- package/dist/export.d.ts +2 -2
- package/dist/export.js +41 -27
- package/dist/git.d.ts +1 -0
- package/dist/git.js +9 -0
- package/dist/images.d.ts +4 -1
- package/dist/images.js +37 -14
- package/dist/install.js +3 -3
- package/dist/model.d.ts +37 -0
- package/dist/model.js +9 -3
- package/dist/project.d.ts +8 -1
- package/dist/project.js +71 -7
- package/dist/validate.js +11 -5
- package/kit/references/adoption.md +57 -0
- package/kit/references/format.md +20 -4
- package/kit/references/media-sources.md +3 -3
- package/kit/references/theme-pairing.md +38 -1
- package/kit/references/visual-language.md +1 -1
- package/kit/references/workflow.md +98 -34
- package/kit/references/writing.md +36 -5
- package/kit/skills/releasekit-draft/SKILL.md +15 -5
- package/kit/skills/releasekit-finalize/SKILL.md +24 -0
- package/kit/skills/releasekit-image/SKILL.md +11 -5
- package/package.json +1 -1
- package/schemas/config.schema.json +87 -0
- package/schemas/release.schema.json +7 -0
- package/kit/skills/releasekit-review/SKILL.md +0 -16
- package/kit/skills/releasekit-translate/SKILL.md +0 -14
package/dist/export.js
CHANGED
|
@@ -7,38 +7,47 @@ import { exists, readNote, write } from './files.js';
|
|
|
7
7
|
import { readVisual } from './content.js';
|
|
8
8
|
export async function exportBundle(project, current, options) {
|
|
9
9
|
const config = await project.config();
|
|
10
|
-
const
|
|
11
|
-
const history = await project.history(
|
|
12
|
-
const
|
|
13
|
-
const copies = [];
|
|
10
|
+
const version = current ?? await project.latestVersion();
|
|
11
|
+
const history = await project.history(version, options.limit ?? config.history.limit);
|
|
12
|
+
const languages = options.locale === undefined ? history[0].locales : [locale.parse(options.locale)];
|
|
14
13
|
for (const release of history) {
|
|
15
14
|
if (release.status !== 'ready')
|
|
16
15
|
throw new Error(`Release ${release.version} is still a draft.`);
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
for (const language of languages) {
|
|
17
|
+
if (!release.locales.includes(language))
|
|
18
|
+
throw new Error(`Release ${release.version} has no ${language} locale.`);
|
|
19
|
+
}
|
|
19
20
|
const checked = await validate(project, release.version);
|
|
20
21
|
if (!checked.valid)
|
|
21
22
|
throw new Error(checked.errors.join('\n'));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
}
|
|
24
|
+
const bundles = [];
|
|
25
|
+
const copies = new Map();
|
|
26
|
+
for (const language of languages) {
|
|
27
|
+
const bundle = { schemaVersion: 1, currentVersion: version, locale: language, releases: [] };
|
|
28
|
+
for (const release of history) {
|
|
29
|
+
const entry = { version: release.version, releasedAt: release.releasedAt, previous: release.previous, notes: [] };
|
|
30
|
+
for (const note of release.notes) {
|
|
31
|
+
const text = await readNote(await project.releaseFile(release.version, `notes/${note.id}/${language}.md`));
|
|
32
|
+
const exported = { id: note.id, category: note.category, title: text.title, bodyMarkdown: text.body, image: null };
|
|
33
|
+
if (note.image) {
|
|
34
|
+
const visual = await readVisual(project, release.version, note.id);
|
|
35
|
+
const variants = activeVariants(visual, release.visuals);
|
|
36
|
+
exported.image = { alt: text.alt, fallbackTheme: variants[0], variants: {} };
|
|
37
|
+
for (const variant of variants) {
|
|
38
|
+
const asset = visual.variants[variant];
|
|
39
|
+
const relative = `assets/${release.version}/${path.posix.basename(asset.file)}`;
|
|
40
|
+
exported.image.variants[variant] = { src: relative, width: asset.width, height: asset.height };
|
|
41
|
+
copies.set(relative, await project.releaseFile(release.version, asset.file));
|
|
42
|
+
}
|
|
35
43
|
}
|
|
44
|
+
entry.notes.push(exported);
|
|
36
45
|
}
|
|
37
|
-
|
|
46
|
+
bundle.releases.push(entry);
|
|
38
47
|
}
|
|
39
|
-
|
|
48
|
+
bundleSchema.parse(bundle);
|
|
49
|
+
bundles.push(bundle);
|
|
40
50
|
}
|
|
41
|
-
bundleSchema.parse(bundle);
|
|
42
51
|
const destination = path.resolve(options.out);
|
|
43
52
|
const contentRoot = await project.content('releases');
|
|
44
53
|
const relativeToContent = path.relative(contentRoot, destination);
|
|
@@ -50,9 +59,14 @@ export async function exportBundle(project, current, options) {
|
|
|
50
59
|
}
|
|
51
60
|
if (await exists(destination))
|
|
52
61
|
throw new Error('The export destination already exists. Choose a new output directory.');
|
|
53
|
-
// No filesystem output is created until every selected release has passed validation.
|
|
54
|
-
for (const
|
|
55
|
-
await write(path.join(destination,
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
// No filesystem output is created until every selected release and locale has passed validation.
|
|
63
|
+
for (const [relative, src] of copies)
|
|
64
|
+
await write(path.join(destination, relative), await fs.readFile(src));
|
|
65
|
+
const files = [];
|
|
66
|
+
for (const bundle of bundles) {
|
|
67
|
+
const file = path.join(destination, `release-notes.${bundle.locale}.json`);
|
|
68
|
+
await write(file, JSON.stringify(bundle, null, 2) + '\n');
|
|
69
|
+
files.push(file);
|
|
70
|
+
}
|
|
71
|
+
return { files, releases: history.length, assets: copies.size };
|
|
58
72
|
}
|
package/dist/git.d.ts
CHANGED
|
@@ -17,5 +17,6 @@ export declare function resolveCommit(root: string, ref: string): string;
|
|
|
17
17
|
export declare function isAncestor(root: string, base: string, head: string): boolean;
|
|
18
18
|
export declare function resolveRange(root: string, from: string | null, to: string): Release['source'];
|
|
19
19
|
export declare function collect(root: string, from: string | null, to: string): GitChanges;
|
|
20
|
+
export declare function collectSnapshot(root: string, to: string): GitChanges;
|
|
20
21
|
export declare function checkPrevious(root: string, previous: Release, source: Release['source']): void;
|
|
21
22
|
export {};
|
package/dist/git.js
CHANGED
|
@@ -60,6 +60,15 @@ export function collect(root, from, to) {
|
|
|
60
60
|
}
|
|
61
61
|
return { source, commits, files };
|
|
62
62
|
}
|
|
63
|
+
export function collectSnapshot(root, to) {
|
|
64
|
+
const source = resolveRange(root, null, to);
|
|
65
|
+
const subject = git(root, ['show', '--no-show-signature', '--no-patch', '--format=%s', source.toSha, '--']).trimEnd();
|
|
66
|
+
const paths = git(root, ['ls-tree', '-r', '--name-only', '-z', source.toSha, '--']);
|
|
67
|
+
return {
|
|
68
|
+
source, commits: [{ sha: source.toSha, subject }],
|
|
69
|
+
files: paths.split('\0').filter(Boolean).map(path => ({ status: 'A', path })),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
63
72
|
export function checkPrevious(root, previous, source) {
|
|
64
73
|
const boundary = source.fromSha;
|
|
65
74
|
if (!boundary || !isAncestor(root, previous.source.toSha, boundary)) {
|
package/dist/images.d.ts
CHANGED
|
@@ -31,7 +31,10 @@ export declare function planImages(project: Project, version: string): Promise<{
|
|
|
31
31
|
providedRequests: number;
|
|
32
32
|
costNote: string;
|
|
33
33
|
}>;
|
|
34
|
-
export
|
|
34
|
+
export interface ImportImageOptions {
|
|
35
|
+
source?: 'generated' | 'provided';
|
|
36
|
+
}
|
|
37
|
+
export declare function importImage(project: Project, version: string, noteId: string, variant: AssetVariant, source: string, options?: ImportImageOptions): Promise<{
|
|
35
38
|
file: string;
|
|
36
39
|
sha256: string;
|
|
37
40
|
sceneHash: string;
|
package/dist/images.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import sharp from 'sharp';
|
|
4
|
-
import { themes, assetVariant, imageSource, activeVariants } from './model.js';
|
|
4
|
+
import { themes, assetVariant, sceneSchema, imageSource, activeVariants } from './model.js';
|
|
5
5
|
import { Project, editable } from './project.js';
|
|
6
6
|
import { readVisual, checkReferenceFiles } from './content.js';
|
|
7
7
|
import { digest, identifier, exists, write, writeYaml } from './files.js';
|
|
8
8
|
import { imagePrompt, sceneHash } from './prompts.js';
|
|
9
|
+
import { fileKey, managedAssetFiles, retainedImageFiles } from './assets.js';
|
|
9
10
|
export async function inspectImage(bytes) {
|
|
10
11
|
const image = sharp(bytes, { limitInputPixels: 16_777_216, failOn: 'warning' });
|
|
11
12
|
const metadata = await image.metadata();
|
|
@@ -74,7 +75,19 @@ export async function planImages(project, version) {
|
|
|
74
75
|
costNote: 'Counts describe required output assets, not provider prices or a guarantee of one tool call per asset. No image service was called.',
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
async function obsoleteNoteImages(project, version, noteId, selected) {
|
|
79
|
+
const candidates = await managedAssetFiles(project, version, noteId);
|
|
80
|
+
if (!candidates.length)
|
|
81
|
+
return [];
|
|
82
|
+
const retained = await retainedImageFiles(project, { version, noteId, visual: selected });
|
|
83
|
+
const obsolete = [];
|
|
84
|
+
for (const file of candidates) {
|
|
85
|
+
if (!retained.has(await fileKey(file)))
|
|
86
|
+
obsolete.push(file);
|
|
87
|
+
}
|
|
88
|
+
return obsolete;
|
|
89
|
+
}
|
|
90
|
+
export async function importImage(project, version, noteId, variant, source, options = {}) {
|
|
78
91
|
const release = await project.release(version);
|
|
79
92
|
editable(release);
|
|
80
93
|
identifier(noteId);
|
|
@@ -82,34 +95,44 @@ export async function importImage(project, version, noteId, variant, source) {
|
|
|
82
95
|
if (!release.notes.some(n => n.id === noteId && n.image))
|
|
83
96
|
throw new Error(`No image-enabled note named ${noteId}.`);
|
|
84
97
|
const visual = await readVisual(project, version, noteId);
|
|
98
|
+
if (options.source !== undefined)
|
|
99
|
+
visual.scene.source = sceneSchema.shape.source.parse(options.source);
|
|
85
100
|
const provided = imageSource(visual.scene) === 'provided';
|
|
86
101
|
if (variant === 'shared') {
|
|
87
102
|
if (!provided)
|
|
88
|
-
throw new Error('Only supplied images can use a shared asset.');
|
|
89
|
-
|
|
90
|
-
throw new Error('Remove the themed variant entries before switching to one shared supplied image.');
|
|
103
|
+
throw new Error('Only supplied images can use a shared asset. Use --source provided when importing a supplied replacement.');
|
|
104
|
+
visual.variants = {};
|
|
91
105
|
}
|
|
92
106
|
else {
|
|
93
107
|
if (!themes(release.visuals).includes(variant))
|
|
94
108
|
throw new Error(`Theme ${variant} is not enabled for this release. Update the project setting and sync the draft first.`);
|
|
95
|
-
|
|
96
|
-
throw new Error('Remove the shared variant entry before switching to distinct supplied theme variants.');
|
|
109
|
+
delete visual.variants.shared;
|
|
97
110
|
}
|
|
98
111
|
const bytes = await fs.readFile(path.resolve(project.root, source));
|
|
99
112
|
const inspected = await inspectImage(bytes);
|
|
100
113
|
const file = `assets/${noteId}.${variant}.${inspected.sha256.slice(0, 12)}.${inspected.extension}`;
|
|
101
114
|
const destination = await project.releaseFile(version, file);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
else
|
|
107
|
-
await write(destination, bytes);
|
|
115
|
+
const destinationExists = await exists(destination);
|
|
116
|
+
if (destinationExists && digest(await fs.readFile(destination)) !== inspected.sha256)
|
|
117
|
+
throw new Error('The asset destination has conflicting content.');
|
|
108
118
|
visual.variants[variant] = {
|
|
109
119
|
file, sha256: inspected.sha256, sceneHash: sceneHash(visual.scene, release.visuals, variant),
|
|
110
120
|
width: inspected.width, height: inspected.height,
|
|
111
121
|
};
|
|
112
|
-
|
|
122
|
+
// Resolve cleanup before writing, and remove old files only after the selection is saved.
|
|
123
|
+
const obsolete = await obsoleteNoteImages(project, version, noteId, visual);
|
|
124
|
+
if (!destinationExists)
|
|
125
|
+
await write(destination, bytes);
|
|
126
|
+
try {
|
|
127
|
+
await writeYaml(await project.releaseFile(version, `visuals/${noteId}.yaml`), visual);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (!destinationExists)
|
|
131
|
+
await fs.unlink(destination);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
for (const oldFile of obsolete)
|
|
135
|
+
await fs.rm(oldFile, { force: true });
|
|
113
136
|
return visual.variants[variant];
|
|
114
137
|
}
|
|
115
138
|
export async function validateImages(project, version, noteId, visual, errors, warnings) {
|
package/dist/install.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Project } from './project.js';
|
|
|
6
6
|
import { configSchema, defaultConfig } from './model.js';
|
|
7
7
|
import { exists, within, write, writeYaml, digest } from './files.js';
|
|
8
8
|
const resources = fileURLToPath(new URL('../kit/', import.meta.url));
|
|
9
|
-
const names = ['releasekit-draft', 'releasekit-image', 'releasekit-
|
|
9
|
+
const names = ['releasekit-draft', 'releasekit-image', 'releasekit-finalize'];
|
|
10
10
|
const managedSchema = z.record(z.string(), z.string().regex(/^[a-f0-9]{64}$/));
|
|
11
11
|
export async function installSkills(project) {
|
|
12
12
|
const config = await project.config();
|
|
@@ -44,8 +44,8 @@ export async function installSkills(project) {
|
|
|
44
44
|
}
|
|
45
45
|
await write(marker, JSON.stringify(managed, null, 2) + '\n');
|
|
46
46
|
return { tools: config.tools, written, conflicts, hints: {
|
|
47
|
-
codex: 'Use $releasekit-draft, $releasekit-image,
|
|
48
|
-
claude: 'Use /releasekit-draft, /releasekit-image,
|
|
47
|
+
codex: 'Use $releasekit-draft, $releasekit-image, or $releasekit-finalize.',
|
|
48
|
+
claude: 'Use /releasekit-draft, /releasekit-image, or /releasekit-finalize.',
|
|
49
49
|
cursor: 'Use the installed releasekit-* skills from the agent skill picker or name them in your request.',
|
|
50
50
|
} };
|
|
51
51
|
}
|
package/dist/model.d.ts
CHANGED
|
@@ -49,6 +49,23 @@ export declare const visualPolicySchema: z.ZodObject<{
|
|
|
49
49
|
}, z.core.$strict>;
|
|
50
50
|
}, z.core.$strict>;
|
|
51
51
|
export type VisualPolicy = z.infer<typeof visualPolicySchema>;
|
|
52
|
+
export declare const historyStartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
53
|
+
ref: z.ZodString;
|
|
54
|
+
sha: z.ZodString;
|
|
55
|
+
past: z.ZodLiteral<"summary">;
|
|
56
|
+
version: z.ZodString;
|
|
57
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
58
|
+
ref: z.ZodString;
|
|
59
|
+
sha: z.ZodString;
|
|
60
|
+
past: z.ZodLiteral<"history">;
|
|
61
|
+
version: z.ZodString;
|
|
62
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
63
|
+
ref: z.ZodString;
|
|
64
|
+
sha: z.ZodString;
|
|
65
|
+
past: z.ZodLiteral<"skip">;
|
|
66
|
+
version: z.ZodNull;
|
|
67
|
+
}, z.core.$strict>], "past">;
|
|
68
|
+
export type HistoryStart = z.infer<typeof historyStartSchema>;
|
|
52
69
|
export declare const configSchema: z.ZodObject<{
|
|
53
70
|
schemaVersion: z.ZodLiteral<1>;
|
|
54
71
|
product: z.ZodString;
|
|
@@ -56,6 +73,22 @@ export declare const configSchema: z.ZodObject<{
|
|
|
56
73
|
locales: z.ZodArray<z.ZodString>;
|
|
57
74
|
history: z.ZodObject<{
|
|
58
75
|
limit: z.ZodNumber;
|
|
76
|
+
start: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
77
|
+
ref: z.ZodString;
|
|
78
|
+
sha: z.ZodString;
|
|
79
|
+
past: z.ZodLiteral<"summary">;
|
|
80
|
+
version: z.ZodString;
|
|
81
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
82
|
+
ref: z.ZodString;
|
|
83
|
+
sha: z.ZodString;
|
|
84
|
+
past: z.ZodLiteral<"history">;
|
|
85
|
+
version: z.ZodString;
|
|
86
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
87
|
+
ref: z.ZodString;
|
|
88
|
+
sha: z.ZodString;
|
|
89
|
+
past: z.ZodLiteral<"skip">;
|
|
90
|
+
version: z.ZodNull;
|
|
91
|
+
}, z.core.$strict>], "past">>;
|
|
59
92
|
}, z.core.$strict>;
|
|
60
93
|
visuals: z.ZodObject<{
|
|
61
94
|
themes: z.ZodEnum<{
|
|
@@ -125,6 +158,10 @@ export declare const releaseSchema: z.ZodObject<{
|
|
|
125
158
|
toRef: z.ZodString;
|
|
126
159
|
toSha: z.ZodString;
|
|
127
160
|
}, z.core.$strict>;
|
|
161
|
+
initialContent: z.ZodOptional<z.ZodEnum<{
|
|
162
|
+
history: "history";
|
|
163
|
+
summary: "summary";
|
|
164
|
+
}>>;
|
|
128
165
|
sourceLocale: z.ZodString;
|
|
129
166
|
locales: z.ZodArray<z.ZodString>;
|
|
130
167
|
visuals: z.ZodObject<{
|
package/dist/model.js
CHANGED
|
@@ -17,10 +17,15 @@ export const visualPolicySchema = z.strictObject({
|
|
|
17
17
|
dark: paletteSchema,
|
|
18
18
|
light: paletteSchema,
|
|
19
19
|
});
|
|
20
|
+
export const historyStartSchema = z.discriminatedUnion('past', [
|
|
21
|
+
z.strictObject({ ref: z.string().min(1), sha, past: z.literal('summary'), version: segment }),
|
|
22
|
+
z.strictObject({ ref: z.string().min(1), sha, past: z.literal('history'), version: segment }),
|
|
23
|
+
z.strictObject({ ref: z.string().min(1), sha, past: z.literal('skip'), version: z.null() }),
|
|
24
|
+
]);
|
|
20
25
|
export const configSchema = z.strictObject({
|
|
21
26
|
schemaVersion: z.literal(1), product: z.string().min(1),
|
|
22
27
|
sourceLocale: locale, locales: z.array(locale).min(1),
|
|
23
|
-
history: z.strictObject({ limit: z.number().int().min(1).max(100) }),
|
|
28
|
+
history: z.strictObject({ limit: z.number().int().min(1).max(100), start: historyStartSchema.optional() }),
|
|
24
29
|
visuals: visualPolicySchema,
|
|
25
30
|
tools: z.array(z.enum(['codex', 'claude', 'cursor'])),
|
|
26
31
|
});
|
|
@@ -35,7 +40,8 @@ export const noteMetaSchema = z.strictObject({
|
|
|
35
40
|
export const releaseSchema = z.strictObject({
|
|
36
41
|
schemaVersion: z.literal(1), version: segment, releasedAt: z.iso.date(),
|
|
37
42
|
previous: segment.nullable(), status: z.enum(['draft', 'ready']),
|
|
38
|
-
source: sourceSchema,
|
|
43
|
+
source: sourceSchema, initialContent: z.enum(['summary', 'history']).optional(),
|
|
44
|
+
sourceLocale: locale, locales: z.array(locale).min(1),
|
|
39
45
|
visuals: visualPolicySchema, notes: z.array(noteMetaSchema),
|
|
40
46
|
emptyReason: z.string().nullable(), contentHash: z.string().nullable(),
|
|
41
47
|
});
|
|
@@ -107,7 +113,7 @@ export function activeVariants(visual, policy) {
|
|
|
107
113
|
}
|
|
108
114
|
export function defaultConfig(product) {
|
|
109
115
|
return {
|
|
110
|
-
schemaVersion: 1, product, sourceLocale: '
|
|
116
|
+
schemaVersion: 1, product, sourceLocale: 'en-US', locales: ['en-US'],
|
|
111
117
|
history: { limit: 3 }, tools: ['codex', 'claude', 'cursor'],
|
|
112
118
|
visuals: {
|
|
113
119
|
themes: 'both', preset: 'quiet-product', width: 1280, height: 800, accent: '#4678ED',
|
package/dist/project.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProjectConfig, type Release } from './model.js';
|
|
1
|
+
import { type HistoryStart, type ProjectConfig, type Release } from './model.js';
|
|
2
2
|
export declare class Project {
|
|
3
3
|
readonly root: string;
|
|
4
4
|
constructor(root: string);
|
|
@@ -10,9 +10,16 @@ export declare class Project {
|
|
|
10
10
|
release(version: string): Promise<Release>;
|
|
11
11
|
save(release: Release): Promise<void>;
|
|
12
12
|
versions(): Promise<string[]>;
|
|
13
|
+
latestVersion(): Promise<string>;
|
|
13
14
|
history(version: string, limit: number): Promise<Release[]>;
|
|
14
15
|
}
|
|
15
16
|
export declare function editable(release: Release): void;
|
|
17
|
+
export interface StartOptions {
|
|
18
|
+
at: string;
|
|
19
|
+
past: HistoryStart['past'];
|
|
20
|
+
version?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function startProject(project: Project, options: StartOptions): Promise<HistoryStart>;
|
|
16
23
|
export interface PrepareOptions {
|
|
17
24
|
from?: string;
|
|
18
25
|
to?: string;
|
package/dist/project.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { configSchema, releaseSchema } from './model.js';
|
|
3
|
+
import { configSchema, historyStartSchema, releaseSchema } from './model.js';
|
|
4
4
|
import { exists, identifier, readYaml, within, writeYaml, KIT_DIR } from './files.js';
|
|
5
|
-
import { repoRoot, resolveRange, checkPrevious } from './git.js';
|
|
5
|
+
import { repoRoot, resolveCommit, resolveRange, checkPrevious } from './git.js';
|
|
6
6
|
export class Project {
|
|
7
7
|
root;
|
|
8
8
|
constructor(root) { this.root = path.resolve(root); }
|
|
@@ -37,6 +37,19 @@ export class Project {
|
|
|
37
37
|
const entries = await fs.readdir(folder, { withFileTypes: true });
|
|
38
38
|
return entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
39
39
|
}
|
|
40
|
+
async latestVersion() {
|
|
41
|
+
const versions = await this.versions();
|
|
42
|
+
if (!versions.length)
|
|
43
|
+
throw new Error('No releases to export. Prepare and finalize a release first.');
|
|
44
|
+
const releases = await Promise.all(versions.map(version => this.release(version)));
|
|
45
|
+
const predecessors = new Set(releases.map(release => release.previous));
|
|
46
|
+
const latest = releases.filter(release => !predecessors.has(release.version));
|
|
47
|
+
if (!latest.length)
|
|
48
|
+
throw new Error('No latest release found: previous-release links contain a cycle.');
|
|
49
|
+
if (latest.length > 1)
|
|
50
|
+
throw new Error(`Multiple latest releases found: ${latest.map(release => release.version).join(', ')}. Specify --current <version>.`);
|
|
51
|
+
return latest[0].version;
|
|
52
|
+
}
|
|
40
53
|
async history(version, limit) {
|
|
41
54
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
42
55
|
throw new Error('History limit must be an integer from 1 to 100.');
|
|
@@ -60,6 +73,24 @@ export function editable(release) {
|
|
|
60
73
|
if (release.status !== 'draft')
|
|
61
74
|
throw new Error('This release is ready. Set status to draft and contentHash to null before editing it.');
|
|
62
75
|
}
|
|
76
|
+
export async function startProject(project, options) {
|
|
77
|
+
const config = await project.config();
|
|
78
|
+
if (config.history.start)
|
|
79
|
+
throw new Error('A history start is already configured. Reuse the saved choice; it was not overwritten.');
|
|
80
|
+
if ((await project.versions()).length)
|
|
81
|
+
throw new Error('History setup requires a project without releases. Continue an existing release line with --previous.');
|
|
82
|
+
if (options.past === 'skip' && options.version !== undefined)
|
|
83
|
+
throw new Error('--baseline-version is not used when --past is skip.');
|
|
84
|
+
if (options.past !== 'skip' && !options.version)
|
|
85
|
+
throw new Error('Specify --baseline-version for the baseline summary or history release.');
|
|
86
|
+
if (options.version)
|
|
87
|
+
identifier(options.version);
|
|
88
|
+
const source = resolveRange(project.root, null, options.at);
|
|
89
|
+
const start = historyStartSchema.parse({ ref: options.at, sha: source.toSha, past: options.past, version: options.version ?? null });
|
|
90
|
+
config.history.start = start;
|
|
91
|
+
await writeYaml(await project.content('config.yaml'), config);
|
|
92
|
+
return start;
|
|
93
|
+
}
|
|
63
94
|
export async function prepare(project, version, options) {
|
|
64
95
|
identifier(version);
|
|
65
96
|
const directory = await project.releaseDir(version);
|
|
@@ -70,13 +101,45 @@ export async function prepare(project, version, options) {
|
|
|
70
101
|
throw new Error('--from-root cannot be combined with --from or --previous.');
|
|
71
102
|
if (options.firstRelease && options.previous)
|
|
72
103
|
throw new Error('--first-release cannot be combined with --previous.');
|
|
104
|
+
const versions = await project.versions();
|
|
105
|
+
const start = config.history.start;
|
|
73
106
|
let previous = options.previous ? await project.release(options.previous) : undefined;
|
|
74
|
-
|
|
107
|
+
let from = options.fromRoot ? null : options.from ?? previous?.source.toSha;
|
|
108
|
+
let to = options.to ?? 'HEAD';
|
|
109
|
+
let initialContent;
|
|
110
|
+
let savedStart = false;
|
|
111
|
+
if (start && start.past !== 'skip' && version === start.version) {
|
|
112
|
+
if (options.from !== undefined || options.previous !== undefined)
|
|
113
|
+
throw new Error('The configured baseline starts at the root and has no previous release.');
|
|
114
|
+
if (options.to !== undefined && resolveCommit(project.root, options.to) !== start.sha)
|
|
115
|
+
throw new Error('The requested end differs from the pinned history start.');
|
|
116
|
+
from = null;
|
|
117
|
+
to = start.sha;
|
|
118
|
+
initialContent = start.past;
|
|
119
|
+
}
|
|
120
|
+
else if (start && from === undefined && !options.firstRelease) {
|
|
121
|
+
if (start.past === 'skip' && !versions.length) {
|
|
122
|
+
from = start.sha;
|
|
123
|
+
savedStart = true;
|
|
124
|
+
}
|
|
125
|
+
else if (start.past !== 'skip' && versions.every(v => v === start.version)) {
|
|
126
|
+
if (!versions.includes(start.version))
|
|
127
|
+
throw new Error(`Prepare the configured baseline ${start.version} first, then continue with --previous ${start.version}.`);
|
|
128
|
+
previous = await project.release(start.version);
|
|
129
|
+
if (previous.source.toSha !== start.sha || previous.initialContent !== start.past)
|
|
130
|
+
throw new Error('The baseline release differs from the saved history start. Choose an explicit --previous release.');
|
|
131
|
+
from = previous.source.toSha;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
75
134
|
if (from === undefined)
|
|
76
|
-
throw new Error('Specify --from, --previous, or --from-root.');
|
|
77
|
-
const source = resolveRange(project.root, from,
|
|
78
|
-
if (
|
|
79
|
-
|
|
135
|
+
throw new Error('Specify --from, --previous, or --from-root, or configure a first-use boundary with start.');
|
|
136
|
+
const source = resolveRange(project.root, from, to);
|
|
137
|
+
if (initialContent && start)
|
|
138
|
+
source.toRef = start.ref;
|
|
139
|
+
if (savedStart && start)
|
|
140
|
+
source.fromRef = start.ref;
|
|
141
|
+
if (!previous && !options.fromRoot && !options.firstRelease && !initialContent) {
|
|
142
|
+
const existing = await Promise.all(versions.map(v => project.release(v)));
|
|
80
143
|
const candidates = existing.filter(r => r.source.toSha === source.fromSha);
|
|
81
144
|
if (candidates.length === 1)
|
|
82
145
|
previous = candidates[0];
|
|
@@ -90,6 +153,7 @@ export async function prepare(project, version, options) {
|
|
|
90
153
|
const release = releaseSchema.parse({
|
|
91
154
|
schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString().slice(0, 10),
|
|
92
155
|
previous: previous?.version ?? null, status: 'draft', source,
|
|
156
|
+
...(initialContent ? { initialContent } : {}),
|
|
93
157
|
sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
|
|
94
158
|
notes: [], emptyReason: null, contentHash: null,
|
|
95
159
|
});
|
package/dist/validate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Project, editable } from './project.js';
|
|
|
3
3
|
import { canonical, digest, readNote, noteHash, identifier } from './files.js';
|
|
4
4
|
import { readVisual, checkReferenceFiles } from './content.js';
|
|
5
5
|
import { validateImages } from './images.js';
|
|
6
|
-
import { checkPrevious, collect } from './git.js';
|
|
6
|
+
import { checkPrevious, collect, collectSnapshot } from './git.js';
|
|
7
7
|
export async function contentHash(project, release) {
|
|
8
8
|
const { status: _status, contentHash: _hash, ...metadata } = release;
|
|
9
9
|
const parts = [metadata];
|
|
@@ -20,8 +20,14 @@ export async function validate(project, version) {
|
|
|
20
20
|
let hash = null;
|
|
21
21
|
try {
|
|
22
22
|
const release = await project.release(version);
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
if (release.initialContent && (release.source.fromSha !== null || release.source.fromRef !== null || release.previous !== null)) {
|
|
24
|
+
errors.push('Initial content requires a root baseline with no previous release.');
|
|
25
|
+
}
|
|
26
|
+
// Summaries inspect only the baseline snapshot; ready releases use their finalized fingerprint.
|
|
27
|
+
const summary = release.initialContent === 'summary';
|
|
28
|
+
const evidence = release.status !== 'draft' ? null : summary
|
|
29
|
+
? collectSnapshot(project.root, release.source.toSha)
|
|
30
|
+
: collect(project.root, release.source.fromSha, release.source.toSha);
|
|
25
31
|
if (!release.locales.includes(release.sourceLocale) || new Set(release.locales).size !== release.locales.length)
|
|
26
32
|
errors.push('Release locales must be unique and include the source locale.');
|
|
27
33
|
if (new Set(release.notes.map(n => n.id)).size !== release.notes.length)
|
|
@@ -35,9 +41,9 @@ export async function validate(project, version) {
|
|
|
35
41
|
for (const note of release.notes) {
|
|
36
42
|
identifier(note.id);
|
|
37
43
|
if (!note.commits.length && !note.paths.length)
|
|
38
|
-
errors.push(`${note.id}: attach at least one changed path or commit as evidence.`);
|
|
44
|
+
errors.push(`${note.id}: attach at least one ${summary ? 'snapshot path or the baseline commit' : 'changed path or commit'} as evidence.`);
|
|
39
45
|
if (evidence && (note.commits.some(c => !commits.has(c)) || note.paths.some(p => !changedPaths.has(p))))
|
|
40
|
-
errors.push(`${note.id}: evidence points outside the prepared Git range.`);
|
|
46
|
+
errors.push(`${note.id}: evidence points outside the ${summary ? 'baseline snapshot' : 'prepared Git range'}.`);
|
|
41
47
|
try {
|
|
42
48
|
const source = await readNote(await project.releaseFile(version, `notes/${note.id}/${release.sourceLocale}.md`));
|
|
43
49
|
if (!source.body.trim())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# First use in an existing product
|
|
2
|
+
|
|
3
|
+
Read this when a product has no ReleaseKit releases and the author is choosing where to start. Resolve the likely baseline through [repository inspection](workflow.md#resolve-release-scope-from-the-repository) before asking; a clear inferred tag interval does not require confirmation. Setup has two separate decisions: the baseline commit, and how to present the product's earlier history. A baseline is the end of the earlier period; regular change notes begin **after** it. Choosing a tag does not mean starting at the commit that created that tag's features.
|
|
4
|
+
|
|
5
|
+
Read existing releases and `history.start` in the project config first. Reuse a saved selection when continuing setup. Existing releases use their explicit `previous` links. An explicitly limited Git interval or a requested full-history release already establishes the work's scope; do not add retrospective work or repeat that choice. For first-use requests with unresolved scope, gather the missing decisions using the workflow's [native question UI](workflow.md#ask-with-the-native-question-ui). Follow [the answer-waiting procedure](workflow.md#wait-for-the-users-answer) after asking. Independent inspection may continue while answers are pending, but do not save an unconfirmed choice or draft dependent copy.
|
|
6
|
+
|
|
7
|
+
## Choose the baseline
|
|
8
|
+
|
|
9
|
+
Resolve the intended end ref to an immutable commit before inspecting candidates. Find release tags reachable from that commit, for example with `git for-each-ref --merged=<endSha> --sort=-creatordate --format='%(refname:short)' refs/tags`. Inspect candidate commit dates and subjects, and report the selected baseline, or show a small relevant set with each tag and its resolved short SHA only when the intended starting point remains ambiguous. A tag's creation date or version-string order alone does not establish the correct release line or whether it is a stable release.
|
|
10
|
+
|
|
11
|
+
Suggest the last shipped tag before the first release the author wants to document, when the product's release history supports that choice. If they want to start recording future changes now, offer the current commit. If no suitable tags exist, use recent commits from `git log -n 8 --format='%h %cs %s' <endSha> --` and allow an explicit commit or tag through free-text input. Do not choose the repository's first commit merely because no notes exist.
|
|
12
|
+
|
|
13
|
+
Explain the boundary in the user's language: “With v1.3.0 as the baseline, regular notes cover changes after v1.3.0. The baseline itself belongs to the earlier-history choice.” If they want a selected commit's change included in the first regular interval, resolve a suitable preceding boundary on that release line; use full history when the root commit itself must be included. Verify ancestry and avoid guessing a merge parent.
|
|
14
|
+
|
|
15
|
+
## Choose what to do with earlier history
|
|
16
|
+
|
|
17
|
+
Offer these distinct choices, with product introduction first as a recommendation for an established product unless the request suggests otherwise:
|
|
18
|
+
|
|
19
|
+
| Choice | What the agent writes | Git work |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| Product introduction (`summary`) | A concise introduction to the product and its main capabilities at the baseline. | Read supporting files at the pinned baseline. Do not reconstruct the sequence of old commits. |
|
|
22
|
+
| Analyze earlier history (`history`) | Evidence-based notes covering the repository's beginning through the baseline. | Inspect that history and the final state; exclude reverted or removed behavior from claims about what is available. |
|
|
23
|
+
| Start with future changes (`skip`) | No earlier-history entry. The first regular release starts after the baseline. | Save the boundary now; inspect the next requested interval when it exists. |
|
|
24
|
+
|
|
25
|
+
A generic introduction is an editorial format, not permission to invent a launch, availability date, features, or broad improvement claims. “Product overview” is appropriate for retrospective adoption. Use “Initial release” or “App launch” only when the user or reliable product evidence establishes that event at this baseline. Keep the introduction useful and grounded even when the author does not want historical analysis. Request missing product facts instead of finalizing placeholder copy.
|
|
26
|
+
|
|
27
|
+
For summary or history, identify the baseline's display version and date. Reuse a known product version or ask for a meaningful entry ID; do not invent historical version numbers. `prepare --date` sets the displayed date and otherwise defaults to the current UTC date. A Git commit or tag date is not automatically the product's release date. Language choices follow [the normal workflow](workflow.md#choose-languages) and can be collected with these decisions.
|
|
28
|
+
|
|
29
|
+
## Save and draft
|
|
30
|
+
|
|
31
|
+
After resolving the choices, save them once. These examples are alternatives, not commands to run together:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# Product introduction at v1.3.0, then regular changes after it.
|
|
35
|
+
releasekit start --at v1.3.0 --past summary --baseline-version 1.3.0
|
|
36
|
+
releasekit prepare 1.3.0
|
|
37
|
+
# Write, translate, review, and finalize the baseline content.
|
|
38
|
+
releasekit prepare 1.4.0 --previous 1.3.0 --to v1.4.0
|
|
39
|
+
|
|
40
|
+
# Analyze the repository's beginning through v1.3.0 as one baseline release.
|
|
41
|
+
releasekit start --at v1.3.0 --past history --baseline-version 1.3.0
|
|
42
|
+
releasekit prepare 1.3.0
|
|
43
|
+
|
|
44
|
+
# Keep only future changes. This setup also works when HEAD is the baseline.
|
|
45
|
+
releasekit start --at v1.3.0 --past skip
|
|
46
|
+
releasekit prepare 1.4.0 --to v1.4.0
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`start` saves the ref, immutable SHA, mode, and optional baseline version in `config.yaml`; it creates no notes. It requires a project without releases and preserves an existing setup. Summary and history require `--baseline-version`; skip has no baseline release or version. Complete shallow history before setup; the CLI does not fetch or check out anything.
|
|
50
|
+
|
|
51
|
+
For summary or history, preparing the saved baseline version uses its pinned SHA even when HEAD or the original tag has moved. It sets `initialContent` on that release. An explicit conflicting `--to` is rejected. Preparation writes only a draft manifest; the agent still needs to write the content. If that baseline is the only release, the next preparation can infer it as the previous release; explicit `--previous` makes the intended lineage clear.
|
|
52
|
+
|
|
53
|
+
For skip, the first preparation without an explicit start uses the saved SHA. If there is no later commit yet, keep setup complete and the draft pending until a nonempty requested interval exists. Do not invent an empty release, move the baseline, or create a launch entry. Later releases use `--previous` or explicit boundaries, so the saved start is not reused across every future release. Explicit ranges remain available for intentionally different release lines.
|
|
54
|
+
|
|
55
|
+
In a summary, attach supporting tracked paths from the baseline snapshot or the baseline SHA itself to each note. Older commits, removed files, later files, and working-tree changes are outside summary evidence. For historical analysis, attach paths or commits within the full pinned range using the normal writing guide. Summaries and analyzed baselines use the same draft (including translations), image, and finalization workflow as other releases, with export when requested; neither mode automatically marks content ready.
|
|
56
|
+
|
|
57
|
+
Keep the baseline and subsequent release changes in separate groups linked through `previous`. The baseline is one exportable version and counts toward the requested history limit. Skip adds no group. A separately requested reconstruction of individual older versions should use explicit per-version ranges and `previous` links instead of combining those releases into one baseline.
|