@tallyrelease/core 0.2.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/abinnovision/tally-release/compare/core-v0.2.0...core-v0.3.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** add tallyrelease pr for shared and separate release PRs ([#34](https://github.com/abinnovision/tally-release/issues/34)) ([5dbb294](https://github.com/abinnovision/tally-release/commit/5dbb2944a6f9535511710cbcfa5c6ddfccb14fe8))
9
+ * **cli:** add tallyrelease run and fill released in plans ([#36](https://github.com/abinnovision/tally-release/issues/36)) ([8d90546](https://github.com/abinnovision/tally-release/commit/8d905469ed398a6daaa0e09f4fd9eb9c4c79c818))
10
+ * **core:** add changelog writer, release items and PR marker ([#30](https://github.com/abinnovision/tally-release/issues/30)) ([e74244e](https://github.com/abinnovision/tally-release/commit/e74244ebc4752e3de39614159ef0761f1cd2a33c))
11
+ * tag merged release PRs with tallyrelease release ([#35](https://github.com/abinnovision/tally-release/issues/35)) ([0058045](https://github.com/abinnovision/tally-release/commit/005804542b3f0b18a1a5afdb1a559231f8d10b4d))
12
+ * write version files and changelogs with tallyrelease version ([#33](https://github.com/abinnovision/tally-release/issues/33)) ([5961ce0](https://github.com/abinnovision/tally-release/commit/5961ce0c56b7ddbb4bff2cf154d5b5556212e8cb))
13
+
3
14
  ## [0.2.0](https://github.com/abinnovision/tally-release/compare/core-v0.1.1...core-v0.2.0) (2026-10-15)
4
15
 
5
16
 
@@ -0,0 +1,4 @@
1
+ /** Standard padded base64 of the UTF-8 encoding of `text`. */
2
+ export declare const encodeBase64: (text: string) => string;
3
+ /** Text of padded base64 holding UTF-8; undefined when either is malformed. */
4
+ export declare const decodeBase64: (data: string) => string | undefined;
package/dist/base64.js ADDED
@@ -0,0 +1,40 @@
1
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
3
+ /** UTF-8 bytes of `text`; throws on lone surrogates. */
4
+ const utf8 = (text) => [...encodeURIComponent(text).matchAll(/%([0-9A-F]{2})|[^%]/gu)].map(([char, hex]) => hex === undefined ? char.charCodeAt(0) : parseInt(hex, 16));
5
+ /** Standard padded base64 of the UTF-8 encoding of `text`. */
6
+ export const encodeBase64 = (text) => {
7
+ const bytes = utf8(text);
8
+ let out = "";
9
+ for (let i = 0; i < bytes.length; i += 3) {
10
+ const [a = 0, b = 0, c = 0] = bytes.slice(i, i + 3);
11
+ const n = a * 65536 + b * 256 + c;
12
+ const chars = [18, 12, 6, 0].map((s) => ALPHABET[Math.floor(n / 2 ** s) % 64]);
13
+ const pad = 3 - Math.min(3, bytes.length - i);
14
+ out += chars.slice(0, 4 - pad).join("") + "=".repeat(pad);
15
+ }
16
+ return out;
17
+ };
18
+ /** Text of padded base64 holding UTF-8; undefined when either is malformed. */
19
+ export const decodeBase64 = (data) => {
20
+ if (!BASE64.test(data)) {
21
+ return undefined;
22
+ }
23
+ let hex = "";
24
+ for (let i = 0; i < data.length; i += 4) {
25
+ const quad = data.slice(i, i + 4);
26
+ const pad = quad.length - quad.replace(/=+$/u, "").length;
27
+ const n = Array.from({ length: 4 }, (_, k) => quad.charAt(k)).reduce((acc, char) => acc * 64 + Math.max(0, ALPHABET.indexOf(char)), 0);
28
+ const bytes = [16, 8, 0].map((s) => Math.floor(n / 2 ** s) % 256);
29
+ hex += bytes
30
+ .slice(0, 3 - pad)
31
+ .map((b) => `%${b.toString(16).padStart(2, "0")}`)
32
+ .join("");
33
+ }
34
+ try {
35
+ return decodeURIComponent(hex);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ };
@@ -0,0 +1,65 @@
1
+ import type { PlanChange, Plan } from "./plan.js";
2
+ import type { Unit, VersionScheme } from "./plugin.js";
3
+ import type { Workspace } from "./workspace.js";
4
+ /** `<root>/CHANGELOG.md` of a unit. */
5
+ export declare const changelogPath: (unit: Pick<Unit, "root">) => string;
6
+ export interface ChangelogDependency {
7
+ /** Manifest field, such as `dependencies`. */
8
+ field: string;
9
+ name: string;
10
+ version: string;
11
+ }
12
+ export interface ChangelogInput {
13
+ version: string;
14
+ /** `YYYY-MM-DD`, the date the section is generated. */
15
+ date: string;
16
+ /** Repository URL such as `https://github.com/o/r`; null omits all links. */
17
+ repoUrl: string | null;
18
+ /** Previous tag of the unit; null omits the compare link. */
19
+ baseTag: string | null;
20
+ tag: string;
21
+ /** Core commit order. */
22
+ changes: readonly PlanChange[];
23
+ dependencies?: readonly ChangelogDependency[];
24
+ /** Group member: lists a synchronize entry when nothing else is listed. */
25
+ member?: {
26
+ unit: string;
27
+ group: string;
28
+ };
29
+ }
30
+ /**
31
+ * One version section in release-please format: heading, breaking notes,
32
+ * sections by commit type with entries sorted like release-please (scope
33
+ * followed by subject, locale order), then dependencies. No trailing newline.
34
+ */
35
+ export declare const renderChangelogSection: (input: ChangelogInput) => string;
36
+ /**
37
+ * The changelog section of a unit with a stable `next` in `plan`, else
38
+ * undefined. Dependencies are direct edges from units of the same release PR,
39
+ * named after the edge reason (`<field>: <name>`, else `dependencies` and the
40
+ * unit id).
41
+ */
42
+ export declare const changelogSection: (workspace: Workspace, plan: Plan, opts: {
43
+ id: string;
44
+ repoUrl: string | null;
45
+ date: string;
46
+ /** Units released in the same release PR; dependencies outside it are not listed. */
47
+ prUnits: readonly string[];
48
+ }) => string | undefined;
49
+ /**
50
+ * Inserts `section` on top of the version sections of `content` (undefined or blank:
51
+ * a new file with `# Changelog`). Version headings are `##` or `###` lines,
52
+ * linked or plain. Sections above `baseVersion` are untagged leftovers and are
53
+ * replaced; without a base version, sections not below the new version are.
54
+ * Text before the first version heading and the other sections stay as they
55
+ * are.
56
+ */
57
+ export declare const updateChangelog: <V>(content: string | undefined, section: string, { baseVersion, scheme, }: {
58
+ baseVersion: string | null;
59
+ scheme: VersionScheme<V>;
60
+ }) => string;
61
+ /**
62
+ * Top version section of unit `id`'s `<root>/CHANGELOG.md`, heading included,
63
+ * read through `read`; undefined without a file or a version section.
64
+ */
65
+ export declare const releaseNotes: (workspace: Workspace, id: string, read: (path: string) => string | undefined) => string | undefined;
@@ -0,0 +1,259 @@
1
+ import { schemeRegistry } from "./schemes.js";
2
+ import { renderTag } from "./tags.js";
3
+ import { compare } from "./util.js";
4
+ /** Commit types in section order; hidden sections list only breaking or Release-As changes. */
5
+ const SECTIONS = [
6
+ ["feat", "Features", false],
7
+ ["feature", "Features", false],
8
+ ["fix", "Bug Fixes", false],
9
+ ["perf", "Performance Improvements", false],
10
+ ["revert", "Reverts", false],
11
+ ["docs", "Documentation", true],
12
+ ["style", "Styles", true],
13
+ ["chore", "Miscellaneous Chores", true],
14
+ ["refactor", "Code Refactoring", true],
15
+ ["test", "Tests", true],
16
+ ["build", "Build System", true],
17
+ ["ci", "Continuous Integration", true],
18
+ ];
19
+ const TITLES = [...new Set(SECTIONS.map(([, title]) => title))];
20
+ const FIELDS = [
21
+ "dependencies",
22
+ "devDependencies",
23
+ "peerDependencies",
24
+ "optionalDependencies",
25
+ ];
26
+ const VERSION_HEADING = /^###? (?:\[v?([^\]\s]+)\]|v?(\S+))/u;
27
+ const EDGE_REASON = /^([A-Za-z]+): (.+)$/u;
28
+ /** release-please `htmlEscape`: `<` and `>` outside code spans. */
29
+ const htmlEscape = (text) => text.replace(/``[^`].*[^`]``|`[^`]*`|<|>/gu, (m) => m.length > 1 ? m : m === "<" ? "&lt;" : "&gt;");
30
+ /** Subject with the trailing PR reference the commit header had. */
31
+ const withPr = (change) => change.pr === undefined
32
+ ? change.subject
33
+ : `${change.subject} (#${String(change.pr)})`;
34
+ /** Subject with its PR, issue and user references linked. */
35
+ const subjectOf = (change, repoUrl) => {
36
+ const text = htmlEscape(withPr(change));
37
+ if (repoUrl === null) {
38
+ return text;
39
+ }
40
+ const host = repoUrl.split("/").slice(0, 3).join("/");
41
+ return text
42
+ .replace(/#([0-9]+)/gu, (_, n) => `[#${n}](${repoUrl}/issues/${n})`)
43
+ .replace(/\B@([a-z0-9](?:-?[a-z0-9/]){0,38})/gu, (m, user) => user.includes("/") ? m : `[@${user}](${host}/${user})`);
44
+ };
45
+ /**
46
+ * Breaking note text as release-please renders it: lines of the first
47
+ * paragraph joined by spaces, except that from the first later line starting
48
+ * with `#### `, `* ` or `- ` on, lines are kept below, indented by four
49
+ * spaces. Without notes, the subject.
50
+ */
51
+ const noteOf = (change, repoUrl) => {
52
+ let text = "";
53
+ let extended = false;
54
+ for (const [i, line] of (change.notes?.split("\n") ?? []).entries()) {
55
+ if (i > 0 && !extended && /^#### |^[*-] /u.test(line)) {
56
+ text = `${text.trim()}\n`;
57
+ extended = true;
58
+ }
59
+ if (line === "") {
60
+ break;
61
+ }
62
+ text += extended ? ` ${line}\n` : `${line} `;
63
+ }
64
+ text = change.notes === undefined ? withPr(change) : text.trim();
65
+ return repoUrl === null
66
+ ? text
67
+ : text.replace(/\(#([0-9]+)\)/u, (_, n) => `([#${n}](${repoUrl}/issues/${n}))`);
68
+ };
69
+ const scoped = (scope, text) => `* ${scope === undefined ? "" : `**${scope}:** `}${text}`;
70
+ const via = (change) => change.via === undefined ? "" : ` (via ${change.via})`;
71
+ const dependencyLines = (deps) => {
72
+ const rank = (field) => {
73
+ const i = FIELDS.indexOf(field);
74
+ return i === -1 ? FIELDS.length : i;
75
+ };
76
+ const fields = [...new Set(deps.map((d) => d.field))].sort((a, b) => rank(a) - rank(b) || compare(a, b));
77
+ return [
78
+ "* The following workspace dependencies were updated",
79
+ ...fields.flatMap((field) => [
80
+ ` * ${field}`,
81
+ ...deps
82
+ .filter((d) => d.field === field)
83
+ .sort((a, b) => compare(a.name, b.name))
84
+ .map((d) => ` * ${d.name} bumped to ${d.version}`),
85
+ ]),
86
+ ].join("\n");
87
+ };
88
+ /** Version of a version heading line, else undefined. */
89
+ const headingVersion = (line, scheme) => {
90
+ const m = VERSION_HEADING.exec(line);
91
+ const raw = m?.[1] ?? m?.[2];
92
+ return raw === undefined ? undefined : scheme.parse(raw);
93
+ };
94
+ /** `<root>/CHANGELOG.md` of a unit. */
95
+ export const changelogPath = (unit) => unit.root === "." ? "CHANGELOG.md" : `${unit.root}/CHANGELOG.md`;
96
+ /**
97
+ * One version section in release-please format: heading, breaking notes,
98
+ * sections by commit type with entries sorted like release-please (scope
99
+ * followed by subject, locale order), then dependencies. No trailing newline.
100
+ */
101
+ export const renderChangelogSection = (input) => {
102
+ const { repoUrl, changes } = input;
103
+ const heading = input.baseTag !== null && repoUrl !== null
104
+ ? `## [${input.version}](${repoUrl}/compare/${input.baseTag}...${input.tag}) (${input.date})`
105
+ : `## ${input.version} (${input.date})`;
106
+ const groups = new Map();
107
+ for (const change of changes) {
108
+ const section = SECTIONS.find(([type]) => type === change.type.toLowerCase());
109
+ const listed = section !== undefined &&
110
+ (!section[2] || change.breaking || change.releaseAs !== undefined);
111
+ if (!section || !listed) {
112
+ continue;
113
+ }
114
+ const subject = subjectOf(change, repoUrl);
115
+ const link = repoUrl === null || change.sha === undefined
116
+ ? ""
117
+ : ` ([${change.sha.slice(0, 7)}](${repoUrl}/commit/${change.sha}))`;
118
+ const entries = groups.get(section[1]) ?? [];
119
+ entries.push({
120
+ key: `${change.scope ?? ""}${subject}`,
121
+ line: `${scoped(change.scope, subject)}${link}${via(change)}`,
122
+ });
123
+ groups.set(section[1], entries);
124
+ }
125
+ const notes = changes
126
+ .filter((c) => c.breaking)
127
+ .map((c) => `${scoped(c.scope, noteOf(c, repoUrl))}${via(c)}`);
128
+ if (input.member && notes.length === 0 && groups.size === 0) {
129
+ const { unit, group } = input.member;
130
+ groups.set("Miscellaneous Chores", [
131
+ { key: "", line: scoped(unit, `Synchronize ${group} versions`) },
132
+ ]);
133
+ }
134
+ let text = `${heading}\n\n`;
135
+ if (notes.length > 0) {
136
+ text += `\n### ⚠ BREAKING CHANGES\n\n${notes.map((n) => `${n}\n`).join("")}`;
137
+ }
138
+ for (const title of TITLES) {
139
+ const entries = groups.get(title);
140
+ if (entries) {
141
+ const lines = entries
142
+ .sort((a, b) => a.key.localeCompare(b.key, "en"))
143
+ .map((e) => `${e.line}\n`);
144
+ text += `\n### ${title}\n\n${lines.join("")}\n`;
145
+ }
146
+ }
147
+ text = text.trim();
148
+ const deps = input.dependencies ?? [];
149
+ return deps.length > 0
150
+ ? `${text}\n\n\n### Dependencies\n\n${dependencyLines(deps)}`
151
+ : text;
152
+ };
153
+ /**
154
+ * The changelog section of a unit with a stable `next` in `plan`, else
155
+ * undefined. Dependencies are direct edges from units of the same release PR,
156
+ * named after the edge reason (`<field>: <name>`, else `dependencies` and the
157
+ * unit id).
158
+ */
159
+ export const changelogSection = (workspace, plan, opts) => {
160
+ const { id } = opts;
161
+ const inPr = new Set(opts.prUnits);
162
+ const units = new Map(workspace.units.map((u) => [u.id, u]));
163
+ const planned = new Map(plan.units.map((u) => [u.id, u]));
164
+ const entry = planned.get(id);
165
+ const unit = units.get(id);
166
+ if (!entry || !unit || entry.next === null) {
167
+ return undefined;
168
+ }
169
+ const dependencies = workspace.graph.edges.flatMap((edge) => {
170
+ const version = planned.get(edge.from)?.next;
171
+ if (edge.to !== id ||
172
+ !inPr.has(edge.from) ||
173
+ version === null ||
174
+ version === undefined) {
175
+ return [];
176
+ }
177
+ const parsed = edge.reasons.flatMap((r) => {
178
+ const m = EDGE_REASON.exec(r);
179
+ return m?.[1] === undefined || m[2] === undefined
180
+ ? []
181
+ : [{ field: m[1], name: m[2], version }];
182
+ });
183
+ return parsed.length > 0
184
+ ? parsed
185
+ : [{ field: "dependencies", name: edge.from, version }];
186
+ });
187
+ return renderChangelogSection({
188
+ version: entry.next,
189
+ date: opts.date,
190
+ repoUrl: opts.repoUrl,
191
+ baseTag: entry.baseTag,
192
+ tag: renderTag(unit, entry.next),
193
+ changes: entry.changes,
194
+ dependencies,
195
+ ...(entry.group === null
196
+ ? {}
197
+ : { member: { unit: id, group: entry.group } }),
198
+ });
199
+ };
200
+ /**
201
+ * Inserts `section` on top of the version sections of `content` (undefined or blank:
202
+ * a new file with `# Changelog`). Version headings are `##` or `###` lines,
203
+ * linked or plain. Sections above `baseVersion` are untagged leftovers and are
204
+ * replaced; without a base version, sections not below the new version are.
205
+ * Text before the first version heading and the other sections stay as they
206
+ * are.
207
+ */
208
+ export const updateChangelog = (content, section, { baseVersion, scheme, }) => {
209
+ const lines = (content?.trim() ? content : "# Changelog\n").split("\n");
210
+ const base = baseVersion === null ? undefined : scheme.parse(baseVersion);
211
+ const next = headingVersion(section.split("\n")[0] ?? "", scheme);
212
+ const stale = (v) => base !== undefined
213
+ ? scheme.compare(v, base) > 0
214
+ : next !== undefined && scheme.compare(v, next) >= 0;
215
+ const preamble = [];
216
+ const kept = [];
217
+ let keep;
218
+ for (const line of lines) {
219
+ const version = headingVersion(line, scheme);
220
+ if (version !== undefined) {
221
+ keep = !stale(version);
222
+ }
223
+ if (keep === undefined) {
224
+ preamble.push(line);
225
+ }
226
+ else if (keep) {
227
+ kept.push(line);
228
+ }
229
+ }
230
+ const rest = kept.join("\n").trim();
231
+ return `${[preamble.join("\n").trimEnd(), section, rest]
232
+ .filter((part) => part !== "")
233
+ .join("\n\n")}\n`;
234
+ };
235
+ /**
236
+ * Top version section of unit `id`'s `<root>/CHANGELOG.md`, heading included,
237
+ * read through `read`; undefined without a file or a version section.
238
+ */
239
+ export const releaseNotes = (workspace, id, read) => {
240
+ const unit = workspace.units.find((u) => u.id === id);
241
+ const scheme = unit && schemeRegistry(workspace.schemes).get(unit.scheme);
242
+ const content = unit && read(changelogPath(unit));
243
+ if (!scheme || content === undefined) {
244
+ return undefined;
245
+ }
246
+ const section = [];
247
+ for (const line of content.split("\n")) {
248
+ if (headingVersion(line, scheme) !== undefined) {
249
+ if (section.length > 0) {
250
+ break;
251
+ }
252
+ }
253
+ else if (section.length === 0) {
254
+ continue;
255
+ }
256
+ section.push(line);
257
+ }
258
+ return section.length === 0 ? undefined : section.join("\n").trim();
259
+ };
package/dist/config.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { BumpLevel, Propagate, VersionSource } from "./plugin.js";
2
+ /** Release PR of a release item: the shared PR or its own. */
3
+ export type ReleasePrMode = "shared" | "separate";
2
4
  /** Config overlay for the unit at a root; creates a config-only unit if none is discovered. */
3
5
  export interface UnitOverlay {
4
6
  id?: string;
@@ -15,6 +17,18 @@ export interface UnitOverlay {
15
17
  tags?: string[];
16
18
  /** First release version, parsed by the unit's scheme. */
17
19
  initialVersion?: string;
20
+ /** Ungrouped released units only; default `defaults.releasePr`. */
21
+ releasePr?: ReleasePrMode;
22
+ /** Templated text replacements written on release. */
23
+ versionFiles?: VersionTemplate[];
24
+ }
25
+ /**
26
+ * A text file holding a unit's version. `file` is relative to the repository
27
+ * root; `pattern` uses `{id}`, `{version}`, `{major}`, `{minor}` and `{patch}`.
28
+ */
29
+ export interface VersionTemplate {
30
+ file: string;
31
+ pattern: string;
18
32
  }
19
33
  /** A branch rule; the first rule whose `name` matches the branch wins. */
20
34
  export interface BranchConfig {
@@ -45,6 +59,8 @@ export interface GroupConfig {
45
59
  units?: string[];
46
60
  /** Selects released units with any of these tags. */
47
61
  tags?: string[];
62
+ /** Default `defaults.releasePr`. */
63
+ releasePr?: ReleasePrMode;
48
64
  }
49
65
  export interface DepConfig {
50
66
  from: string;
@@ -64,6 +80,8 @@ export interface TallyConfig {
64
80
  tagFormat?: string;
65
81
  propagate?: Propagate;
66
82
  initialVersion?: string;
83
+ /** Default `shared`. */
84
+ releasePr?: ReleasePrMode;
67
85
  };
68
86
  /** Overlays keyed by unit root. */
69
87
  units?: Record<string, UnitOverlay>;
@@ -74,6 +92,10 @@ export interface TallyConfig {
74
92
  bumps?: BumpsConfig;
75
93
  /** Fixed groups by name; names follow the unit id pattern. */
76
94
  groups?: Record<string, GroupConfig>;
95
+ tags?: {
96
+ /** Floating tag formats with `{id}`, `{major}` and `{minor}`. */
97
+ floating?: string[];
98
+ };
77
99
  }
78
100
  /** Global input owned by core. */
79
101
  export declare const CONFIG_FILE = "tallyrelease.yaml";
@@ -76,6 +76,11 @@ export const conventionalCommits = {
76
76
  collect: (_ctx, { commits }) => commits.map((commit) => {
77
77
  const parsed = parseCommit(commit.message);
78
78
  const values = parsed.footers["release-as"] ?? [];
79
+ // The last breaking footer wins, as in release-please.
80
+ const notes = [
81
+ ...(parsed.footers["breaking change"] ?? []),
82
+ ...(parsed.footers["breaking-change"] ?? []),
83
+ ].at(-1);
79
84
  return {
80
85
  sha: commit.sha,
81
86
  type: parsed.type,
@@ -83,6 +88,7 @@ export const conventionalCommits = {
83
88
  ...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
84
89
  subject: parsed.subject,
85
90
  ...(parsed.body === undefined ? {} : { body: parsed.body }),
91
+ ...(notes === undefined ? {} : { notes }),
86
92
  ...(parsed.pr === undefined ? {} : { pr: parsed.pr }),
87
93
  ...(values.length === 0 ? {} : { releaseAs: values.map(releaseAs) }),
88
94
  };
@@ -1,5 +1,7 @@
1
1
  import type { Unit } from "./plugin.js";
2
2
  import type { ActivePlugin, PluginEnv } from "./plugins.js";
3
+ /** Drops `.` and empty segments; rejects absolute roots and `..` segments. */
4
+ export declare const normalizeRoot: (root: string) => string;
3
5
  /**
4
6
  * Runs `discover` hooks in config order, applies config overlays and fills
5
7
  * defaults. Returns units sorted by id.
package/dist/discover.js CHANGED
@@ -4,17 +4,17 @@ import { runHook } from "./plugins.js";
4
4
  import { compare, uniqueSorted } from "./util.js";
5
5
  const UNIT_ID = /^(?!\.\.?$)[A-Za-z0-9._-]+$/;
6
6
  const UNIT_TAG = /^[A-Za-z0-9._-]+$/;
7
+ /** Escapes glob metacharacters so a root matches literally. */
8
+ const escapeGlob = (path) => path.replace(/[[\]{}()!*?+@\\]/g, "\\$&");
9
+ const isInside = (path, root) => path !== root && (root === "." || path.startsWith(`${root}/`));
7
10
  /** Drops `.` and empty segments; rejects absolute roots and `..` segments. */
8
- const normalizeRoot = (root) => {
11
+ export const normalizeRoot = (root) => {
9
12
  const segments = root.split("/");
10
13
  if (root.startsWith("/") || segments.includes("..")) {
11
14
  throw new TallyError("E_ROOT_INVALID", `root "${root}" must be repo-relative without ".." segments`, { root });
12
15
  }
13
16
  return segments.filter((s) => s !== "" && s !== ".").join("/") || ".";
14
17
  };
15
- /** Escapes glob metacharacters so a root matches literally. */
16
- const escapeGlob = (path) => path.replace(/[[\]{}()!*?+@\\]/g, "\\$&");
17
- const isInside = (path, root) => path !== root && (root === "." || path.startsWith(`${root}/`));
18
18
  /**
19
19
  * Runs `discover` hooks in config order, applies config overlays and fills
20
20
  * defaults. Returns units sorted by id.
@@ -0,0 +1,14 @@
1
+ import type { Unit, VersionScheme } from "./plugin.js";
2
+ /** Throws `E_CONFIG` for a floating tag format with an unknown token. */
3
+ export declare const validateFloating: (formats: readonly string[]) => void;
4
+ /**
5
+ * Floating tags `version` takes: per format, the rendered name when `version`
6
+ * is stable and not lower than any stable version among the unit's `tags`
7
+ * (tag names, others ignored) that renders to the same name. In format order,
8
+ * without duplicates.
9
+ */
10
+ export declare const renderFloatingTags: <V>(unit: Pick<Unit, "id" | "tagFormat">, version: string, { tags, scheme, formats, }: {
11
+ tags: readonly string[];
12
+ scheme: VersionScheme<V>;
13
+ formats: readonly string[];
14
+ }) => string[];
@@ -0,0 +1,44 @@
1
+ import { TallyError } from "./errors.js";
2
+ import { compileTagFormat } from "./tags.js";
3
+ const TOKEN = /\{([^{}]*)\}/gu;
4
+ const TOKENS = new Set(["id", "major", "minor"]);
5
+ /** Throws `E_CONFIG` for a floating tag format with an unknown token. */
6
+ export const validateFloating = (formats) => {
7
+ for (const format of formats) {
8
+ const unknown = [...format.matchAll(TOKEN)].find(([, token]) => !TOKENS.has(token ?? ""));
9
+ if (unknown) {
10
+ throw new TallyError("E_CONFIG", `floating tag format "${format}" uses unknown token "${unknown[0]}"; use {id}, {major} or {minor}`, { format });
11
+ }
12
+ }
13
+ };
14
+ /**
15
+ * Floating tags `version` takes: per format, the rendered name when `version`
16
+ * is stable and not lower than any stable version among the unit's `tags`
17
+ * (tag names, others ignored) that renders to the same name. In format order,
18
+ * without duplicates.
19
+ */
20
+ export const renderFloatingTags = (unit, version, { tags, scheme, formats, }) => {
21
+ const released = scheme.parse(version);
22
+ if (released === undefined || scheme.isPrerelease(released)) {
23
+ return [];
24
+ }
25
+ const format = compileTagFormat(unit.tagFormat, unit.id);
26
+ const versions = tags.flatMap((tag) => {
27
+ const raw = format.match(tag);
28
+ const v = raw === undefined ? undefined : scheme.parse(raw);
29
+ return v === undefined || scheme.isPrerelease(v) ? [] : [v];
30
+ });
31
+ const render = (pattern, v) => {
32
+ const { major, minor } = scheme.core(v);
33
+ return pattern
34
+ .replaceAll("{id}", unit.id)
35
+ .replaceAll("{major}", String(major))
36
+ .replaceAll("{minor}", String(minor));
37
+ };
38
+ const names = formats.flatMap((pattern) => {
39
+ const name = render(pattern, released);
40
+ const highest = versions.every((v) => render(pattern, v) !== name || scheme.compare(v, released) <= 0);
41
+ return highest ? [name] : [];
42
+ });
43
+ return [...new Set(names)];
44
+ };
package/dist/index.d.ts CHANGED
@@ -1,28 +1,37 @@
1
1
  export declare const version = "0.0.0";
2
2
  export type { AffectedDoc, AffectedOptions, AffectedReason, AffectedUnit, BaseSource, } from "./affected.js";
3
+ export type { ChangelogDependency, ChangelogInput } from "./changelog.js";
3
4
  export type { ParsedCommit } from "./conventional.js";
4
5
  export type { ExplainedChange, ExplainOptions, ReleaseAsFooter, UnitExplanation, } from "./explain.js";
5
6
  export type { GroupPlan, Plan, PlanChange, PlanLine, PlanOptions, ReleasedUnit, UnitPlan, } from "./plan.js";
6
- export type { DepConfig, GroupConfig, TallyConfig, UnitOverlay, } from "./config.js";
7
+ export type { DepConfig, GroupConfig, ReleasePrMode, TallyConfig, UnitOverlay, VersionTemplate, } from "./config.js";
7
8
  export type { Warning } from "./errors.js";
8
9
  export type { Graph, SourcedEdge } from "./graph.js";
9
10
  export type { Group } from "./groups.js";
10
11
  export type { BumpLevel, Change, Ctx, Edge, EdgeDraft, GitReader, IntentSource, MaybePromise, Plugin, PreCtx, Propagate, RawCommit, ReadonlyFs, ReleaseAsValue, TagRef, Unit, UnitDraft, VersionFile, VersionMode, VersionScheme, VersionSource, WriteCtx, } from "./plugin.js";
12
+ export type { Marker, MarkerRelease } from "./marker.js";
11
13
  export type { ActivePlugin, PluginEnv } from "./plugins.js";
14
+ export type { ReleaseItem } from "./release-items.js";
12
15
  export type { BranchRule, ReleaseSettings } from "./release.js";
13
16
  export type { SchemeEntry } from "./schemes.js";
14
17
  export type { SemVer } from "./semver.js";
15
18
  export type { IntentEntry, Workspace, WorkspaceInput } from "./workspace.js";
16
19
  export { computeAffected } from "./affected.js";
20
+ export { changelogSection, releaseNotes, renderChangelogSection, updateChangelog, } from "./changelog.js";
17
21
  export { CONFIG_FILE } from "./config.js";
18
22
  export { conventionalCommits, parseCommit } from "./conventional.js";
19
23
  export { discoverUnits } from "./discover.js";
20
24
  export { TallyError } from "./errors.js";
21
25
  export { explainUnit } from "./explain.js";
26
+ export { renderFloatingTags } from "./floating.js";
22
27
  export { buildGraph } from "./graph.js";
23
28
  export { definePlugin, defaultUnitId, PLUGIN_API_VERSION } from "./plugin.js";
24
29
  export { computePlan } from "./plan.js";
30
+ export { decodeMarker, encodeMarker } from "./marker.js";
25
31
  export { activatePlugins } from "./plugins.js";
32
+ export { planEquals, releaseEntries, releaseItems } from "./release-items.js";
33
+ export { highestReleases, releasedFromTags, withReleased } from "./released.js";
26
34
  export { semver } from "./semver.js";
27
35
  export { validateRelease, validateTags } from "./validate.js";
36
+ export { releaseFiles, versionFileClaims } from "./version-files.js";
28
37
  export { loadWorkspace } from "./workspace.js";
package/dist/index.js CHANGED
@@ -1,14 +1,20 @@
1
1
  export const version = "0.0.0";
2
2
  export { computeAffected } from "./affected.js";
3
+ export { changelogSection, releaseNotes, renderChangelogSection, updateChangelog, } from "./changelog.js";
3
4
  export { CONFIG_FILE } from "./config.js";
4
5
  export { conventionalCommits, parseCommit } from "./conventional.js";
5
6
  export { discoverUnits } from "./discover.js";
6
7
  export { TallyError } from "./errors.js";
7
8
  export { explainUnit } from "./explain.js";
9
+ export { renderFloatingTags } from "./floating.js";
8
10
  export { buildGraph } from "./graph.js";
9
11
  export { definePlugin, defaultUnitId, PLUGIN_API_VERSION } from "./plugin.js";
10
12
  export { computePlan } from "./plan.js";
13
+ export { decodeMarker, encodeMarker } from "./marker.js";
11
14
  export { activatePlugins } from "./plugins.js";
15
+ export { planEquals, releaseEntries, releaseItems } from "./release-items.js";
16
+ export { highestReleases, releasedFromTags, withReleased } from "./released.js";
12
17
  export { semver } from "./semver.js";
13
18
  export { validateRelease, validateTags } from "./validate.js";
19
+ export { releaseFiles, versionFileClaims } from "./version-files.js";
14
20
  export { loadWorkspace } from "./workspace.js";
@@ -0,0 +1,17 @@
1
+ /** A release recorded in a release PR marker. */
2
+ export interface MarkerRelease {
3
+ id: string;
4
+ version: string;
5
+ tag: string;
6
+ }
7
+ /** Release PR marker, version 1. */
8
+ export interface Marker {
9
+ /** Line head the release commit was built on. */
10
+ head: string;
11
+ releaseCommit: string;
12
+ releases: MarkerRelease[];
13
+ }
14
+ /** `<!-- tallyrelease-plan:v1 <base64 json> -->` holding only the marker fields. */
15
+ export declare const encodeMarker: ({ head, releaseCommit, releases, }: Marker) => string;
16
+ /** The marker in a PR body; undefined when missing or malformed. */
17
+ export declare const decodeMarker: (body: string) => Marker | undefined;