rman 1.0.9 → 1.0.12

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.
@@ -26,14 +26,21 @@ export var RunService;
26
26
  * `getScriptSteps`:
27
27
  * run:
28
28
  * build:
29
- * script: tsc -b
30
- * preScript: [node ./generate.js, node ./validate.js]
31
- * postScript: node ./copy-assets.js
29
+ * before: [node ./generate.js, node ./validate.js]
30
+ * exec: tsc -b
31
+ * after: node ./copy-assets.js
32
32
  * override: true # use these even if the package *does* define its own
33
+ *
34
+ * A bare string is shorthand for `exec`, which is by far the common case - a script that is just
35
+ * a command, with nothing to configure about how it runs:
36
+ * run:
37
+ * test: mocha # same as test: { exec: mocha }
33
38
  */
34
39
  function getConfig(pkg, script) {
35
40
  const runCfg = pkg.config?.run;
36
41
  const cfg = runCfg && typeof runCfg === 'object' ? runCfg[script] : undefined;
42
+ if (typeof cfg === 'string' || Array.isArray(cfg))
43
+ return { exec: cfg };
37
44
  return cfg && typeof cfg === 'object' ? cfg : {};
38
45
  }
39
46
  RunService.getConfig = getConfig;
@@ -197,10 +204,14 @@ export var RunService;
197
204
  const ifStatusCache = new Map();
198
205
  /** Repo-wide bookend: root's own pre/post hooks run once each, exclusively, around every package
199
206
  * (unless root itself opts out via `run.<script>.skip`, fails its own `run.<script>.if`, or the
200
- * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there). */
201
- const rootIf = !cwdScope && parseIfExpr(rootCfg.if);
207
+ * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there).
208
+ *
209
+ * Only in a monorepo. Without one the root *is* the single package, already in the loop below
210
+ * with the same hooks and the same directory - a bookend would simply run each of them a
211
+ * second time. */
212
+ const rootIf = !cwdScope && repository.monorepo && parseIfExpr(rootCfg.if);
202
213
  const rootIfPasses = rootIf ? await evaluateIf(repository, repository.rootPackage, rootIf, ifStatusCache) : true;
203
- const rootSkipped = !!cwdScope || rootCfg.skip === true || !rootIfPasses;
214
+ const rootSkipped = !!cwdScope || !repository.monorepo || rootCfg.skip === true || !rootIfPasses;
204
215
  const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
205
216
  const rootPre = rootSteps.filter(s => s.name === 'pre' + script);
206
217
  const rootPost = rootSteps.filter(s => s.name === 'post' + script);
@@ -255,11 +266,36 @@ export var RunService;
255
266
  }));
256
267
  }
257
268
  if (!children.length) {
258
- console.log(colors.gray(`No package defines a "${script}" script.`));
259
- return;
269
+ /**
270
+ * Two different nothings, and only one of them is fine.
271
+ *
272
+ * Nobody in the repository defines this script at all: the name is a mistake - a typo, or a
273
+ * script that used to exist - and `npm run` fails on exactly this. Staying silent is how
274
+ * `rman run qc` sat in a CI pipeline for months reporting success while running nothing, with
275
+ * `qc` defined only on the root (whose own scripts a monorepo never runs, only its
276
+ * `pre`/`post` bookends).
277
+ *
278
+ * Everything was filtered out instead - `--scope`, `--changed`, `run.<script>.skip`, an
279
+ * `if:` that didn't match: zero is the correct answer to what was asked, and asking "build
280
+ * only what changed" when nothing changed must not fail a pipeline.
281
+ */
282
+ /** `getPackages()` and nothing else - in a monorepo that excludes the root, which is the
283
+ * point: the root contributes only `pre`/`post` bookends, never the script itself, so a
284
+ * `qc` defined *only* there is exactly the mistake above rather than an excuse for it. (And
285
+ * had the root contributed a bookend, `children` wouldn't be empty.) In a single-package
286
+ * repository the root *is* the one package, and is covered. */
287
+ const definedSomewhere = repository.getPackages().some(pkg => getScriptSteps(pkg, script).length > 0);
288
+ if (definedSomewhere) {
289
+ console.log(colors.gray(`Nothing to run - every package was filtered out of "${script}".`));
290
+ return;
291
+ }
292
+ const message = `No package defines a "${script}" script.`;
293
+ console.log(colors.red(message));
294
+ const err = new Error(message);
295
+ err.logged = true;
296
+ throw err;
260
297
  }
