@tallyrelease/cli 0.1.1-beta.1 → 0.2.0-beta.7

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/dist/session.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Env, GithubContext } from "./ci/github-env.js";
2
2
  import type { Forge } from "./forge/forge.js";
3
3
  import type { RepoGit } from "./git/reader.js";
4
- import type { ReadonlyFs, Warning, Workspace } from "@tallyrelease/core";
4
+ import type { Plan, PlanOptions, ReadonlyFs, Warning, Workspace } from "@tallyrelease/core";
5
5
  export interface Writer {
6
6
  write: (chunk: string) => unknown;
7
7
  }
@@ -15,6 +15,8 @@ export interface Deps {
15
15
  git?: RepoGit;
16
16
  /** Defaults to `node:fs` at the repo root. */
17
17
  fs?: ReadonlyFs;
18
+ /** Files at a commit; defaults to the git object store at the repo root. */
19
+ fsAt?: (ref: string) => ReadonlyFs;
18
20
  /** Defaults to GitHub when a token is available; used only with a token. */
19
21
  forge?: Forge;
20
22
  }
@@ -36,6 +38,18 @@ export interface Session {
36
38
  /** Undefined without a token. */
37
39
  readonly forge: Forge | undefined;
38
40
  readonly workspace: () => Promise<Workspace>;
41
+ /** Content of `path` at commit `ref`, undefined when absent. */
42
+ readonly readAt: (ref: string, path: string) => string | undefined;
43
+ /**
44
+ * Runs `use` with the workspace and files at commit `ref`, or of the
45
+ * working tree when `ref` is null; the config is always read from the
46
+ * working tree.
47
+ */
48
+ readonly workspaceAt: <T>(ref: string | null, use: (workspace: Workspace, fs: ReadonlyFs) => Promise<T>) => Promise<T>;
49
+ /** `computePlan` with the workspace and files of `workspaceAt(ref)`. */
50
+ readonly planAt: (ref: string | null, opts: Omit<PlanOptions, "fs">) => Promise<Plan>;
39
51
  readonly warn: (warnings: readonly Warning[]) => void;
40
52
  }
41
53
  export declare const createSession: (deps: Deps, opts: GlobalOptions) => Session;
54
+ /** PR head branch, else the GitHub ref name, else the checked-out branch, else `HEAD`. */
55
+ export declare const branchName: (session: Pick<Session, "git" | "github">) => Promise<string>;
package/dist/session.js CHANGED
@@ -1,10 +1,10 @@
1
- import { loadWorkspace, TallyError } from "@tallyrelease/core";
1
+ import { computePlan, loadWorkspace, TallyError } from "@tallyrelease/core";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename, relative, resolve, sep } from "node:path";
4
4
  import { githubContext } from "./ci/github-env.js";
5
5
  import { loadConfig } from "./config/load.js";
6
6
  import { githubForge } from "./forge/github.js";
7
- import { nodeFs } from "./fs-ctx.js";
7
+ import { gitBlob, gitTreeFs, nodeFs } from "./fs-ctx.js";
8
8
  import { git as runGit } from "./git/exec.js";
9
9
  import { gitReader } from "./git/reader.js";
10
10
  import { painter } from "./output/paint.js";
@@ -56,30 +56,42 @@ export const createSession = (deps, opts) => {
56
56
  }
57
57
  return basename(root);
58
58
  };
59
+ const repoGit = deps.git ?? gitReader(root, debug);
60
+ const workspaceAt = async (ref, use) => {
61
+ if (onDisk) {
62
+ await assertRepoRoot();
63
+ }
64
+ const config = loadConfig(fs, configPath);
65
+ const files = ref === null ? fs : (deps.fsAt?.(ref) ?? gitTreeFs(root, ref));
66
+ const workspace = await loadWorkspace({
67
+ config,
68
+ configFile: configPath,
69
+ plugins: Object.values(builtinPlugins),
70
+ fs: files,
71
+ repo: { name: await repoName() },
72
+ ...(debug && { debug }),
73
+ });
74
+ return await use(workspace, files);
75
+ };
59
76
  return {
60
77
  cwd: deps.cwd,
61
78
  root,
62
79
  env: deps.env,
63
80
  stdout: deps.stdout,
64
81
  fs,
65
- git: deps.git ?? gitReader(root, debug),
82
+ git: repoGit,
66
83
  github,
67
84
  forge: token
68
85
  ? (deps.forge ?? githubForge({ token, baseUrl: github?.apiUrl }))
69
86
  : undefined,
70
- workspace: async () => {
71
- if (onDisk) {
72
- await assertRepoRoot();
73
- }
74
- return await loadWorkspace({
75
- config: loadConfig(fs, configPath),
76
- configFile: configPath,
77
- plugins: Object.values(builtinPlugins),
78
- fs,
79
- repo: { name: await repoName() },
80
- ...(debug && { debug }),
81
- });
82
- },
87
+ workspace: async () => await workspaceAt(null, (workspace) => Promise.resolve(workspace)),
88
+ readAt: (ref, path) => deps.fsAt ? deps.fsAt(ref).read(path) : gitBlob(root, ref, path),
89
+ workspaceAt,
90
+ planAt: async (ref, opts) => await workspaceAt(ref, async (workspace, files) => await computePlan(workspace, repoGit, {
91
+ ...opts,
92
+ fs: files,
93
+ ...(debug && { debug }),
94
+ })),
83
95
  warn: (warnings) => {
84
96
  const paint = painter(deps.stderr);
85
97
  for (const w of warnings) {
@@ -88,3 +100,8 @@ export const createSession = (deps, opts) => {
88
100
  },
89
101
  };
90
102
  };
103
+ /** PR head branch, else the GitHub ref name, else the checked-out branch, else `HEAD`. */
104
+ export const branchName = async (session) => session.github?.headRef ??
105
+ session.github?.refName ??
106
+ (await session.git.branch()) ??
107
+ "HEAD";
@@ -0,0 +1,50 @@
1
+ import type { Plan } from "@tallyrelease/core";
2
+ export type ShadowStatus = "match" | "mismatch" | "tallyrelease-only" | "rp-only";
3
+ /** Known divergence classes between tallyrelease and release-please. */
4
+ export type ShadowLabel = "absorbed-internal" | "dependent-patch" | "group-vs-rp" | "release-config" | "separate-pr" | "squash-split";
5
+ export interface ShadowRow {
6
+ /** Manifest path, else the unit root. */
7
+ path: string;
8
+ unit: string | null;
9
+ status: ShadowStatus;
10
+ /** Manifest value at `HEAD^1`. */
11
+ before: string | null;
12
+ /** Manifest value at `HEAD`. */
13
+ after: string | null;
14
+ /** tallyrelease `from`. */
15
+ from: string | null;
16
+ /** tallyrelease `next`. */
17
+ next: string | null;
18
+ /** Sorted. */
19
+ labels: ShadowLabel[];
20
+ }
21
+ export interface ShadowUnit {
22
+ id: string;
23
+ path: string;
24
+ released: boolean;
25
+ dependsOn: readonly string[];
26
+ }
27
+ export interface CompareInput {
28
+ /** Manifest at `HEAD^1`. */
29
+ before: Readonly<Record<string, string>>;
30
+ /** Manifest at `HEAD`. */
31
+ after: Readonly<Record<string, string>>;
32
+ /** Plan of `HEAD^1`. */
33
+ plan: Pick<Plan, "units">;
34
+ units: readonly ShadowUnit[];
35
+ /** Parsed `release-please-config.json`. */
36
+ rpConfig: Readonly<Record<string, unknown>>;
37
+ /** Full commit messages by sha, for `squash-split`. */
38
+ messages: ReadonlyMap<string, string>;
39
+ /** Commit types with a bump level. */
40
+ bumpTypes: ReadonlySet<string>;
41
+ }
42
+ /** True when release-please would split `message` into more commits. */
43
+ export declare const isSquashBody: (message: string) => boolean;
44
+ /**
45
+ * Compares the plan of `HEAD^1` with the manifest change of a release-please
46
+ * merge. Rows are sorted by path; a label names a known divergence class only
47
+ * when it explains the direction of the difference, and never changes the
48
+ * status.
49
+ */
50
+ export declare const compareShadow: (input: CompareInput) => ShadowRow[];
@@ -0,0 +1,112 @@
1
+ import { semver } from "@tallyrelease/core";
2
+ /*
3
+ * release-please (`splitMessages` in src/commit.ts) splits a message into
4
+ * extra commits at a blank line followed by one of these types, and at
5
+ * `BEGIN_NESTED_COMMIT` blocks.
6
+ */
7
+ const SPLIT = /\n\n(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.*?\))?: |BEGIN_NESTED_COMMIT/;
8
+ /** `a > b` as semver; false when either does not parse. */
9
+ const higher = (a, b) => {
10
+ const [x, y] = [a, b].map((v) => (v === null ? undefined : semver.parse(v)));
11
+ return x !== undefined && y !== undefined && semver.compare(x, y) > 0;
12
+ };
13
+ /** True when `v` is exactly the patch bump of `base`. */
14
+ const isPatchOf = (v, base) => {
15
+ const [x, y] = [v, base].map((s) => s === null ? undefined : semver.parse(s));
16
+ return (x !== undefined &&
17
+ y !== undefined &&
18
+ semver.compare(x, semver.bump(y, "patch")) === 0);
19
+ };
20
+ /** Effective `separate-pull-requests` for a manifest path. */
21
+ const separatePr = (rpConfig, path) => {
22
+ const packages = rpConfig["packages"];
23
+ const own = packages?.[path]?.["separate-pull-requests"];
24
+ return (own ?? rpConfig["separate-pull-requests"]) === true;
25
+ };
26
+ /** True when release-please would split `message` into more commits. */
27
+ export const isSquashBody = (message) => SPLIT.test(message);
28
+ /**
29
+ * Compares the plan of `HEAD^1` with the manifest change of a release-please
30
+ * merge. Rows are sorted by path; a label names a known divergence class only
31
+ * when it explains the direction of the difference, and never changes the
32
+ * status.
33
+ */
34
+ export const compareShadow = (input) => {
35
+ const { before, after, plan, units, rpConfig, messages, bumpTypes } = input;
36
+ const changed = new Set(Object.keys(after).filter((path) => before[path] !== after[path]));
37
+ const unitByPath = new Map(units.map((u) => [u.path, u]));
38
+ const pathById = new Map(units.map((u) => [u.id, u.path]));
39
+ const planByPath = new Map(plan.units.map((u) => [u.path, u]));
40
+ // release-please node-workspace patches dependents of changed paths.
41
+ const dependentPatch = (row, planned) => {
42
+ if (row.status === "rp-only") {
43
+ const deps = unitByPath.get(row.path)?.dependsOn ?? [];
44
+ return (isPatchOf(row.after, row.before) &&
45
+ deps.some((id) => changed.has(pathById.get(id) ?? "")));
46
+ }
47
+ return (row.status === "tallyrelease-only" &&
48
+ (planned?.propagatedFrom.length ?? 0) > 0 &&
49
+ isPatchOf(row.next, row.from));
50
+ };
51
+ // tallyrelease is higher because of a bumping change from an absorbed unit.
52
+ const absorbedInternal = (row, planned) => (row.status === "tallyrelease-only" ||
53
+ (row.status === "mismatch" && higher(row.next, row.after))) &&
54
+ (planned?.changes ?? []).some((c) => c.via !== undefined && (c.breaking || bumpTypes.has(c.type)));
55
+ // release-please is higher and split a squash body into more commits.
56
+ const squashSplit = (row, planned) => (row.status === "rp-only" ||
57
+ (row.status === "mismatch" && higher(row.after, row.next))) &&
58
+ (planned?.changes ?? []).some((c) => c.sha !== undefined && isSquashBody(messages.get(c.sha) ?? ""));
59
+ const labels = (row, planned) => {
60
+ if (row.status === "match") {
61
+ return [];
62
+ }
63
+ const unit = unitByPath.get(row.path);
64
+ const checks = [
65
+ ["absorbed-internal", absorbedInternal(row, planned)],
66
+ ["dependent-patch", dependentPatch(row, planned)],
67
+ ["group-vs-rp", Boolean(planned?.group)],
68
+ ["release-config", !unit?.released || !(row.path in after)],
69
+ [
70
+ "separate-pr",
71
+ row.status === "tallyrelease-only" && separatePr(rpConfig, row.path),
72
+ ],
73
+ ["squash-split", squashSplit(row, planned)],
74
+ ];
75
+ return checks.filter(([, hit]) => hit).map(([label]) => label);
76
+ };
77
+ const rows = [];
78
+ for (const path of changed) {
79
+ const unit = unitByPath.get(path);
80
+ const planned = planByPath.get(path);
81
+ const next = planned?.next ?? null;
82
+ const value = after[path] ?? null;
83
+ const row = {
84
+ path,
85
+ unit: unit?.id ?? null,
86
+ status: next === null ? "rp-only" : next === value ? "match" : "mismatch",
87
+ before: before[path] ?? null,
88
+ after: value,
89
+ from: planned?.from ?? null,
90
+ next,
91
+ labels: [],
92
+ };
93
+ rows.push({ ...row, labels: labels(row, planned) });
94
+ }
95
+ for (const planned of plan.units) {
96
+ if (planned.next === null || changed.has(planned.path)) {
97
+ continue;
98
+ }
99
+ const row = {
100
+ path: planned.path,
101
+ unit: planned.id,
102
+ status: "tallyrelease-only",
103
+ before: before[planned.path] ?? null,
104
+ after: after[planned.path] ?? null,
105
+ from: planned.from,
106
+ next: planned.next,
107
+ labels: [],
108
+ };
109
+ rows.push({ ...row, labels: labels(row, planned) });
110
+ }
111
+ return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
112
+ };
@@ -47,6 +47,10 @@
47
47
  "rebuild"
