@sous-io/sous 0.2.25 → 0.2.27

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.
@@ -6,8 +6,10 @@
6
6
  * and this module exists to keep them:
7
7
  *
8
8
  * 1. A published version is IMMUTABLE. An entry that is already in the index
9
- * keeps the hash it was published with; a recomputed hash that disagrees is
10
- * reported as an error, never quietly written over.
9
+ * and carries a tag keeps the hash and the dependencies it was published
10
+ * with; a recomputed hash that disagrees, or dependencies that no longer
11
+ * agree with what the version's manifest declares, are reported as errors,
12
+ * never quietly written over.
11
13
  * 2. A version is published when a tag carries it, and every version the index
12
14
  * records has to have one. The single exception is the version a recipe's
13
15
  * manifest declares right now: a release regenerates the index, commits it
@@ -16,12 +18,14 @@
16
18
  * OLDER version without a tag is an error, because that is a published
17
19
  * version nothing can fetch.
18
20
  * 3. The tags are the backstop. A version that has a tag but is missing from the
19
- * index is rebuilt from the tagged tree, so deleting the index file and
20
- * regenerating it restores the same catalog.
21
+ * index is rebuilt from the tagged tree, dependencies included, so deleting
22
+ * the index file and regenerating it restores the same catalog.
21
23
  *
22
24
  * Each version entry also records what it was released against, so a consumer
23
25
  * installs the versions a recipe was published with rather than re-resolving
24
- * its ranges long afterwards.
26
+ * its ranges long afterwards. Those dependencies are resolved exactly once: when
27
+ * the version is first recorded, against the repository as it stands then (or,
28
+ * for a version rebuilt from its tag, as it stood at that tag), and never again.
25
29
  */
26
30
 
27
31
  import fs from "node:fs";
@@ -51,6 +55,7 @@ import {
51
55
  } from "./tags.js";