261
298
  panel.start();
262
- let failed = false;
263
299
  try {
264
300
  /** bail:false here - each package's own resolved bail setting decides whether to call
265
301
  * `rootTask.abort()` itself (see `runSteps`), since power-tasks' own `bail` can't vary per package. */
@@ -267,13 +303,25 @@ export var RunService;
267
303
  await rootTask.toPromise();
268
304
  }
269
305
  catch {
270
- failed = true;
306
+ // Swallowed on purpose: whether the root promise rejected says nothing reliable about the
307
+ // run - see below. The per-package tallies are what decide.
271
308
  }
272
309
  finally {
310
+ /** A package's own `bail` aborts the root task, which settles its promise *immediately* while
311
+ * the packages already in flight keep running. Waiting for every child here is what makes
312
+ * the summary below describe a finished run rather than a snapshot of one still going -
313
+ * measured: it printed "0 succeeded, 1 failed, 3 skipped" and then three of those "skipped"
314
+ * packages went on to succeed. */
315
+ await Promise.allSettled(children.map(child => child.toPromise()));
273
316
  panel.stop();
274
317
  }
275
- panel.printSummary();
276
- if (failed) {
318
+ const summary = panel.printSummary();
319
+ /** The tallies, never `rootTask.toPromise()`'s own outcome: with a sibling still in flight at
320
+ * the moment one package failed, that promise *resolves*, and this command used to exit 0 on a
321
+ * run it had just reported as failed - non-deterministically, since it came down to which
322
+ * packages happened to still be running (measured: `1 0 1 1 0` across five identical runs).
323
+ * A single failed package must fail the command, every time. */
324
+ if (summary.failedCount > 0) {
277
325
  const err = new Error(`"${script}" failed`);
278
326
  err.logged = true;
279
327
  throw err;
@@ -336,9 +384,9 @@ function getScriptSteps(pkg, script) {
336
384
  if (cfg.override === true || !hasOwn)
337
385
  json.scripts[key] = value;
338
386
  };
339
- applyConfigScript(script, cfg.script);
340
- applyConfigScript('pre' + script, cfg.preScript);
341
- applyConfigScript('post' + script, cfg.postScript);
387
+ applyConfigScript(script, cfg.exec);
388
+ applyConfigScript('pre' + script, cfg.before);
389
+ applyConfigScript('post' + script, cfg.after);
342
390
  json.scripts[script] = json.scripts[script] || '#';
343
391
  const info = parseNpmScript(json, 'npm run ' + script);
344
392
  if (!info?.raw?.length)
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import semver from 'semver';
3
4
  import { detectChangeHash, expandTag } from '../utils/change-hash.js';
@@ -6,6 +7,7 @@ import { exec } from '../utils/exec.js';
6
7
  import { GitHelper } from '../utils/git.js';
7
8
  import { filterPackages } from '../utils/package-filter.js';
8
9
  import { expandReleaseTag, findLastReleaseVersion, formatCalendarVersion, isCalendarVersion, usesCalendarVersion, } from '../utils/release-version.js';
10
+ import { stampVersionConstant, stampVersionLabel } from '../utils/version-stamp.js';
9
11
  import { parseWorkspaceRange } from '../utils/workspace-range.js';
10
12
  import { ChangelogService } from './changelog.service.js';
11
13
  export var VersionService;
@@ -133,9 +135,13 @@ export var VersionService;
133
135
  const isRealEntry = (e) => !(repository.monorepo && e.package === repository.rootPackage);
134
136
  const bumped = plan.filter(e => e.status === 'bump' && isRealEntry(e));
135
137
  const bumpedByName = new Map(bumped.map(e => [e.package.name, e]));
138
+ /** Repo-relative paths of every file stamped with the new version below (a Dockerfile label, a
139
+ * source constant), so each lands in the same commit as the bump that made it stale - keyed by
140
+ * package, the way `changelogFileByPackage` is. */
141
+ const stampedByPackage = new Map();
136
142
  for (const entry of bumped) {
137
143
  const pkg = entry.package;
138
- await runVersionScript(pkg, 'preScript', 'preversion');
144
+ await runVersionScript(pkg, 'before', 'preversion');
139
145
  pkg.json.version = entry.to;
140
146
  for (const depKey of DEPENDENCY_KEYS) {
141
147
  const deps = pkg.json[depKey];
@@ -157,9 +163,14 @@ export var VersionService;
157
163
  deps[depName] = '^' + depEntry.to;
158
164
  }
159
165
  }
160
- await runVersionScript(pkg, 'script', 'version');
166
+ await runVersionScript(pkg, 'exec', 'version');
161
167
  pkg.writeJson();
162
- await runVersionScript(pkg, 'postScript', 'postversion');
168
+ // Before `postversion`, so a script that reacts to the bump sees the whole new state.
169
+ const stamped = [stampDockerfile(pkg, entry.to), ...stampSourceFiles(pkg, entry.to)].filter((f) => !!f);
170
+ if (stamped.length) {
171
+ stampedByPackage.set(pkg.name, stamped.map(f => path.relative(repository.dirname, f)));
172
+ }
173
+ await runVersionScript(pkg, 'after', 'postversion');
163
174
  }
164
175
  const rootEntry = repository.monorepo ? plan.find(e => e.package === repository.rootPackage) : undefined;
165
176
  if (rootEntry?.status === 'bump') {
@@ -218,6 +229,7 @@ export var VersionService;
218
229
  const changelogFile = changelogFileByPackage.get(e.package.name);
219
230
  if (changelogFile)
220
231
  files.push(changelogFile);
232
+ files.push(...(stampedByPackage.get(e.package.name) ?? []));
221
233
  }
222
234
  await git.commit(files, buildCommitMessage(repository, groupEntries, options.message));
223
235
  const tags = new Set(groupEntries.map(e => expandTag(e.package, e.to)));
@@ -490,3 +502,55 @@ async function runVersionScript(pkg, cfgKey, npmScriptName) {
490
502
  if (command)
491
503
  await exec(command, { cwd: pkg.dirname, stdio: 'inherit' });
492
504
  }
505
+ /**
506
+ * Keeps a package's Dockerfile `org.opencontainers.image.version` label in step with the version
507
+ * just written, returning the absolute path when it actually changed (so the caller can fold it
508
+ * into the same commit) and `undefined` otherwise.
509
+ *
510
+ * Here rather than in a build script: the label is a *statement of the package's version*, so it
511
+ * belongs to whatever writes that version - which keeps it in the bump commit, leaves the tree
512
+ * clean, and makes it right for anyone building the Dockerfile by hand. A build-time rewrite is
513
+ * both later than it needs to be and invisible to git.
514
+ *
515
+ * The same path `publish --target docker` builds from (`publish.docker.dockerfile`, default
516
+ * `Dockerfile`), so the two can never disagree about which file this is. Opt out with `.rmanrc
517
+ * "version": { "stampDockerfile": false }`; a package with no Dockerfile, or one that doesn't
518
+ * declare the label, is a no-op either way.
519
+ */
520
+ function stampDockerfile(pkg, version) {
521
+ if (pkg.config?.version?.stampDockerfile === false)
522
+ return undefined;
523
+ const file = path.resolve(pkg.dirname, pkg.config?.publish?.docker?.dockerfile || 'Dockerfile');
524
+ if (!fs.existsSync(file))
525
+ return undefined;
526
+ const stamped = stampVersionLabel(fs.readFileSync(file, 'utf-8'), version);
527
+ if (stamped === undefined)
528
+ return undefined;
529
+ fs.writeFileSync(file, stamped, 'utf-8');
530
+ return file;
531
+ }
532
+ /**
533
+ * Keeps every `.rmanrc "version.stamp"` source file's `version` constant in step with the version
534
+ * just written, returning the absolute paths of the ones that actually changed.
535
+ *
536
+ * Explicitly listed rather than discovered: unlike the OCI Dockerfile label there is no standard
537
+ * saying "this file holds the version", and the assignment this matches is deliberately broad. A
538
+ * listed file a package doesn't have is a silent no-op, which is what lets one `"[*]"` declaration
539
+ * cover a repo where only some packages carry one.
540
+ */
541
+ function stampSourceFiles(pkg, version) {
542
+ const configured = pkg.config?.version?.stamp;
543
+ const patterns = typeof configured === 'string' ? [configured] : (configured ?? []);
544
+ const stamped = [];
545
+ for (const rel of patterns) {
546
+ const file = path.resolve(pkg.dirname, rel);
547
+ if (!fs.existsSync(file))
548
+ continue;
549
+ const next = stampVersionConstant(fs.readFileSync(file, 'utf-8'), version);
550
+ if (next === undefined)
551
+ continue;
552
+ fs.writeFileSync(file, next, 'utf-8');
553
+ stamped.push(file);
554
+ }
555
+ return stamped;
556
+ }
@@ -15,8 +15,8 @@ export declare function tagPattern(pkg: Package): string;
15
15
  export declare function findLatestTag(git: GitHelper, pkg: Package): Promise<string | undefined>;
16
16
  /** The forward direction of `findLatestTag`: expands `pkg`'s (cascaded) `.rmanrc
17
17
  * changelog.tagPattern` into the concrete tag name `version` belongs under - `{name}` becomes the
18
- * package's own name, `*` becomes `version`. Shared by `version` (creating the tag), `publish
19
- * --target github` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
18
+ * package's own name, `*` becomes `version`. Shared by `version` (creating the tag),
19
+ * `github-release` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
20
20
  * fallback (mapping a published version back onto a tag), so all three name tags identically. */
21
21
  export declare function expandTag(pkg: Package, version: string): string;
22
22
  /** The pattern expansion `expandTag` performs, on any pattern - `{name}` becomes `name`, `*` becomes
@@ -22,8 +22,8 @@ export async function findLatestTag(git, pkg) {
22
22
  }
23
23
  /** The forward direction of `findLatestTag`: expands `pkg`'s (cascaded) `.rmanrc
24
24
  * changelog.tagPattern` into the concrete tag name `version` belongs under - `{name}` becomes the
25
- * package's own name, `*` becomes `version`. Shared by `version` (creating the tag), `publish
26
- * --target github` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
25
+ * package's own name, `*` becomes `version`. Shared by `version` (creating the tag),
26
+ * `github-release` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
27
27
  * fallback (mapping a published version back onto a tag), so all three name tags identically. */
28
28
  export function expandTag(pkg, version) {
29
29
  return applyTagPattern(tagPattern(pkg), pkg.name, version);
@@ -63,5 +63,5 @@ export declare function npmRunPath(options?: RunPathOptions): string;
63
63
  ```
64
64
  */
65
65
  export declare function npmRunPathEnv(options?: EnvOptions): {
66
- [x: string]: string | undefined;
66
+ [key: string]: string | undefined;
67
67
  };
@@ -0,0 +1,40 @@
1
+ /** Keeping a package's version truthful wherever the package itself declares it - its Dockerfile's
2
+ * OCI label, and any source file that hard-codes it as a constant. Both are `version`'s job rather
3
+ * than a build step's: a version is being written, these are places it is written, and doing it
4
+ * here puts them in the bump commit instead of leaving the repository disagreeing with itself. */
5
+ /** The OCI label whose value is, by specification, "version of the packaged software" - so a
6
+ * package's own `package.json` version is the only correct value it can hold, which is what makes
7
+ * stamping it automatic rather than something to configure. */
8
+ export declare const OCI_VERSION_LABEL = "org.opencontainers.image.version";
9
+ /**
10
+ * Rewrites every `org.opencontainers.image.version` value in a Dockerfile's `LABEL` instructions to
11
+ * `version`, returning the new content - or `undefined` when there was nothing to change (no such
12
+ * label, or it already holds this version), so a caller can skip writing the file at all.
13
+ *
14
+ * Only ever *rewrites*: a Dockerfile that doesn't declare the label is left exactly as it is rather
15
+ * than having one inserted. Which labels an image carries is the author's decision; keeping one
16
+ * they already declared truthful is not.
17
+ *
18
+ * The existing quoting style is preserved, so the diff is the version and nothing else.
19
+ */
20
+ export declare function stampVersionLabel(content: string, version: string): string | undefined;
21
+ /**
22
+ * Rewrites the string assigned to a `name` constant (default `version`) in a source file to
23
+ * `version`, returning the new content - or `undefined` when there was nothing to change, so a
24
+ * caller can skip writing the file at all. Both an assignment and an object property are matched:
25
+ *
26
+ * ```ts
27
+ * export const version = '1'; -> export const version = '6.0.10';
28
+ * { name: 'app', version: '1' } -> { name: 'app', version: '6.0.10' }
29
+ * ```
30
+ *
31
+ * Unlike the Dockerfile label there is no standard naming a file as holding the version, so this
32
+ * only ever runs against files `.rmanrc "version.stamp"` explicitly lists - which is also what
33
+ * keeps a match this broad safe.
34
+ *
35
+ * Stamping the *source* rather than the build output is the point: a build-time rewrite leaves the
36
+ * checked-in file claiming some placeholder, so anything running from source (tests, ts-node, the
37
+ * dev loop) reports that placeholder, git never records the released version, and the rewrite has
38
+ * to be redone on every build.
39
+ */
40
+ export declare function stampVersionConstant(content: string, version: string, name?: string): string | undefined;
@@ -0,0 +1,76 @@
1
+ /** Keeping a package's version truthful wherever the package itself declares it - its Dockerfile's
2
+ * OCI label, and any source file that hard-codes it as a constant. Both are `version`'s job rather
3
+ * than a build step's: a version is being written, these are places it is written, and doing it
4
+ * here puts them in the bump commit instead of leaving the repository disagreeing with itself. */
5
+ /** The OCI label whose value is, by specification, "version of the packaged software" - so a
6
+ * package's own `package.json` version is the only correct value it can hold, which is what makes
7
+ * stamping it automatic rather than something to configure. */
8
+ export const OCI_VERSION_LABEL = 'org.opencontainers.image.version';
9
+ /**
10
+ * Rewrites every `org.opencontainers.image.version` value in a Dockerfile's `LABEL` instructions to
11
+ * `version`, returning the new content - or `undefined` when there was nothing to change (no such
12
+ * label, or it already holds this version), so a caller can skip writing the file at all.
13
+ *
14
+ * Only ever *rewrites*: a Dockerfile that doesn't declare the label is left exactly as it is rather
15
+ * than having one inserted. Which labels an image carries is the author's decision; keeping one
16
+ * they already declared truthful is not.
17
+ *
18
+ * The existing quoting style is preserved, so the diff is the version and nothing else.
19
+ */
20
+ export function stampVersionLabel(content, version) {
21
+ const lines = content.split('\n');
22
+ let changed = false;
23
+ // A LABEL's key=value pairs can spill over several lines via trailing backslashes, so the
24
+ // instruction a line belongs to is tracked rather than assumed from the line itself. Anything
25
+ // outside a LABEL is left alone - the same key in an ARG, an ENV or a comment is not a label.
26
+ let continuing = false;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i];
29
+ const inLabel = continuing || /^\s*LABEL\b/i.test(line);
30
+ continuing = inLabel && /\\\s*$/.test(line);
31
+ if (!inLabel)
32
+ continue;
33
+ lines[i] = line.replace(LABEL_VALUE, (whole, key, value) => {
34
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : '';
35
+ const next = `${key}${quote}${version}${quote}`;
36
+ if (next !== whole)
37
+ changed = true;
38
+ return next;
39
+ });
40
+ }
41
+ return changed ? lines.join('\n') : undefined;
42
+ }
43
+ /**
44
+ * Rewrites the string assigned to a `name` constant (default `version`) in a source file to
45
+ * `version`, returning the new content - or `undefined` when there was nothing to change, so a
46
+ * caller can skip writing the file at all. Both an assignment and an object property are matched:
47
+ *
48
+ * ```ts
49
+ * export const version = '1'; -> export const version = '6.0.10';
50
+ * { name: 'app', version: '1' } -> { name: 'app', version: '6.0.10' }
51
+ * ```
52
+ *
53
+ * Unlike the Dockerfile label there is no standard naming a file as holding the version, so this
54
+ * only ever runs against files `.rmanrc "version.stamp"` explicitly lists - which is also what
55
+ * keeps a match this broad safe.
56
+ *
57
+ * Stamping the *source* rather than the build output is the point: a build-time rewrite leaves the
58
+ * checked-in file claiming some placeholder, so anything running from source (tests, ts-node, the
59
+ * dev loop) reports that placeholder, git never records the released version, and the rewrite has
60
+ * to be redone on every build.
61
+ */
62
+ export function stampVersionConstant(content, version, name = 'version') {
63
+ let changed = false;
64
+ const pattern = new RegExp(`(\\b${escapeRegExp(name)}\\s*[:=]\\s*)(['"\`])([^'"\`\\n]*)\\2`, 'g');
65
+ const result = content.replace(pattern, (whole, prefix, quote) => {
66
+ const next = `${prefix}${quote}${version}${quote}`;
67
+ if (next !== whole)
68
+ changed = true;
69
+ return next;
70
+ });
71
+ return changed ? result : undefined;
72
+ }
73
+ const LABEL_VALUE = new RegExp(`(${OCI_VERSION_LABEL.replace(/\./g, '\\.')}\\s*=\\s*)("[^"\\n]*"|'[^'\\n]*'|[^\\s\\\\]+)`, 'g');
74
+ function escapeRegExp(value) {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ }