@tallyrelease/core 0.5.0-beta.47 → 0.5.0-beta.52

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.
@@ -1,6 +1,8 @@
1
1
  import type { PlanChange, Plan } from "./model/plan-types.js";
2
2
  import type { Unit, VersionScheme } from "./plugin.js";
3
3
  import type { Workspace } from "./workspace.js";
4
+ /** Version of a version heading line, else undefined. */
5
+ export declare const headingVersion: <V>(line: string, scheme: VersionScheme<V>) => V | undefined;
4
6
  /** `<root>/CHANGELOG.md` of a unit. */
5
7
  export declare const changelogPath: (unit: Pick<Unit, "root">) => string;
6
8
  export interface ChangelogDependency {
package/dist/changelog.js CHANGED
@@ -85,7 +85,7 @@ const dependencyLines = (deps) => {
85
85
  ].join("\n");
86
86
  };
87
87
  /** Version of a version heading line, else undefined. */
88
- const headingVersion = (line, scheme) => {
88
+ export const headingVersion = (line, scheme) => {
89
89
  const m = VERSION_HEADING.exec(line);
90
90
  const raw = m?.[1] ?? m?.[2];
91
91
  return raw === undefined ? undefined : scheme.parse(raw);
package/dist/codes.d.ts CHANGED
@@ -22,5 +22,5 @@ export declare const defineCodes: <R extends Record<string, CodeDoc>>(docs: R &
22
22
  /**
23
23
  * Codes raised by core. Codes are only added, never removed or renamed.
24
24
  */
25
- export declare const coreCodes: CodeRegistry<"E_BRANCH_RULE" | "E_CAP" | "E_CAP_BASE" | "E_CHANNEL_DUPLICATE" | "E_CHANNEL_INVALID" | "E_CHANNEL_WILDCARD" | "E_CONFIG" | "E_DUPLICATE_ROOT" | "E_DUPLICATE_UNIT" | "E_EDGE_SELF" | "E_EDGE_UNKNOWN_UNIT" | "E_GIT_DATE" | "E_GROUP_NOT_RELEASED" | "E_GROUP_OVERLAP" | "E_GROUP_SCHEME" | "E_GROUP_UNKNOWN_UNIT" | "E_INITIAL_VERSION" | "E_PLUGIN_API" | "E_PLUGIN_HOOK" | "E_PLUGIN_NAME" | "E_PLUGIN_OPTIONS" | "E_PLUGIN_UNKNOWN" | "E_RELEASE_ITEM_NAME" | "E_RELEASE_PR_SPLIT_EDGE" | "E_ROOT_INVALID" | "E_ROOT_MISSING" | "E_SCHEME_CLASH" | "E_SCHEME_UNKNOWN" | "E_TAG_FORMAT" | "E_TAG_FORMAT_OVERLAP" | "E_UNIT_GLOB" | "E_UNIT_ID" | "E_UNIT_TAG" | "E_UNIT_UNKNOWN" | "E_VERSION_FILE_CLAIM" | "E_VERSION_FILE_MISSING" | "E_VERSION_INVALID" | "W_CYCLE" | "W_GROUP_EMPTY" | "W_INITIAL_BELOW_PRERELEASE" | "W_NO_BRANCH_RULE" | "W_NO_MATCHING_TAG" | "W_NO_RELEASED_UNITS" | "W_PLUGIN" | "W_RELEASED_NO_BASE" | "W_RELEASE_AS_CONFLICT" | "W_RELEASE_AS_IGNORED" | "W_RELEASE_AS_INVALID" | "W_RELEASE_AS_UNKNOWN" | "W_RELEASE_NOT_BUILT" | "W_UNIT_GLOB_UNMATCHED" | "W_UNIT_UNKNOWN" | "W_VERSION_FILE_NO_MATCH">;
25
+ export declare const coreCodes: CodeRegistry<"E_BRANCH_RULE" | "E_CAP" | "E_CAP_BASE" | "E_CHANNEL_DUPLICATE" | "E_CHANNEL_INVALID" | "E_CHANNEL_WILDCARD" | "E_CONFIG" | "E_DUPLICATE_ROOT" | "E_DUPLICATE_UNIT" | "E_EDGE_SELF" | "E_EDGE_UNKNOWN_UNIT" | "E_GIT_DATE" | "E_GROUP_NOT_RELEASED" | "E_GROUP_OVERLAP" | "E_GROUP_SCHEME" | "E_GROUP_UNKNOWN_UNIT" | "E_INITIAL_VERSION" | "E_PLUGIN_API" | "E_PLUGIN_HOOK" | "E_PLUGIN_NAME" | "E_PLUGIN_OPTIONS" | "E_PLUGIN_UNKNOWN" | "E_RELEASE_ITEM_NAME" | "E_RELEASE_PR_SPLIT_EDGE" | "E_ROOT_INVALID" | "E_ROOT_MISSING" | "E_SCHEME_CLASH" | "E_SCHEME_UNKNOWN" | "E_TAG_FORMAT" | "E_TAG_FORMAT_OVERLAP" | "E_UNIT_GLOB" | "E_UNIT_ID" | "E_UNIT_TAG" | "E_UNIT_UNKNOWN" | "E_VERSION_FILE_CLAIM" | "E_VERSION_FILE_MISSING" | "E_VERSION_INVALID" | "W_CYCLE" | "W_GROUP_EMPTY" | "W_INITIAL_BELOW_PRERELEASE" | "W_MARKER_NO_MATCH" | "W_NO_BRANCH_RULE" | "W_NO_MATCHING_TAG" | "W_NO_RELEASED_UNITS" | "W_PLUGIN" | "W_RELEASED_NO_BASE" | "W_RELEASE_AS_CONFLICT" | "W_RELEASE_AS_IGNORED" | "W_RELEASE_AS_INVALID" | "W_RELEASE_AS_UNKNOWN" | "W_RELEASE_NOT_BUILT" | "W_UNIT_GLOB_UNMATCHED" | "W_UNIT_UNKNOWN" | "W_VERSION_FILE_NO_MATCH">;
26
26
  export {};
package/dist/codes.js CHANGED
@@ -146,7 +146,7 @@ export const coreCodes = defineCodes({
146
146
  fix: "Pass an existing unit id. List ids with `tallyrelease graph`.",
147
147
  },
148
148
  E_VERSION_FILE_CLAIM: {
149
- cause: "A file is the version file of two units, or is both a plugin version file and a `versionFiles` template target.",
149
+ cause: "A file is the version file of two units, is both a plugin version file and a `versionFiles` template target, or is a `versionFiles` marker file of two units (markers carry no unit id).",
150
150
  fix: "Remove the duplicate `versionFiles` entry, or set one file per unit.",
151
151
  },
152
152
  E_VERSION_FILE_MISSING: {
@@ -169,6 +169,10 @@ export const coreCodes = defineCodes({
169
169
  cause: "A unit's initial version does not exceed an existing prerelease tag of that unit.",
170
170
  fix: "Raise `initialVersion` above the prerelease tag named in the message, or remove the stale tag.",
171
171
  },
172
+ W_MARKER_NO_MATCH: {
173
+ cause: "A `versionFiles` entry without `pattern` names a file with no line that carries an `x-tallyrelease-version` marker and a version of the unit's scheme, so the file is not updated for the release.",
174
+ fix: "Add `# x-tallyrelease-version` or `<!-- x-tallyrelease-version -->` to the line that holds the version, set a `pattern`, or remove the entry.",
175
+ },
172
176
  W_NO_BRANCH_RULE: {
173
177
  cause: "No `branches` rule matches the branch, so versions in the plan are null.",
174
178
  fix: "Add a rule for the branch under `branches`, for example `{ name: main, line: default }`, or pass `--branch`.",
package/dist/config.d.ts CHANGED
@@ -29,10 +29,12 @@ export interface UnitOverlay {
29
29
  /**
30
30
  * A text file holding a unit's version. `file` is relative to the repository
31
31
  * root; `pattern` uses `{id}`, `{version}`, `{major}`, `{minor}` and `{patch}`.
32
+ * Without `pattern`, lines marked `# x-tallyrelease-version` or
33
+ * `<!-- x-tallyrelease-version -->` are rewritten.
32
34
  */
33
35
  export interface VersionTemplate {
34
36
  file: string;
35
- pattern: string;
37
+ pattern?: string;
36
38
  }
37
39
  /** A branch rule; the first rule whose `name` matches the branch wins. */
38
40
  export interface BranchConfig {
package/dist/plan.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface PlanResult {
16
16
  export declare const planWithFacts: (workspace: Workspace, git: GitReader, opts: PlanOptions) => Promise<PlanResult>;
17
17
  /**
18
18
  * Plans `head`: validates release config, computes the build set, collects
19
- * git and intent facts and resolves versions and kinds.
19
+ * git and intent facts, resolves versions and kinds and runs the plugins'
20
+ * `validate` hooks.
20
21
  */
21
22
  export declare const computePlan: (workspace: Workspace, git: GitReader, opts: PlanOptions) => Promise<Plan>;
package/dist/plan.js CHANGED
@@ -3,11 +3,12 @@ import { checkLimits, matchBranch } from "./branches.js";
3
3
  import { changeLevel, reach } from "./bump.js";
4
4
  import { coreCodes } from "./codes.js";
5
5
  import { collectPlanFacts } from "./facts.js";
6
+ import { pluginCtx, runHook } from "./plugins.js";
6
7
  import { propagate } from "./propagate.js";
7
8
  import { releaseAsWarnings } from "./release-as.js";
8
9
  import { footerOf, releaseGroup, releaseUnit, unitIntent } from "./release.js";
9
10
  import { noMatchingTag } from "./tags.js";
10
- import { maxLevel } from "./util.js";
11
+ import { frozenCopy, maxLevel } from "./util.js";
11
12
  import { validateRelease } from "./validate.js";
12
13
  const lineWarnings = (workspace, branch, line) => {
13
14
  if (!line) {
@@ -323,6 +324,30 @@ const resolveReleases = (workspace, input) => {
323
324
  releases,
324
325
  };
325
326
  };
327
+ /** Warnings of the plugins' `validate` hooks, run in config order on one frozen copy of the plan. */
328
+ const pluginValidation = async (workspace, plan, opts) => {
329
+ const warnings = [];
330
+ const ctx = pluginCtx({ ...opts, repo: { name: workspace.repo } }, warnings);
331
+ let frozen;
332
+ for (const { plugin, options } of workspace.plugins) {
333
+ const { validate } = plugin;
334
+ if (validate) {
335
+ const copy = (frozen ??= frozenCopy(plan));
336
+ // eslint-disable-next-line no-await-in-loop -- hooks run in config order
337
+ const messages = await runHook(plugin.name, "validate", async () => {
338
+ const result = await validate(ctx(plugin.name, options), copy);
339
+ if (result !== undefined && !Array.isArray(result)) {
340
+ throw new TypeError("validate must return string[] or nothing");
341
+ }
342
+ return result ?? [];
343
+ });
344
+ for (const message of messages) {
345
+ warnings.push(coreCodes.warning("W_PLUGIN", message, { plugin: plugin.name }));
346
+ }
347
+ }
348
+ }
349
+ return warnings;
350
+ };
326
351
  /** Release math over collected facts; no I/O. */
327
352
  export const resolvePlan = (workspace, input) => resolveReleases(workspace, input).plan;
328
353
  /** `computePlan` keeping the collected facts, for `explainUnit`. */
@@ -340,10 +365,12 @@ export const planWithFacts = async (workspace, git, opts) => {
340
365
  schemes: workspace.schemes,
341
366
  all: opts.all ?? false,
342
367
  });
368
+ plan.warnings.push(...(await pluginValidation(workspace, plan, opts)));
343
369
  return { plan, facts, releases };
344
370
  };
345
371
  /**
346
372
  * Plans `head`: validates release config, computes the build set, collects
347
- * git and intent facts and resolves versions and kinds.
373
+ * git and intent facts, resolves versions and kinds and runs the plugins'
374
+ * `validate` hooks.
348
375
  */
349
376
  export const computePlan = async (workspace, git, opts) => (await planWithFacts(workspace, git, opts)).plan;
package/dist/plugin.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Plan } from "./model/plan-types.js";
1
2
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
3
  export type MaybePromise<T> = T | Promise<T>;
3
4
  export declare const PLUGIN_API_VERSION = 1;
@@ -16,6 +17,18 @@ export interface Plugin<O = unknown> {
16
17
  versionFiles?: (ctx: Ctx<O>, unit: Unit) => MaybePromise<VersionFile[]>;
17
18
  schemes?: Record<string, VersionScheme<any>>;
18
19
  intent?: IntentSource<O>[];
20
+ /** Checks the computed plan; returned messages become `W_PLUGIN` warnings. */
21
+ validate?: (ctx: Ctx<O>, plan: Plan) => MaybePromise<string[] | void>;
22
+ /**
23
+ * Called only for units owned by this plugin with a changelog section.
24
+ * Returns the section that replaces the default `section`; it must start
25
+ * with a version heading of the released version and have no other.
26
+ */
27
+ changelog?: (ctx: Ctx<O>, input: {
28
+ unit: Unit;
29
+ plan: Plan;
30
+ section: string;
31
+ }) => MaybePromise<string>;
19
32
  }
20
33
  export interface Ctx<O = unknown> {
21
34
  readonly options: O;
package/dist/util.d.ts CHANGED
@@ -5,3 +5,8 @@ export declare const compare: (a: string, b: string) => number;
5
5
  export declare const uniqueSorted: (values: Iterable<string>) => string[];
6
6
  /** The higher of two levels; null is lowest. */
7
7
  export declare const maxLevel: (a: BumpLevel | null, b: BumpLevel | null) => BumpLevel | null;
8
+ /**
9
+ * Deep-frozen copy of a JSON value, for hooks that must not change it.
10
+ * Expensive (a full copy plus a walk over it): make one per pass and share it.
11
+ */
12
+ export declare const frozenCopy: <T>(value: T) => T;
package/dist/util.js CHANGED
@@ -5,3 +5,19 @@ export const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
5
5
  export const uniqueSorted = (values) => [...new Set(values)].sort(compare);
6
6
  /** The higher of two levels; null is lowest. */
7
7
  export const maxLevel = (a, b) => a === null || (b !== null && LEVELS.indexOf(b) > LEVELS.indexOf(a)) ? b : a;
8
+ /** Freezes every nested object; walks the whole value, so keep it off hot paths. */
9
+ const deepFreeze = (value) => {
10
+ if (typeof value === "object" && value !== null) {
11
+ Object.values(value).forEach(deepFreeze);
12
+ Object.freeze(value);
13
+ }
14
+ };
15
+ /**
16
+ * Deep-frozen copy of a JSON value, for hooks that must not change it.
17
+ * Expensive (a full copy plus a walk over it): make one per pass and share it.
18
+ */
19
+ export const frozenCopy = (value) => {
20
+ const copy = JSON.parse(JSON.stringify(value));
21
+ deepFreeze(copy);
22
+ return copy;
23
+ };
@@ -7,16 +7,18 @@ import type { Workspace } from "./workspace.js";
7
7
  export declare const validateTemplates: (workspace: Workspace) => void;
8
8
  /**
9
9
  * Plugin version files by unit id. Throws `E_VERSION_FILE_CLAIM` when a path
10
- * is a plugin version file of two units or also a template. Templates may
11
- * share a file.
10
+ * is a plugin version file of two units or also a template, or a marker file
11
+ * of two units. Templates may share a file.
12
12
  */
13
13
  export declare const versionFileClaims: (workspace: Workspace) => Promise<ReadonlyMap<string, readonly VersionFile[]>>;
14
14
  /**
15
15
  * Files of a release of `items` (stable `next`): plugin version files,
16
- * templates and `<root>/CHANGELOG.md` per unit, read through `fs`. Returns
16
+ * templates, markers and `<root>/CHANGELOG.md` per unit, read through `fs`; the
17
+ * owning plugin's `changelog` hook may replace a unit's section. Returns
17
18
  * only files whose content changes, and each unit's changelog section;
18
19
  * checks claims first. Templates skip first releases and warn
19
- * `W_VERSION_FILE_NO_MATCH` without any match; a missing version file is
20
+ * `W_VERSION_FILE_NO_MATCH` without any match, markers warn
21
+ * `W_MARKER_NO_MATCH`; a missing version file is
20
22
  * `E_VERSION_FILE_MISSING`.
21
23
  */
22
24
  export declare const releaseFiles: (workspace: Workspace, plan: Plan, opts: {
@@ -1,12 +1,15 @@
1
- import { changelogPath, changelogSection, updateChangelog, } from "./changelog.js";
1
+ import { changelogPath, changelogSection, headingVersion, updateChangelog, } from "./changelog.js";
2
2
  import { coreCodes } from "./codes.js";
3
3
  import { normalizeRoot } from "./discover.js";
4
- import { runHook } from "./plugins.js";
4
+ import { pluginCtx, runHook } from "./plugins.js";
5
+ import { frozenCopy } from "./util.js";
5
6
  const TOKEN = /\{([^{}]*)\}/gu;
6
7
  const SPECIAL = /[\\^$.*+?()[\]{}|/]/gu;
7
8
  const CORE_TOKENS = ["major", "minor", "patch"];
8
9
  const VERSION = "\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
9
10
  const ALNUM = /[0-9A-Za-z]/u;
11
+ const MARKERS = ["# x-tallyrelease-version", "<!-- x-tallyrelease-version -->"];
12
+ const WORD = /[0-9A-Za-z](?:[0-9A-Za-z.+-]*[0-9A-Za-z])?/gu;
10
13
  const escape = (text) => text.replace(SPECIAL, "\\$&");
11
14
  const configError = (pattern, unit, problem) => coreCodes.error("E_CONFIG", `version file pattern "${pattern}" of unit "${unit}" ${problem}`, { unit, pattern });
12
15
  /**
@@ -107,6 +110,32 @@ const applyTemplate = (content, { pattern, unit, from, next, scheme, }) => {
107
110
  });
108
111
  return { content: replaced, hit };
109
112
  };
113
+ /**
114
+ * Replaces, on each marker line, the first word the scheme parses (after an
115
+ * optional `v`) with `next`. `hit` is false when no marker line has one.
116
+ */
117
+ const applyMarkers = (content, { next, scheme }) => {
118
+ let hit = false;
119
+ const lines = content.split("\n").map((line) => {
120
+ if (!MARKERS.some((m) => line.includes(m))) {
121
+ return line;
122
+ }
123
+ for (const m of line.matchAll(WORD)) {
124
+ const [word] = m;
125
+ const start = word.startsWith("v") && scheme.parse(word.slice(1)) !== undefined
126
+ ? m.index + 1
127
+ : scheme.parse(word) !== undefined
128
+ ? m.index
129
+ : undefined;
130
+ if (start !== undefined) {
131
+ hit = true;
132
+ return `${line.slice(0, start)}${next}${line.slice(m.index + word.length)}`;
133
+ }
134
+ }
135
+ return line;
136
+ });
137
+ return { content: lines.join("\n"), hit };
138
+ };
110
139
  /** Repo-relative template file; `E_CONFIG` for `.`, absolute or `..` paths. */
111
140
  const templateFile = (file, unit) => {
112
141
  const invalid = () => coreCodes.error("E_CONFIG", `version file "${file}" of unit "${unit}" must be a repo-relative file without ".." segments`, { unit, file });
@@ -140,18 +169,34 @@ const versionFileStep = async (staged, step, pluginFiles) => {
140
169
  staged.files.set(file.path, written);
141
170
  }
142
171
  };
143
- /** Rewrites the version templates of the unit; skips first releases. */
172
+ /**
173
+ * Rewrites the version templates and markers of the unit; templates skip
174
+ * first releases.
175
+ */
144
176
  const templateStep = (staged, step) => {
145
177
  const { workspace, item, unit, scheme, from } = step;
146
178
  const warnings = [];
147
- const templates = from === null ? [] : (workspace.release.templates.get(unit.id) ?? []);
148
- for (const template of templates) {
179
+ for (const template of workspace.release.templates.get(unit.id) ?? []) {
149
180
  const { pattern } = template;
150
181
  const file = templateFile(template.file, unit.id);
182
+ if (pattern === undefined) {
183
+ const result = applyMarkers(required(staged, file, unit.id), {
184
+ next: item.version,
185
+ scheme,
186
+ });
187
+ staged.files.set(file, result.content);
188
+ if (!result.hit) {
189
+ warnings.push(coreCodes.warning("W_MARKER_NO_MATCH", `version file "${file}" of unit "${unit.id}" has no x-tallyrelease-version marker with a version`, { unit: unit.id }));
190
+ }
191
+ continue;
192
+ }
193
+ if (from === null) {
194
+ continue;
195
+ }
151
196
  const result = applyTemplate(required(staged, file, unit.id), {
152
197
  pattern,
153
198
  unit: unit.id,
154
- from: from ?? "",
199
+ from,
155
200
  next: item.version,
156
201
  scheme,
157
202
  });
@@ -162,15 +207,49 @@ const templateStep = (staged, step) => {
162
207
  }
163
208
  return warnings;
164
209
  };
210
+ /**
211
+ * The owning plugin's `changelog` section, which must have one version
212
+ * heading, of the released version, on its first line; else `section`.
213
+ */
214
+ const pluginSection = async (step, section) => {
215
+ const { workspace, item, unit, scheme, ctx, hookPlan } = step;
216
+ const owner = workspace.plugins.find((p) => p.plugin.name === unit.plugin);
217
+ const hook = owner?.plugin.changelog;
218
+ if (!owner || !hook) {
219
+ return section;
220
+ }
221
+ return await runHook(unit.plugin, "changelog", async () => {
222
+ const result = await hook(ctx(unit.plugin, owner.options), {
223
+ unit,
224
+ plan: hookPlan(),
225
+ section,
226
+ });
227
+ if (typeof result !== "string") {
228
+ throw new TypeError("changelog must return a string");
229
+ }
230
+ const text = result.trim();
231
+ const [first = "", ...rest] = text.split("\n");
232
+ const version = headingVersion(first, scheme);
233
+ const next = scheme.parse(item.version);
234
+ if (version === undefined ||
235
+ next === undefined ||
236
+ scheme.compare(version, next) !== 0 ||
237
+ rest.some((line) => headingVersion(line, scheme) !== undefined)) {
238
+ throw new Error(`section of unit "${unit.id}" must start with a heading of version ${item.version} and have no other version heading`);
239
+ }
240
+ return text;
241
+ });
242
+ };
165
243
  /** Updates the unit's changelog; returns the new section, if any. */
166
- const changelogStep = (staged, step) => {
244
+ const changelogStep = async (staged, step) => {
167
245
  const { workspace, plan, unit, scheme, from, repoUrl, date, prUnits } = step;
168
- const section = changelogSection(workspace, plan, {
246
+ const fallback = changelogSection(workspace, plan, {
169
247
  id: unit.id,
170
248
  repoUrl,
171
249
  date,
172
250
  prUnits,
173
251
  });
252
+ const section = fallback === undefined ? undefined : await pluginSection(step, fallback);
174
253
  if (section !== undefined) {
175
254
  const path = changelogPath(unit);
176
255
  staged.files.set(path, updateChangelog(staged.read(path), section, {
@@ -185,14 +264,16 @@ export const validateTemplates = (workspace) => {
185
264
  for (const [id, templates] of workspace.release.templates) {
186
265
  for (const { file, pattern } of templates) {
187
266
  templateFile(file, id);
188
- compileTemplate(pattern, id);
267
+ if (pattern !== undefined) {
268
+ compileTemplate(pattern, id);
269
+ }
189
270
  }
190
271
  }
191
272
  };
192
273
  /**
193
274
  * Plugin version files by unit id. Throws `E_VERSION_FILE_CLAIM` when a path
194
- * is a plugin version file of two units or also a template. Templates may
195
- * share a file.
275
+ * is a plugin version file of two units or also a template, or a marker file
276
+ * of two units. Templates may share a file.
196
277
  */
197
278
  export const versionFileClaims = async (workspace) => {
198
279
  const files = await workspace.versionFiles();
@@ -206,23 +287,33 @@ export const versionFileClaims = async (workspace) => {
206
287
  claims.set(path, id);
207
288
  }
208
289
  }
290
+ const markers = new Map();
209
291
  for (const [id, templates] of workspace.release.templates) {
210
- for (const { file } of templates) {
292
+ for (const { file, pattern } of templates) {
211
293
  const path = templateFile(file, id);
212
294
  const owner = claims.get(path);
213
295
  if (owner !== undefined) {
214
296
  throw coreCodes.error("E_VERSION_FILE_CLAIM", `version file template of unit "${id}" targets "${path}", a version file of unit "${owner}"`, { path, units: [owner, id] });
215
297
  }
298
+ if (pattern === undefined) {
299
+ const marked = markers.get(path);
300
+ if (marked !== undefined && marked !== id) {
301
+ throw coreCodes.error("E_VERSION_FILE_CLAIM", `version file "${path}" holds markers of units "${marked}" and "${id}"`, { path, units: [marked, id] });
302
+ }
303
+ markers.set(path, id);
304
+ }
216
305
  }
217
306
  }
218
307
  return files;
219
308
  };
220
309
  /**
221
310
  * Files of a release of `items` (stable `next`): plugin version files,
222
- * templates and `<root>/CHANGELOG.md` per unit, read through `fs`. Returns
311
+ * templates, markers and `<root>/CHANGELOG.md` per unit, read through `fs`; the
312
+ * owning plugin's `changelog` hook may replace a unit's section. Returns
223
313
  * only files whose content changes, and each unit's changelog section;
224
314
  * checks claims first. Templates skip first releases and warn
225
- * `W_VERSION_FILE_NO_MATCH` without any match; a missing version file is
315
+ * `W_VERSION_FILE_NO_MATCH` without any match, markers warn
316
+ * `W_MARKER_NO_MATCH`; a missing version file is
226
317
  * `E_VERSION_FILE_MISSING`.
227
318
  */
228
319
  export const releaseFiles = async (workspace, plan, opts) => {
@@ -240,6 +331,9 @@ export const releaseFiles = async (workspace, plan, opts) => {
240
331
  };
241
332
  const sections = new Map();
242
333
  const warnings = [];
334
+ const ctx = pluginCtx({ fs, repo: { name: workspace.repo } }, warnings);
335
+ let frozen;
336
+ const hookPlan = () => (frozen ??= frozenCopy(plan));
243
337
  for (const item of items) {
244
338
  const prUnits = items
245
339
  .filter((i) => pr(i) === pr(item))
@@ -262,11 +356,14 @@ export const releaseFiles = async (workspace, plan, opts) => {
262
356
  prUnits,
263
357
  repoUrl,
264
358
  date,
359
+ ctx,
360
+ hookPlan,
265
361
  };
266
362
  // eslint-disable-next-line no-await-in-loop -- files are written in order
267
363
  await versionFileStep(staged, step, pluginFiles.get(id) ?? []);
268
364
  warnings.push(...templateStep(staged, step));
269
- const section = changelogStep(staged, step);
365
+ // eslint-disable-next-line no-await-in-loop -- files are written in order
366
+ const section = await changelogStep(staged, step);
270
367
  if (section !== undefined) {
271
368
  sections.set(id, section);
272
369
  }
@@ -36,6 +36,11 @@ export interface Workspace {
36
36
  readonly schemes: ReadonlyMap<string, VersionScheme<any>>;
37
37
  /** Core sources first, then enabled plugins in config order. */
38
38
  readonly intents: readonly IntentEntry[];
39
+ /** Core first, then enabled plugins in config order, with their options. */
40
+ readonly plugins: readonly {
41
+ readonly plugin: Plugin;
42
+ readonly options: unknown;
43
+ }[];
39
44
  /** Bumps and branch rules with defaults applied. */
40
45
  readonly release: ReleaseSettings;
41
46
  /** Fixed groups, sorted by name. */
package/dist/workspace.js CHANGED
@@ -87,6 +87,10 @@ export const loadWorkspace = async (input) => {
87
87
  warnings: [...warnings, ...graph.warnings],
88
88
  schemes: schemeRegistry(active.map((a) => a.plugin)),
89
89
  intents,
90
+ plugins: active.map(({ plugin, ctx }) => ({
91
+ plugin,
92
+ options: ctx.options,
93
+ })),
90
94
  release: releaseSettings(input.config, units),
91
95
  groups,
92
96
  versionFiles: versionFilesOf(active, units),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyrelease/core",
3
- "version": "0.5.0-beta.47",
3
+ "version": "0.5.0-beta.52",
4
4
  "description": "Pure planning core of tallyrelease",
5
5
  "repository": {
6
6
  "type": "git",