52
56
  import {
53
57
  describeError,
58
+ validateRepo,
54
59
  type RepoValidation,
55
60
  type ValidatedRecipe,
56
61
  type ValidationProblem,
@@ -160,20 +165,7 @@ export async function buildIndex(
160
165
  // and every version each of them publishes. Both are needed to turn a
161
166
  // dependency's declared range into the exact version a consumer installs.
162
167
  const publishing = options.publishing ?? {};
163
- const siblingVersions = new Map<string, string>();
164
- const publishedVersions = new Map<string, string[]>();
165
- for (const recipe of validation.recipes) {
166
- const settled = publishing[recipe.key] ?? recipe.manifest.version;
167
- siblingVersions.set(recipe.key, settled);
168
- const known = new Set(
169
- (tagsByKey.get(recipe.key) ?? []).map((tag) => tag.version)
170
- );
171
- for (const version of Object.keys(existing?.recipes[recipe.key]?.versions ?? {})) {
172
- known.add(version);
173
- }
174
- known.add(settled);
175
- publishedVersions.set(recipe.key, [...known]);
176
- }
168
+ const current = siblingState(validation, tagsByKey, existing, publishing);
177
169
 
178
170
  for (const recipe of validation.recipes) {
179
171
  if (!Object.hasOwn(namespaces, recipe.manifest.namespace)) continue;
@@ -185,14 +177,18 @@ export async function buildIndex(
185
177
  const manifestName = path.basename(recipe.manifestPath);
186
178
 
187
179
  // Rule 3: every tagged version belongs in the index, including ones the
188
- // committed index has lost.
180
+ // committed index has lost. Its dependencies are resolved against the
181
+ // repository as it stood at the tag, which is what it was released against.
189
182
  for (const tag of recipeTags) {
190
183
  if (Object.hasOwn(versions, tag.version)) continue;
184
+ const rebuilt = await rebuildTaggedVersion(rootDir, tag, recipe, tagsByKey, existing, run);
185
+ problems.push(...rebuilt.problems);
191
186
  versions[tag.version] = {
192
- hash: await hashTaggedRecipe(rootDir, tag.tag, recipe.path, run),
187
+ hash: rebuilt.hash,
193
188
  tag: tag.tag,
194
189
  prerelease: semver.prerelease(tag.version) !== null,
195
190
  ...(await releasedAtOf(rootDir, tag.tag, run)),
191
+ ...(rebuilt.dependencies === undefined ? {} : { dependencies: rebuilt.dependencies }),
196
192
  };
197
193
  }
198
194
 
@@ -201,12 +197,7 @@ export async function buildIndex(
201
197
  const hasTag = recipeTags.some((entry) => entry.tag === tagName);
202
198
  const workingHash = await hashDirectory(recipe.dir);
203
199
  const where = relativeTo(rootDir, recipe.manifestPath);
204
- const dependencies = resolveIndexDependencies(
205
- recipe,
206
- validation,
207
- siblingVersions,
208
- publishedVersions
209
- );
200
+ const declared = declaredDependencies(recipe, validation);
210
201
 
211
202
  // A version the index publishes must have a tag, with one exception: the
212
203
  // version the manifest declares right now, which is the one a release is in
@@ -242,21 +233,24 @@ export async function buildIndex(
242
233
  `now carries. A published version never changes, so either the tag was moved ` +
243
234
  `or the index was edited; restore whichever is wrong before releasing again.`,
244
235
  });
245
- } else {
246
- // A release records a version before its tag exists, dating it from the
247
- // run; the tag that follows carries a date of its own. Keeping the date
248
- // the index already published is what stops the very next regeneration
249
- // from calling a perfectly current index stale.
250
- versions[version] = {
251
- hash: taggedHash,
252
- tag: tagName,
253
- prerelease: semver.prerelease(version) !== null,
254
- ...(published?.releasedAt === undefined
255
- ? await releasedAtOf(rootDir, tagName, run)
256
- : { releasedAt: published.releasedAt }),
257
- ...(dependencies === undefined ? {} : { dependencies }),
258
- };
236
+ } else if (published !== undefined) {
237
+ // Already published: the entry is carried forward as it stands, its
238
+ // dependencies and any field a later sous wrote included. Only a missing
239
+ // release date is filled in, from the tag.
240
+ if (published.releasedAt === undefined) {
241
+ versions[version] = { ...published, ...(await releasedAtOf(rootDir, tagName, run)) };
242
+ }
243
+ // The check needs the manifest that was published, which the working one
244
+ // is only while the folder still matches the tag. An entry with no
245
+ // dependencies at all was published before sous recorded them; a
246
+ // consumer resolves that version's ranges, and it stays that way.
247
+ if (taggedHash === workingHash && published.dependencies !== undefined) {
248
+ problems.push(
249
+ ...checkRecordedDependencies(key, version, published.dependencies, declared)
250
+ );
251
+ }
259
252
  }
253
+ // A tagged version the index did not record was rebuilt from its tag above.
260
254
 
261
255
  if (taggedHash !== workingHash) {
262
256
  // A recipe this run is not publishing is allowed to have unpublished
@@ -284,6 +278,7 @@ export async function buildIndex(
284
278
  // follows on the very commit that carries this index.
285
279
  const published = existingVersions[version];
286
280
  if (options.publishing?.[key] === version || published !== undefined) {
281
+ const dependencies = resolveIndexDependencies(declared, current);
287
282
  versions[version] = {
288
283
  hash: workingHash,
289
284
  tag: tagName,
@@ -420,50 +415,76 @@ export function describeIndexDrift(
420
415
  // --- Internals ----------------------------------------------------------------------------------
421
416
 
422
417
  /**
423
- * Turns one recipe's declared dependencies into the exact versions its index
424
- * entry publishes.
425
- *
426
- * This is the whole point of recording dependencies in the index: a consumer
427
- * installing version 1.4.0 of a recipe installs what 1.4.0 was released
428
- * against, not whatever its ranges happen to reach months later.
429
- *
430
- * - A SIBLING with no range means "the version released alongside me", which
431
- * is the version this run settles that sibling on.
432
- * - A SIBLING with a range resolves against the versions this repository
433
- * publishes, including the ones this run is adding.
434
- * - A CROSS-REPOSITORY dependency carries the identity of the repository it
435
- * lives in, which is what a consumer needs in order to add that repository
436
- * and find the recipe. Its exact version belongs to that repository's own
437
- * index, so the range the manifest declared is recorded instead.
418
+ * One dependency a recipe's manifest declares, keyed `namespace/recipe` in
419
+ * `DeclaredDependencies`. A whole-namespace declaration is expanded into one
420
+ * entry per recipe the namespace holds.
421
+ */
422
+ type DeclaredDependency =
423
+ | {
424
+ /** A recipe in another repository, named by its location. */
425
+ kind: "remote";
426
+ /** The canonical identity of the repository publishing it. */
427
+ repo: string;
428
+ /** The range the manifest declared, `*` when it declared none. */
429
+ range: string;
430
+ }
431
+ | {
432
+ /** A recipe in this same repository. */
433
+ kind: "sibling";
434
+ /** The range the manifest declared, when it declared one. */
435
+ range?: string;
436
+ /**
437
+ * True when the manifest names this recipe itself, rather than only the
438
+ * namespace holding it. A recipe a namespace gained after a version was
439
+ * published is rightly missing from that version's entry; one the
440
+ * manifest names is not.
441
+ */
442
+ named: boolean;
443
+ };
444
+
445
+ /** Everything one recipe's manifest declares, under `depends` and `subscribes`. */
446
+ type DeclaredDependencies = {
447
+ /** Every dependency, keyed `namespace/recipe`; a later declaration wins over an earlier one. */
448
+ byKey: Map<string, DeclaredDependency>;
449
+ /** The namespaces of this repository the manifest declares whole. */
450
+ namespaces: Set<string>;
451
+ };
452
+
453
+ /** What each recipe of a repository is published at, as one resolution sees it. */
454
+ type SiblingState = {
455
+ /** The version each recipe is released at, which is what "released alongside me" means. */
456
+ settled: Map<string, string>;
457
+ /** Every version each recipe publishes, for resolving a declared range. */
458
+ published: Map<string, string[]>;
459
+ };
460
+
461
+ /**
462
+ * Reads the dependencies a recipe's manifest declares, expanding a
463
+ * whole-namespace declaration into the recipes that namespace holds.
438
464
  *
439
- * @param recipe - The recipe whose dependencies are being resolved.
465
+ * @param recipe - The recipe whose dependencies are being read.
440
466
  * @param validation - The validated repository, for expanding namespace refs.
441
- * @param siblingVersions - What each recipe here is published at after this run.
442
- * @param publishedVersions - Every version each recipe here publishes.
443
467
  */
444
- function resolveIndexDependencies(
468
+ function declaredDependencies(
445
469
  recipe: ValidatedRecipe,
446
- validation: RepoValidation,
447
- siblingVersions: Map<string, string>,
448
- publishedVersions: Map<string, string[]>
449
- ): Record<string, IndexDependency> | undefined {
470
+ validation: RepoValidation
471
+ ): DeclaredDependencies {
472
+ const byKey = new Map<string, DeclaredDependency>();
473
+ const namespaces = new Set<string>();
450
474
  const declared = [
451
475
  ...(recipe.manifest.depends ?? []),
452
476
  ...(recipe.manifest.subscribes ?? []),
453
477
  ];
454
- if (declared.length === 0) return undefined;
455
478
 
456
- const resolved: Record<string, IndexDependency> = {};
457
-
458
- /** Records one sibling, at the version this run settles it on. */
459
- const recordSibling = (key: string, range: string | undefined): void => {
460
- if (range === undefined) {
461
- const settled = siblingVersions.get(key);
462
- resolved[key] = settled === undefined ? { range: "*" } : { version: settled };
463
- return;
464
- }
465
- const best = semver.maxSatisfying(publishedVersions.get(key) ?? [], range);
466
- resolved[key] = best === null ? { range } : { version: best };
479
+ /** Records one sibling, remembering whether any declaration named it. */
480
+ const addSibling = (key: string, range: string | undefined, named: boolean): void => {
481
+ const before = byKey.get(key);
482
+ const namedBefore = before?.kind === "sibling" && before.named;
483
+ byKey.set(key, {
484
+ kind: "sibling",
485
+ ...(range === undefined ? {} : { range }),
486
+ named: named || namedBefore,
487
+ });
467
488
  };
468
489
 
469
490
  for (const written of declared) {
@@ -476,29 +497,299 @@ function resolveIndexDependencies(
476
497
  }
477
498
 
478
499
  if (parsed.kind === "remote") {
479
- resolved[`${parsed.namespace}/${parsed.recipe}`] = {
500
+ byKey.set(`${parsed.namespace}/${parsed.recipe}`, {
501
+ kind: "remote",
480
502
  repo: parsed.canonicalRepo!,
481
503
  range: parsed.range ?? "*",
482
- };
504
+ });
483
505
  continue;
484
506
  }
485
507
 
486
508
  if (parsed.recipe !== undefined) {
487
- recordSibling(`${parsed.namespace}/${parsed.recipe}`, parsed.range);
509
+ addSibling(`${parsed.namespace}/${parsed.recipe}`, parsed.range, true);
488
510
  continue;
489
511
  }
490
512
 
491
513
  // A whole-namespace dependency means every recipe in that namespace.
514
+ namespaces.add(parsed.namespace);
492
515
  for (const entry of validation.recipes) {
493
516
  if (entry.manifest.namespace !== parsed.namespace) continue;
494
517
  if (entry.key === recipe.key) continue;
495
- recordSibling(entry.key, undefined);
518
+ addSibling(entry.key, undefined, false);
519
+ }
520
+ }
521
+
522
+ return { byKey, namespaces };
523
+ }
524
+
525
+ /**
526
+ * Works out what each recipe of a repository is published at, for resolving
527
+ * the dependencies of a version being recorded.
528
+ *
529
+ * Two moments call this. A run recording a NEW version resolves against the
530
+ * repository as it stands, counting the versions the run itself is publishing.
531
+ * A run rebuilding a version from its TAG resolves against the repository as
532
+ * it stood at that tag (no `publishing` given): a sibling is settled on the
533
+ * version its manifest declared there, and only versions up to that one count
534
+ * as published.
535
+ *
536
+ * @param validation - The repository, as it stands or as it stood at a tag.
537
+ * @param tagsByKey - Every release tag, grouped by recipe.
538
+ * @param existing - The committed index, when there is one.
539
+ * @param publishing - The versions this run publishes; omitted for a tag.
540
+ */
541
+ function siblingState(
542
+ validation: RepoValidation,
543
+ tagsByKey: Map<string, RecipeTag[]>,
544
+ existing: IndexFile | undefined,
545
+ publishing?: Record<string, string>
546
+ ): SiblingState {
547
+ const settled = new Map<string, string>();
548
+ const published = new Map<string, string[]>();
549
+
550
+ for (const recipe of validation.recipes) {
551
+ const version = publishing?.[recipe.key] ?? recipe.manifest.version;
552
+ settled.set(recipe.key, version);
553
+
554
+ const known = new Set((tagsByKey.get(recipe.key) ?? []).map((tag) => tag.version));
555
+ for (const other of Object.keys(existing?.recipes[recipe.key]?.versions ?? {})) {
556
+ known.add(other);
496
557
  }
558
+ known.add(version);
559
+
560
+ const all = [...known];
561
+ published.set(
562
+ recipe.key,
563
+ publishing === undefined ? all.filter((other) => semver.lte(other, version)) : all
564
+ );
565
+ }
566
+
567
+ return { settled, published };
568
+ }
569
+
570
+ /**
571
+ * Turns one recipe's declared dependencies into the exact versions its index
572
+ * entry publishes.
573
+ *
574
+ * This is the whole point of recording dependencies in the index: a consumer
575
+ * installing version 1.4.0 of a recipe installs what 1.4.0 was released
576
+ * against, not whatever its ranges happen to reach months later. That is also
577
+ * why this runs only when a version is first recorded; an entry the index
578
+ * already publishes is carried forward and never resolved again.
579
+ *
580
+ * - A SIBLING with no range means "the version released alongside me", which
581
+ * is the version that sibling is settled on.
582
+ * - A SIBLING with a range resolves against the versions this repository
583
+ * publishes.
584
+ * - A CROSS-REPOSITORY dependency carries the identity of the repository it
585
+ * lives in, which is what a consumer needs in order to add that repository
586
+ * and find the recipe. Its exact version belongs to that repository's own
587
+ * index, so the range the manifest declared is recorded instead.
588
+ *
589
+ * @param declared - What the recipe's manifest declares.
590
+ * @param siblings - What each recipe here is published at, for this resolution.
591
+ */
592
+ function resolveIndexDependencies(
593
+ declared: DeclaredDependencies,
594
+ siblings: SiblingState
595
+ ): Record<string, IndexDependency> | undefined {
596
+ const resolved: Record<string, IndexDependency> = {};
597
+
598
+ for (const [key, dependency] of declared.byKey) {
599
+ if (dependency.kind === "remote") {
600
+ resolved[key] = { repo: dependency.repo, range: dependency.range };
601
+ continue;
602
+ }
603
+ if (dependency.range === undefined) {
604
+ const settled = siblings.settled.get(key);
605
+ resolved[key] = settled === undefined ? { range: "*" } : { version: settled };
606
+ continue;
607
+ }
608
+ const best = semver.maxSatisfying(siblings.published.get(key) ?? [], dependency.range);
609
+ resolved[key] = best === null ? { range: dependency.range } : { version: best };
497
610
  }
498
611
 
499
612
  return Object.keys(resolved).length === 0 ? undefined : resolved;
500
613
  }
501
614
 
615
+ /**
616
+ * Checks the dependencies the index records for an already published version
617
+ * against what that version's manifest declares.
618
+ *
619
+ * The recorded list is frozen, so it is never compared with a fresh
620
+ * resolution: a sibling released since, or a recipe a declared namespace has
621
+ * gained since, is exactly what freezing keeps out of it. What must still hold
622
+ * is that the list honours the manifest: every recipe the manifest names is
623
+ * there, nothing is there that the manifest does not declare, a sibling sits
624
+ * inside its declared range, and a dependency in another repository carries the
625
+ * repository and range the manifest wrote. A disagreement means the index was
626
+ * edited, or this sous reads the manifest differently from the one that
627
+ * published the version.
628
+ *
629
+ * @param key - The recipe's key, `namespace/recipe`.
630
+ * @param version - The published version.
631
+ * @param recorded - The dependencies the index records for it.
632
+ * @param declared - What the version's manifest declares.
633
+ */
634
+ function checkRecordedDependencies(
635
+ key: string,
636
+ version: string,
637
+ recorded: Record<string, IndexDependency> | undefined,
638
+ declared: DeclaredDependencies
639
+ ): ValidationProblem[] {
640
+ const differences: string[] = [];
641
+ const entries = recorded ?? {};
642
+
643
+ for (const [name, dependency] of declared.byKey) {
644
+ const entry = entries[name];
645
+ if (entry === undefined) {
646
+ if (dependency.kind === "remote" || dependency.named) {
647
+ differences.push(
648
+ `'${name}': the manifest declares ${describeDeclared(dependency)}, and the index ` +
649
+ `records nothing for it`
650
+ );
651
+ }
652
+ continue;
653
+ }
654
+ if (!recordedSatisfies(entry, dependency)) {
655
+ differences.push(
656
+ `'${name}': the index records ${describeRecorded(entry)}, and the manifest declares ` +
657
+ `${describeDeclared(dependency)}`
658
+ );
659
+ }
660
+ }
661
+
662
+ for (const [name, entry] of Object.entries(entries)) {
663
+ if (declared.byKey.has(name)) continue;
664
+ // A recipe the declared namespace held when the version was published, and
665
+ // no longer does, is history rather than a disagreement.
666
+ const namespace = name.slice(0, name.indexOf("/"));
667
+ if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
668
+ differences.push(
669
+ `'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
670
+ `declare it`
671
+ );
672
+ }
673
+
674
+ if (differences.length === 0) return [];
675
+ return [
676
+ {
677
+ level: "error",
678
+ where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}'].dependencies`,
679
+ message:
680
+ `version ${version} of '${key}' is already published, and the dependencies the index ` +
681
+ `records for it disagree with what its manifest declares:\n` +
682
+ differences.map((line) => ` - ${line}\n`).join("") +
683
+ ` A published version never changes, so a release carries its dependencies forward ` +
684
+ `exactly as they were published. If the index was edited, restore the entry. To ` +
685
+ `publish different dependencies, bump the version in this manifest, or with ` +
686
+ `'sous repo release --bump patch'.`,
687
+ },
688
+ ];
689
+ }
690
+
691
+ /** True when a recorded dependency honours what the manifest declares for it. */
692
+ function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependency): boolean {
693
+ if (dependency.kind === "remote") {
694
+ return (
695
+ entry.repo === dependency.repo &&
696
+ entry.range === dependency.range &&
697
+ entry.version === undefined
698
+ );
699
+ }
700
+ if (entry.repo !== undefined) return false;
701
+ if (dependency.range === undefined) return true;
702
+ if (entry.version !== undefined) return semver.satisfies(entry.version, dependency.range);
703
+ return entry.range === dependency.range;
704
+ }
705
+
706
+ /** Describes a recorded dependency the way an author would say it. */
707
+ function describeRecorded(entry: IndexDependency): string {
708
+ const what =
709
+ entry.version !== undefined ? `version ${entry.version}` : `the range '${entry.range}'`;
710
+ return entry.repo === undefined ? what : `${what} from ${entry.repo}`;
711
+ }
712
+
713
+ /** Describes a declared dependency the way an author would say it. */
714
+ function describeDeclared(dependency: DeclaredDependency): string {
715
+ if (dependency.kind === "remote") {
716
+ return `the range '${dependency.range}' from ${dependency.repo}`;
717
+ }
718
+ return dependency.range === undefined ? "it with no range" : `the range '${dependency.range}'`;
719
+ }
720
+
721
+ /** What rebuilding one tagged version from its tag produced. */
722
+ type RebuiltVersion = {
723
+ /** The content hash of the recipe folder at the tag. */
724
+ hash: string;
725
+ /** The dependencies it was released against, when they could be worked out. */
726
+ dependencies?: Record<string, IndexDependency>;
727
+ /** A warning when they could not. */
728
+ problems: ValidationProblem[];
729
+ };
730
+
731
+ /**
732
+ * Rebuilds a tagged version the index does not record, from the tree its tag
733
+ * carries: the content hash of the recipe folder, and the dependencies its
734
+ * manifest declared there, resolved against the repository as it stood at the
735
+ * tag. That is as close as sous can get to what the release that cut the tag
736
+ * recorded; it is resolved once, then frozen like any other entry.
737
+ *
738
+ * @param rootDir - The repository's root directory.
739
+ * @param tag - The tag to rebuild from.
740
+ * @param recipe - The recipe as it stands now, for its folder and its key.
741
+ * @param tagsByKey - Every release tag, grouped by recipe.
742
+ * @param existing - The committed index, when there is one.
743
+ * @param run - The command runner git calls go through.
744
+ */
745
+ async function rebuildTaggedVersion(
746
+ rootDir: string,
747
+ tag: RecipeTag,
748
+ recipe: ValidatedRecipe,
749
+ tagsByKey: Map<string, RecipeTag[]>,
750
+ existing: IndexFile | undefined,
751
+ run: RunOptions["run"]
752
+ ): Promise<RebuiltVersion> {
753
+ return withTaggedTree(
754
+ rootDir,
755
+ tag.tag,
756
+ ".",
757
+ async (treeDir) => {
758
+ const hash = await hashDirectory(path.join(treeDir, ...recipe.path.split("/")));
759
+
760
+ let reason: string;
761
+ try {
762
+ const snapshot = validateRepo(treeDir);
763
+ const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
764
+ if (tagged !== undefined) {
765
+ const dependencies = resolveIndexDependencies(
766
+ declaredDependencies(tagged, snapshot),
767
+ siblingState(snapshot, tagsByKey, existing)
768
+ );
769
+ return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
770
+ }
771
+ reason = `The repository manifest at that tag does not list '${recipe.key}'.`;
772
+ } catch (error) {
773
+ reason = describeError(error);
774
+ }
775
+
776
+ return {
777
+ hash,
778
+ problems: [
779
+ {
780
+ level: "warning",
781
+ where: `${INDEX_FILENAME} recipes['${recipe.key}'].versions['${tag.version}']`,
782
+ message:
783
+ `version ${tag.version} was rebuilt from the tag '${tag.tag}' without its ` +
784
+ `dependencies, because sous could not read the recipe at that tag.\n ${reason}`,
785
+ },
786
+ ],
787
+ };
788
+ },
789
+ { run }
790
+ );
791
+ }
792
+
502
793
  /** Groups release tags by the recipe key they belong to. */
503
794
  function groupTagsByKey(tags: ReadonlyArray<RecipeTag>): Map<string, RecipeTag[]> {
504
795
  const grouped = new Map<string, RecipeTag[]>();