48
48
  ],
49
49
  "description": "Policy for this unit when a dependency is released: patch (at least a patch bump), rebuild (rebuild only) or auto (patch if the edge is pinned, else rebuild)."
50
+ },
51
+ "initialVersion": {
52
+ "type": "string",
53
+ "description": "Version of the first release, parsed by the unit's scheme. Defaults to the scheme's initial version (semver: 1.0.0)."
50
54
  }
51
55
  },
52
56
  "additionalProperties": false,
@@ -114,6 +118,10 @@
114
118
  "pattern": "^[A-Za-z0-9._-]+$"
115
119
  },
116
120
  "description": "Labels for filtering with affected --tag (letters, digits, dot, underscore, dash), for example docker or npm."
121
+ },
122
+ "initialVersion": {
123
+ "type": "string",
124
+ "description": "Version of the first release, parsed by the unit's scheme. Defaults to the scheme's initial version (semver: 1.0.0)."
117
125
  }
118
126
  },
119
127
  "additionalProperties": false
@@ -163,6 +171,158 @@
163
171
  "items": {
164
172
  "type": "string"
165
173
  }
174
+ },
175
+ "branches": {
176
+ "type": "array",
177
+ "items": {
178
+ "type": "object",
179
+ "properties": {
180
+ "name": {
181
+ "type": "string",
182
+ "description": "Branch name pattern; supports * and the captures {major} and {minor}."
183
+ },
184
+ "line": {
185
+ "type": "string",
186
+ "enum": [
187
+ "default",
188
+ "maintenance"
189
+ ],
190
+ "description": "Release line of matching branches."
191
+ },
192
+ "channel": {
193
+ "type": "string",
194
+ "description": "Prerelease channel (letters, digits, dash); may use {major} and {minor} captured by name."
195
+ },
196
+ "unit": {
197
+ "type": "string",
198
+ "description": "Maintenance rules: ungrouped released unit whose version must be on the line captured by name. Excludes group."
199
+ },
200
+ "group": {
201
+ "type": "string",
202
+ "description": "Maintenance rules: fixed group whose version must be on the line captured by name. Excludes unit."
203
+ }
204
+ },
205
+ "required": [
206
+ "name",
207
+ "line"
208
+ ],
209
+ "additionalProperties": false
210
+ },
211
+ "description": "Branch rules; the first rule matching the branch wins."
212
+ },
213
+ "bumps": {
214
+ "type": "object",
215
+ "properties": {
216
+ "types": {
217
+ "type": "object",
218
+ "propertyNames": {
219
+ "type": "string"
220
+ },
221
+ "additionalProperties": {
222
+ "type": "string",
223
+ "enum": [
224
+ "major",
225
+ "minor",
226
+ "patch"
227
+ ]
228
+ },
229
+ "description": "Commit type to bump level, merged over feat: minor, fix: patch, perf: patch, revert: patch. Breaking changes bump major."
230
+ },
231
+ "preMajor": {
232
+ "type": "object",
233
+ "properties": {
234
+ "breaking": {
235
+ "type": "string",
236
+ "enum": [
237
+ "major",
238
+ "minor",
239
+ "patch"
240
+ ]
241
+ },
242
+ "feat": {
243
+ "type": "string",
244
+ "enum": [
245
+ "major",
246
+ "minor",
247
+ "patch"
248
+ ]
249
+ }
250
+ },
251
+ "additionalProperties": false,
252
+ "description": "Bump levels for breaking changes and feat while the major version is 0. Default minor for both."
253
+ },
254
+ "absorbed": {
255
+ "type": "string",
256
+ "enum": [
257
+ "patch",
258
+ "minor"
259
+ ],
260
+ "description": "Level a released unit gets from bump-producing changes absorbed from unreleased dependencies: patch (default) or minor (feat and breaking give minor, fix gives patch)."
261
+ }
262
+ },
263
+ "additionalProperties": false,
264
+ "description": "How commits map to bump levels."
265
+ },
266
+ "groups": {
267
+ "type": "object",
268
+ "propertyNames": {
269
+ "type": "string",
270
+ "pattern": "^(?!\\.\\.?$)[A-Za-z0-9._-]+$"
271
+ },
272
+ "additionalProperties": {
273
+ "anyOf": [
274
+ {
275
+ "type": "object",
276
+ "properties": {
277
+ "units": {
278
+ "type": "array",
279
+ "items": {
280
+ "type": "string",
281
+ "pattern": "^(?!\\.\\.?$)[A-Za-z0-9._-]+$"
282
+ },
283
+ "description": "Member unit ids."
284
+ },
285
+ "tags": {
286
+ "type": "array",
287
+ "items": {
288
+ "type": "string"
289
+ },
290
+ "description": "Selects released units with any of these tags."
291
+ }
292
+ },
293
+ "required": [
294
+ "units"
295
+ ],
296
+ "additionalProperties": false
297
+ },
298
+ {
299
+ "type": "object",
300
+ "properties": {
301
+ "units": {
302
+ "type": "array",
303
+ "items": {
304
+ "type": "string",
305
+ "pattern": "^(?!\\.\\.?$)[A-Za-z0-9._-]+$"
306
+ },
307
+ "description": "Member unit ids."
308
+ },
309
+ "tags": {
310
+ "type": "array",
311
+ "items": {
312
+ "type": "string"
313
+ },
314
+ "description": "Selects released units with any of these tags."
315
+ }
316
+ },
317
+ "required": [
318
+ "tags"
319
+ ],
320
+ "additionalProperties": false
321
+ }
322
+ ]
323
+ },
324
+ "description": "Fixed groups by name (unit id characters): members release together with one version. Each group needs units, tags or both.",
325
+ "minProperties": 1
166
326
  }
167
327
  },
168
328
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyrelease/cli",
3
- "version": "0.1.1-beta.1",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "Command line interface of tallyrelease",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,6 +21,7 @@
21
21
  "default": "./dist/index.js"
22
22
  },
23
23
  "./affected.schema.json": "./dist/affected.schema.json",
24
+ "./plan.schema.json": "./dist/plan.schema.json",
24
25
  "./tallyrelease.schema.json": "./dist/tallyrelease.schema.json"
25
26
  },
26
27
  "bin": {
@@ -48,8 +49,9 @@
48
49
  "prettier": "@abinnovision/prettier-config",
49
50
  "dependencies": {
50
51
  "@octokit/rest": "^22.0.1",
51
- "@tallyrelease/core": "^0.1.1-beta.1",
52
+ "@tallyrelease/core": "^0.2.0-beta.8",
52
53
  "commander": "^15.0.0",
54
+ "picomatch": "^4.0.7",
53
55
  "tinyglobby": "^0.2.17",
54
56
  "yaml": "^2.9.1",
55
57
  "zod": "^4.6.6"
@@ -59,6 +61,7 @@
59
61
  "@abinnovision/prettier-config": "^2.2.0",
60
62
  "@internal/tsconfig": "0.0.0",
61
63
  "@types/node": "^24.19.2",
64
+ "@types/picomatch": "^4.0.3",
62
65
  "@vitest/coverage-v8": "^5.0.4",
63
66
  "ajv": "^8.20.0",
64
67
  "eslint": "^10.12.0",
@@ -79,6 +82,7 @@
79
82
  "default": "./dist/index.js"
80
83
  },
81
84
  "./affected.schema.json": "./dist/affected.schema.json",
85
+ "./plan.schema.json": "./dist/plan.schema.json",
82
86
  "./tallyrelease.schema.json": "./dist/tallyrelease.schema.json"
83
87
  },
84
88
  "npm": true