@theholocron/astromech 5.0.0-alpha.8 → 5.0.0-alpha.81

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/README.md CHANGED
@@ -33,7 +33,7 @@ const report = astromech.run("verification.unitTests", { passthrough: ["--watch"
33
33
  `holocron run verification.unitTests` runs your tests — you don't tell it
34
34
  turbo vs pnpm vs npm, or which runner:
35
35
 
36
- ```
36
+ ```text
37
37
  1. turbo.json defines the task → turbo run <task>
38
38
  2. package.json has a <task> script → <detected pm> run <task>
39
39
  (a "holocron run …" thin caller is skipped — no recursion)
@@ -53,28 +53,28 @@ what it actually runs, and why. Tool names never appear in
53
53
  `holocron.config.ts` — they're an implementation detail this table
54
54
  documents, not a naming convention repos need to follow.
55
55
 
56
- | Task | Runs | Notes |
57
- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
58
- | `verification.unitTests` | vitest | carries the `--coverage` org default |
59
- | `verification.typeSafety` | tsc | `tsc --noEmit` |
60
- | `verification.performance` | Lighthouse CI | only runs with a `lighthouse.config.*` present |
61
- | `sourceQuality.staticAnalysis` | eslint, actionlint, git-merge-conflict-markers | a `linterGroup` — see below |
62
- | `sourceQuality.formatting` | prettier, editorconfig-checker, markdownlint-cli2 | a `linterGroup` |
63
- | `sourceQuality.structuredDataValidation` | yamllint | |
64
- | `sourceQuality.deadCodeAnalysis` | knip | |
65
- | `security.secretDetection` | gitleaks | |
66
- | `security.codeScanning` | CodeQL | no local equivalent — CI only |
67
- | `security.dependencyReview` | GitHub's native Dependabot alerts/graph | a capability-model method (`Source.enableVulnerabilityAlerts()`), not a task |
68
- | `delivery.build` | tsdown / vite / rollup / tsc, detected from the repo's own config file | |
69
- | `delivery.publish` | semantic-release | no local equivalent — CI only; carries `preview` (npm dist-tags) |
70
- | `delivery.deploy` | Cloudflare Pages / Vercel | no local equivalent — CI only; carries `preview` |
71
- | `delivery.bundleSize` | bundle-size upload to Codecov | no local equivalent — CI only |
72
- | `platform.repoSync` | `holocron sync` | keeps generated files current |
73
- | `platform.commitStandards` | commitlint | no local equivalent — enforced by the `commit-msg` hook locally, over the PR's commit range in CI |
74
- | `platform.repoValidation` | `scripts/validate-adrs.mjs`, `scripts/validate-registry.mjs`, `scripts/validate-docs-presence.mjs` | a job-bearing task — spec/ADR frontmatter, registry-doc completeness, and docs-presence, not linters |
75
- | `knowledge.wiki` | Fern | publishes `docs/wiki/*.md` — not a sync, a publish; carries `preview` |
76
- | `knowledge.docs` | Astro build | the docs site itself; carries `preview` (defaults on) |
77
- | `knowledge.components` | Storybook build | a browsable component catalog — "Storybook" is the tool, not the intent; carries `preview` (defaults on) |
56
+ | Task | Runs | Notes |
57
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
58
+ | `verification.unitTests` | vitest | carries the `--coverage` org default |
59
+ | `verification.typeSafety` | tsc | `tsc --noEmit` |
60
+ | `verification.performance` | Lighthouse CI | only runs with a `lighthouse.config.*` present |
61
+ | `sourceQuality.staticAnalysis` | eslint, actionlint, git-merge-conflict-markers | a `linterGroup` — see below |
62
+ | `sourceQuality.formatting` | prettier, editorconfig-checker, markdownlint-cli2 | a `linterGroup` |
63
+ | `sourceQuality.structuredDataValidation` | yamllint | |
64
+ | `sourceQuality.deadCodeAnalysis` | knip | |
65
+ | `security.secretDetection` | gitleaks | |
66
+ | `security.codeScanning` | CodeQL | no local equivalent — CI only |
67
+ | `security.dependencyReview` | GitHub's native Dependabot alerts/graph | a capability-model method (`Source.enableVulnerabilityAlerts()`), not a task |
68
+ | `delivery.build` | tsdown / vite / rollup / tsc, detected from the repo's own config file, or `package.json`'s own `dependencies`/`devDependencies` if that config file isn't there | |
69
+ | `delivery.publish` | semantic-release | no local equivalent — CI only; carries `preview` (npm dist-tags) |
70
+ | `delivery.deploy` | Cloudflare Pages / Vercel | no local equivalent — CI only; carries `preview` |
71
+ | `delivery.bundleSize` | bundle-size upload to Codecov | no local equivalent — CI only |
72
+ | `platform.repoSync` | `holocron sync` | keeps generated files current |
73
+ | `platform.commitStandards` | commitlint | no local equivalent — enforced by the `commit-msg` hook locally, over the PR's commit range in CI |
74
+ | `platform.repoValidation` | `scripts/validate-adrs.mjs`, `scripts/validate-registry.mjs`, `scripts/validate-docs-presence.mjs` | a job-bearing task — spec/ADR frontmatter, registry-doc completeness, and docs-presence, not linters |
75
+ | `knowledge.wiki` | Fern | publishes `docs/wiki/*.md` — not a sync, a publish; carries `preview` |
76
+ | `knowledge.docs` | Astro build | the docs site itself; carries `preview` (defaults on) |
77
+ | `knowledge.components` | Storybook build | a browsable component catalog — "Storybook" is the tool, not the intent; carries `preview` (defaults on) |
78
78
 
79
79
  **`preview` is a cross-cutting feature, not a namespace.** npm has staging
80
80
  dist-tags, Cloudflare/Vercel do per-PR preview deploys, Fern previews
@@ -83,10 +83,12 @@ publish/deploy-shaped task can carry, toggled via that task's `with:`.
83
83
 
84
84
  **`linterGroup` tasks** (`sourceQuality.staticAnalysis`,
85
85
  `sourceQuality.formatting`) bundle more than one tool under a single
86
- required check. Each tool is still gated by its own detection rule (e.g.
87
- `eslint` only runs if `eslint.config.*` is present) — the
86
+ required check. Each tool is still gated by its own detection rule — e.g.
87
+ `eslint` runs when either a local `eslint.config.*` is present, or the
88
+ package has no local file but `@theholocron/eslint-config` resolves
89
+ (the same shared-config splice the `tool`/`detect` runner path uses) — the
88
90
  `tool name → detection rule → local binary` mapping lives in one place,
89
- `src/linters.ts`. A repo's choice of which of these run is just which
91
+ `src/linters.ts`. A repo's choice of which of these run is based on which
90
92
  tasks it includes in `tasks: [...]`, same as any other task.
91
93
 
92
94
  **No super-linter.** Each task above runs its own tool directly, through
package/dist/index.d.mts CHANGED
@@ -221,6 +221,16 @@ declare function normalizeWorkflowWith(raw: Record<string, unknown>): Record<str
221
221
  */
222
222
  declare function deriveDeployPaths(raw: Record<string, unknown>): string[];
223
223
  //#endregion
224
+ //#region src/turbo.d.ts
225
+ interface EnsureRootWorkspaceMemberResult {
226
+ content: string;
227
+ changed: boolean;
228
+ }
229
+ interface EnsureTurboDependencyResult {
230
+ content: string;
231
+ changed: boolean;
232
+ }
233
+ //#endregion
224
234
  //#region src/astromech.d.ts
225
235
  interface AstromechOptions {
226
236
  /** Repo root. */
@@ -315,6 +325,31 @@ interface Astromech {
315
325
  * config (nothing to write). See {@link TurboTaskConfig} in `registry.ts`.
316
326
  */
317
327
  turboConfig(): string | null;
328
+ /**
329
+ * Fixes #692: whenever {@link turboConfig} isn't `null`, this repo's root
330
+ * package needs to actually be a workspace member for the tasks it fans
331
+ * out — otherwise a root that's genuinely a directly-buildable package
332
+ * (not just an orchestrator) silently vanishes from `turbo run <task>`.
333
+ * Pass `pnpm-workspace.yaml`'s current content (or `""` if none exists —
334
+ * a no-op either way, since no `packages:` key means single-package mode
335
+ * already) and root `package.json`'s own `scripts` object — values and
336
+ * all, not just keys, so a task-name-matching script that's already the
337
+ * generated `holocron run <task> --` wrapper (predating this call, not
338
+ * written by it) is correctly excluded rather than treated as proof root
339
+ * is directly buildable (#846). `changed: false` means don't write
340
+ * anything — the file is already correct.
341
+ */
342
+ ensureRootWorkspaceMember(workspaceYaml: string, rootScripts: Readonly<Record<string, string>>): EnsureRootWorkspaceMemberResult;
343
+ /**
344
+ * `turboConfig()` writes a `turbo.json` but never touches `package.json`
345
+ * — a repo with no local `turbo` devDependency falls back to whatever
346
+ * (if anything) is globally on `PATH`, per `run.ts`'s `resolveBin()`.
347
+ * Pass root `package.json`'s raw content and `pnpm-workspace.yaml`'s
348
+ * current content (checked for an existing `turbo` catalog entry, which
349
+ * takes precedence over the hardcoded fallback version). `changed: false`
350
+ * means don't write anything — `devDependencies.turbo` already exists.
351
+ */
352
+ ensureTurboDependency(packageJson: string, workspaceYaml: string): EnsureTurboDependencyResult;
318
353
  }
319
354
  declare function createAstromech(options: AstromechOptions): Astromech;
320
355
  //#endregion
@@ -417,11 +452,20 @@ declare const LINTER_NAMES: ReadonlySet<string>;
417
452
  * Resolve the linter set for a repo. An `explicit` list (from `config.tasks`)
418
453
  * picks the candidate set; otherwise the candidates are every `always` linter
419
454
  * plus every `detect`-gated linter whose filenames are present at the repo
420
- * root. Either way, a `detect`-gated linter (eslint, markdownlint) only makes
421
- * the final set when its config file is actually present — a repo can't run
422
- * eslint without an `eslint.config.*`, and super-linter FATALs if you ask it
423
- * to (theholocron/holocron#654). `always` linters are unconditional. Result
424
- * is ordered by {@link LINTERS} declaration order.
455
+ * root, OR whose mapped `@theholocron/*-config` package resolves per
456
+ * {@link sharedConfigAvailable} — a repo can't run eslint with genuinely no
457
+ * config anywhere (super-linter FATALs if you ask it to,
458
+ * theholocron/holocron#654), but a *shared* one it has installed counts
459
+ * (holocron#795: proving `clients`' eslint.config.ts files reduce to zero
460
+ * remaining content is only half the story — the file itself was still load-
461
+ * bearing here as the sole "eslint is even configured" signal until this
462
+ * fallback existed). `always` linters are unconditional. Result is ordered
463
+ * by {@link LINTERS} declaration order.
464
+ *
465
+ * `sharedConfigAvailable` is caller-computed (`run.ts`'s `runLinterGroup`
466
+ * already resolves each linter's `resolveToolConfig()` per member to build
467
+ * its actual command — reusing that instead of duplicating the package-
468
+ * resolution logic here keeps this function itself pure and file-I/O-free).
425
469
  *
426
470
  * @throws when an `explicit` name is not in the registry — a typo is a
427
471
  * config bug, not a linter to silently skip.
@@ -429,6 +473,7 @@ declare const LINTER_NAMES: ReadonlySet<string>;
429
473
  declare function resolveLinters(opts: {
430
474
  explicit?: string[];
431
475
  rootFiles: string[];
476
+ sharedConfigAvailable?: ReadonlySet<string>;
432
477
  }): Array<{
433
478
  name: string;
434
479
  def: LinterDef;
@@ -560,8 +605,18 @@ declare function requiredChecks(config: TasksConfig): string[];
560
605
  * each tool's real `--config`/`--extends` loader, not just the file's shape).
561
606
  * A repo doesn't need this package installed at all — every lookup degrades to
562
607
  * `[]` (no flag added, the tool falls back to its own auto-discovery of a
563
- * local file, unchanged from today) rather than throwing, so this is additive
564
- * and safe to run unconditionally.
608
+ * local file, unchanged from today) rather than throwing.
609
+ *
610
+ * **Local file wins.** A `<tool>.config.*` sitting in `cwd` (whether it's
611
+ * pure boilerplate or has real per-package customization on top of the
612
+ * shared bundle, e.g. `mergeConfig(base, { test: { coverage: { exclude }
613
+ * } })`) always skips the splice, even when the shared package is also
614
+ * installed — an explicit `--config <shared path>` flag beats a tool's own
615
+ * auto-discovery every time, so splicing it unconditionally would silently
616
+ * discard that customization the file is still sitting right there on disk.
617
+ * The shared bundle is only ever the fallback for a package with *no* local
618
+ * file at all — that's still the "delete the file to go fully shared" path
619
+ * this was designed for (#676); it just isn't unconditional (#749).
565
620
  *
566
621
  * `semantic-release`, `devmoji`, `editorconfig-checker`, and `knip` are
567
622
  * deliberately absent — `semantic-release-config`'s `defineConfig()` needs
@@ -579,10 +634,12 @@ interface ResolverDeps {
579
634
  declare const RESOLVABLE_TOOLS: ReadonlySet<string>;
580
635
  /**
581
636
  * `<flag> <absolute path>` for `tool`, resolved against `cwd`'s
582
- * `node_modules` — or `[]` when the tool isn't mapped, the package isn't
583
- * installed, its `package.json` doesn't declare the export subpath this
584
- * needs, or the resolved file doesn't actually exist on disk (a stale
585
- * install, or a package version that predates the export existing).
637
+ * `node_modules` — or `[]` when the tool isn't mapped, `cwd` already has its
638
+ * own local config file for it (#749 — local always wins, customized or
639
+ * not), the shared package isn't installed, its `package.json` doesn't
640
+ * declare the export subpath this needs, or the resolved file doesn't
641
+ * actually exist on disk (a stale install, or a package version that
642
+ * predates the export existing).
586
643
  */
587
644
  declare function resolveToolConfig(tool: string, cwd: string, deps: ResolverDeps): string[];
588
645
  //#endregion
package/dist/index.mjs CHANGED
@@ -299,11 +299,20 @@ const LINTER_NAMES = new Set(Object.keys(LINTERS));
299
299
  * Resolve the linter set for a repo. An `explicit` list (from `config.tasks`)
300
300
  * picks the candidate set; otherwise the candidates are every `always` linter
301
301
  * plus every `detect`-gated linter whose filenames are present at the repo
302
- * root. Either way, a `detect`-gated linter (eslint, markdownlint) only makes
303
- * the final set when its config file is actually present — a repo can't run
304
- * eslint without an `eslint.config.*`, and super-linter FATALs if you ask it
305
- * to (theholocron/holocron#654). `always` linters are unconditional. Result
306
- * is ordered by {@link LINTERS} declaration order.
302
+ * root, OR whose mapped `@theholocron/*-config` package resolves per
303
+ * {@link sharedConfigAvailable} — a repo can't run eslint with genuinely no
304
+ * config anywhere (super-linter FATALs if you ask it to,
305
+ * theholocron/holocron#654), but a *shared* one it has installed counts
306
+ * (holocron#795: proving `clients`' eslint.config.ts files reduce to zero
307
+ * remaining content is only half the story — the file itself was still load-
308
+ * bearing here as the sole "eslint is even configured" signal until this
309
+ * fallback existed). `always` linters are unconditional. Result is ordered
310
+ * by {@link LINTERS} declaration order.
311
+ *
312
+ * `sharedConfigAvailable` is caller-computed (`run.ts`'s `runLinterGroup`
313
+ * already resolves each linter's `resolveToolConfig()` per member to build
314
+ * its actual command — reusing that instead of duplicating the package-
315
+ * resolution logic here keeps this function itself pure and file-I/O-free).
307
316
  *
308
317
  * @throws when an `explicit` name is not in the registry — a typo is a
309
318
  * config bug, not a linter to silently skip.
@@ -311,17 +320,18 @@ const LINTER_NAMES = new Set(Object.keys(LINTERS));
311
320
  function resolveLinters(opts) {
312
321
  const order = Object.keys(LINTERS);
313
322
  const present = new Set(opts.rootFiles);
314
- const configPresent = (def) => def.always === true || (def.detect ?? []).some((f) => present.has(f));
323
+ const shared = opts.sharedConfigAvailable ?? /* @__PURE__ */ new Set();
324
+ const configPresent = (name, def) => def.always === true || (def.detect ?? []).some((f) => present.has(f)) || shared.has(name);
315
325
  if (opts.explicit && opts.explicit.length > 0) {
316
326
  const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
317
327
  if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
318
328
  const wanted = new Set(opts.explicit);
319
- return order.filter((n) => wanted.has(n) && configPresent(LINTERS[n])).map((name) => ({
329
+ return order.filter((n) => wanted.has(n) && configPresent(n, LINTERS[n])).map((name) => ({
320
330
  name,
321
331
  def: LINTERS[name]
322
332
  }));
323
333
  }
324
- return order.filter((name) => configPresent(LINTERS[name])).map((name) => ({
334
+ return order.filter((name) => configPresent(name, LINTERS[name])).map((name) => ({
325
335
  name,
326
336
  def: LINTERS[name]
327
337
  }));
@@ -364,18 +374,61 @@ const TOOL_CONFIGS = {
364
374
  flag: ["--config"]
365
375
  }
366
376
  };
377
+ /**
378
+ * Every filename a tool's own auto-discovery would pick up from `cwd` —
379
+ * same extension set `registry.ts`'s `turbo.inputs`/`local.detect` entries
380
+ * already use for these tools, kept in sync by hand (no shared source of
381
+ * truth between the two today). Presence of any one of these means the
382
+ * package has its own local config — customized or not, `resolveToolConfig`
383
+ * can't tell the difference from the filename alone, so it defers to it
384
+ * either way (#749).
385
+ */
386
+ const LOCAL_CONFIG_FILES = {
387
+ eslint: [
388
+ "eslint.config.ts",
389
+ "eslint.config.js",
390
+ "eslint.config.mjs",
391
+ "eslint.config.cjs"
392
+ ],
393
+ prettier: [
394
+ "prettier.config.ts",
395
+ "prettier.config.js",
396
+ "prettier.config.mjs",
397
+ "prettier.config.cjs"
398
+ ],
399
+ vitest: [
400
+ "vitest.config.ts",
401
+ "vitest.config.js",
402
+ "vitest.config.mjs"
403
+ ],
404
+ tsdown: [
405
+ "tsdown.config.ts",
406
+ "tsdown.config.js",
407
+ "tsdown.config.mjs",
408
+ "tsdown.config.cjs"
409
+ ],
410
+ commitlint: [
411
+ "commitlint.config.ts",
412
+ "commitlint.config.js",
413
+ "commitlint.config.mjs",
414
+ "commitlint.config.cjs"
415
+ ]
416
+ };
367
417
  /** Every tool name the resolver knows how to point at a shared config. */
368
418
  const RESOLVABLE_TOOLS = new Set(Object.keys(TOOL_CONFIGS));
369
419
  /**
370
420
  * `<flag> <absolute path>` for `tool`, resolved against `cwd`'s
371
- * `node_modules` — or `[]` when the tool isn't mapped, the package isn't
372
- * installed, its `package.json` doesn't declare the export subpath this
373
- * needs, or the resolved file doesn't actually exist on disk (a stale
374
- * install, or a package version that predates the export existing).
421
+ * `node_modules` — or `[]` when the tool isn't mapped, `cwd` already has its
422
+ * own local config file for it (#749 — local always wins, customized or
423
+ * not), the shared package isn't installed, its `package.json` doesn't
424
+ * declare the export subpath this needs, or the resolved file doesn't
425
+ * actually exist on disk (a stale install, or a package version that
426
+ * predates the export existing).
375
427
  */
376
428
  function resolveToolConfig(tool, cwd, deps) {
377
429
  const spec = TOOL_CONFIGS[tool];
378
430
  if (!spec) return [];
431
+ if ((LOCAL_CONFIG_FILES[tool] ?? []).some((name) => deps.fileExists(joinPath(cwd, name)))) return [];
379
432
  const pkgDir = joinPath(cwd, "node_modules", ...spec.package.split("/"));
380
433
  const pkgJsonPath = joinPath(pkgDir, "package.json");
381
434
  if (!deps.fileExists(pkgJsonPath)) return [];
@@ -537,7 +590,7 @@ function runTask(input) {
537
590
  def.local.command,
538
591
  ...passthrough
539
592
  ]);
540
- const runner = resolveRunner(def.local, cwd, listDir);
593
+ const runner = resolveRunner(def.local, cwd, listDir, readFile);
541
594
  if (runner) {
542
595
  const flags = (def.flags?.[runner.tool] ?? []).filter((f) => !passthrough.includes(f));
543
596
  const configFlags = resolveToolConfig(runner.tool, cwd, {
@@ -600,7 +653,7 @@ function runTask(input) {
600
653
  * here, flag the rest, let CI enforce.
601
654
  */
602
655
  function runUnit(input, label, local, run, passthrough) {
603
- const { print, logger, listDir, lookPath, cwd } = input;
656
+ const { print, logger, listDir, lookPath, cwd, readFile } = input;
604
657
  const skip = (msg) => {
605
658
  print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
606
659
  logger[input.required ? "warn" : "debug"]({
@@ -613,7 +666,7 @@ function runUnit(input, label, local, run, passthrough) {
613
666
  };
614
667
  };
615
668
  if (local) {
616
- const runner = resolveRunner(local, cwd, listDir);
669
+ const runner = resolveRunner(local, cwd, listDir, readFile);
617
670
  if (!runner) return skip(`no ${label} runner for this repo`);
618
671
  const found = lookPath(cwd, runner.tool);
619
672
  if (!found) return skip(`${runner.tool} not installed locally for ${label} — enforced in CI`);
@@ -688,9 +741,14 @@ function runLinterGroup(input, names, passthrough) {
688
741
  } catch {
689
742
  rootFiles = [];
690
743
  }
744
+ const sharedConfigAvailable = new Set(names.filter((name) => resolveToolConfig(name, cwd, {
745
+ readFile,
746
+ fileExists
747
+ }).length > 0));
691
748
  const resolved = resolveLinters({
692
749
  explicit: names,
693
- rootFiles
750
+ rootFiles,
751
+ sharedConfigAvailable
694
752
  });
695
753
  const reports = [];
696
754
  let anyHasLocalBin = false;
@@ -749,19 +807,53 @@ const mkRunner = (tool, args = []) => ({
749
807
  });
750
808
  /**
751
809
  * Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
752
- * files. Only called for `tool` / `detect` runners — the caller handles
810
+ * files, falling back (#750) to each candidate's `tool` name against
811
+ * `package.json`'s own declared dependencies when neither matches by
812
+ * filename. Only called for `tool` / `detect` runners — the caller handles
753
813
  * `command` runners itself, so `local.detect` is present whenever
754
814
  * `local.tool` is not.
815
+ *
816
+ * The dependency fallback matters once a Bucket A migration (#680) deletes
817
+ * a build tool's own config file (`tsdown.config.ts`, `vite.config.ts`, …):
818
+ * that file was the *only* signal `detect[]` had to offer, so removing it
819
+ * used to make `delivery.build` report "no task for this repo" and silently
820
+ * skip the build, even though the tool is still very much in use — a
821
+ * package can't actually build with `tsdown` without it in
822
+ * `dependencies`/`devDependencies`, so that's a signal at least as reliable
823
+ * as the config file, and one deleting the file doesn't remove.
824
+ *
825
+ * **Per-candidate, not two full passes** (#750 follow-up — the first
826
+ * shipped version got this wrong): `delivery.build`'s own `detect[]` ends
827
+ * with `{ when: /^tsconfig\.json$/, tool: "tsc" }` as its last-resort
828
+ * fallback, and `tsconfig.json` exists in essentially every package
829
+ * regardless of build tool. A "check every candidate's filename, *then*
830
+ * check every candidate's dependency" two-pass design would let that
831
+ * always-present fallback file win over a real `tsdown`/`vite`/`rollup`
832
+ * dependency match, the moment the earlier candidates' own config files are
833
+ * gone — exactly backwards. Checking `(file match) || (dependency match)`
834
+ * together for each candidate before moving to the next preserves the
835
+ * intended precedence: a real signal for an *earlier* candidate, by either
836
+ * means, still wins over a later candidate's mere filename presence.
755
837
  */
756
- function resolveRunner(local, cwd, listDir) {
838
+ function resolveRunner(local, cwd, listDir, readFile) {
757
839
  if (local.tool) return mkRunner(local.tool, local.args);
758
840
  let files;
759
841
  try {
760
842
  files = listDir(cwd);
761
843
  } catch {
762
- return;
844
+ files = [];
845
+ }
846
+ const deps = packageJsonDependencies(cwd, readFile);
847
+ for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f)) || deps.has(candidate.tool)) return mkRunner(candidate.tool, candidate.args);
848
+ }
849
+ /** Every key from `package.json`'s `dependencies` + `devDependencies`, or an empty set on any read/parse failure. */
850
+ function packageJsonDependencies(cwd, readFile) {
851
+ try {
852
+ const pkg = JSON.parse(readFile(join(cwd, "package.json")));
853
+ return /* @__PURE__ */ new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
854
+ } catch {
855
+ return /* @__PURE__ */ new Set();
763
856
  }
764
- for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f))) return mkRunner(candidate.tool, candidate.args);
765
857
  }
766
858
  function resolveBin(cwd, tool, fileExists) {
767
859
  const local = join(cwd, "node_modules", ".bin", tool);
@@ -823,7 +915,7 @@ const WORKFLOW_TEMPLATES = {
823
915
  "platform.repoValidation": "name: Platform\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\npermissions:\n contents: read\n\njobs:\n repo-validation:\n name: Repo Validation\n uses: theholocron/.github/.github/workflows/platform.repoValidation.yml@main\n secrets: inherit\n",
824
916
  "knowledge.wiki": "name: Knowledge\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\nconcurrency:\n group: ${{ github.event_name == 'pull_request' && format('wiki-preview-{0}', github.event.pull_request.number) || 'wiki' }}\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n\npermissions:\n contents: read\n deployments: write\n\njobs:\n publish:\n name: Publish\n if: ${{ github.event_name != 'pull_request' }}\n uses: theholocron/.github/.github/workflows/knowledge.wiki.yml@main\n secrets: inherit\n\n preview:\n name: Preview\n if: ${{ github.event_name == 'pull_request' }}\n uses: theholocron/.github/.github/workflows/knowledge.wiki.yml@main\n with:\n preview: true\n preview-id: pr-${{ github.event.pull_request.number }}\n secrets: inherit\n",
825
917
  preview: "name: Preview\n\non: # yamllint disable-line rule:truthy\n pull_request:\n branches: [main]\n\nconcurrency:\n group: preview-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n deployments: write\n pull-requests: write\n\njobs:\n preview:\n name: Preview\n uses: theholocron/.github/.github/workflows/preview.yml@main\n secrets: inherit\n",
826
- review: "name: Review\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\nconcurrency:\n group: review-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n checks: write\n pull-requests: write\n\njobs:\n review:\n name: Lint Annotations\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n",
918
+ review: "name: Source Quality\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\nconcurrency:\n group: review-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n checks: write\n pull-requests: write\n\njobs:\n review:\n name: Annotations\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n",
827
919
  stale: "name: Stale\n\non: # yamllint disable-line rule:truthy\n schedule:\n - cron: \"30 1 * * *\"\n\npermissions:\n contents: write\n issues: write\n pull-requests: write\n\njobs:\n stale:\n uses: theholocron/.github/.github/workflows/stale.yml@main\n with:\n exempt-issue-labels: \"in-progress,wip\"\n exempt-all-issue-milestones: true\n exempt-all-issue-projects: true\n exempt-all-pr-projects: true\n secrets: inherit\n",
828
920
  greetings: "name: Platform\n\non: # yamllint disable-line rule:truthy\n pull_request:\n issues:\n\npermissions:\n issues: write\n pull-requests: write\n\njobs:\n greetings:\n name: Greetings\n uses: theholocron/.github/.github/workflows/greetings.yml@main\n secrets: inherit\n",
829
921
  dependencies: "name: Platform\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n dependencies:\n name: Dependencies\n uses: theholocron/.github/.github/workflows/dependencies.yml@main\n secrets: inherit\n",
@@ -1296,7 +1388,7 @@ function requiredChecks(config) {
1296
1388
  var auto_commit_default = "name: Auto Commit\ndescription: >\n Commit and push any file changes using stefanzweifel/git-auto-commit-action.\n If a branch name is provided and differs from the current branch, a new branch\n is created automatically.\n\ninputs:\n token:\n description: >\n GitHub token with push access to the target branch. Used to authenticate\n the git remote so the commit can be pushed.\n required: true\n branch:\n description: >\n Branch to commit to. Leave empty to commit to the current branch (useful\n in PR contexts). Provide an explicit name (e.g. chore/auto-sync) to push\n to a separate branch.\n required: false\n default: \"\"\n commit-message:\n description: Commit message.\n required: true\n commit-options:\n description: >\n Additional options passed to git commit (e.g. --no-verify).\n required: false\n default: \"\"\n commit-user-name:\n description: Git user.name for the commit.\n required: false\n default: \"github-actions[bot]\"\n commit-user-email:\n description: Git user.email for the commit.\n required: false\n default: \"41898282+github-actions[bot]@users.noreply.github.com\"\n commit-author:\n description: >\n Git author string in \"Name <email>\" format.\n required: false\n default: \"github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>\"\n\noutputs:\n changes-detected:\n description: \"'true' if any files were committed, 'false' otherwise.\"\n value: ${{ steps.commit.outputs.changes_detected }}\n\nruns:\n using: composite\n steps:\n - name: Configure git remote with token\n shell: bash\n run: |\n REMOTE_URL=$(git remote get-url origin)\n AUTHED_URL=$(echo \"$REMOTE_URL\" | sed \"s|https://|https://x-access-token:${TOKEN}@|\")\n git remote set-url origin \"$AUTHED_URL\"\n env:\n TOKEN: ${{ inputs.token }}\n\n - name: Reset branch to current HEAD\n if: ${{ inputs.branch != '' }}\n shell: bash\n run: git checkout -B \"$BRANCH\"\n env:\n BRANCH: ${{ inputs.branch }}\n\n - name: Build signed commit message\n id: msg\n shell: bash\n run: |\n TRAILER=\"Signed-off-by: ${COMMIT_USER_NAME} <${COMMIT_USER_EMAIL}>\"\n if echo \"$COMMIT_MESSAGE\" | grep -qF \"$TRAILER\"; then\n FINAL=\"$COMMIT_MESSAGE\"\n else\n FINAL=\"${COMMIT_MESSAGE}\"$'\\n\\n'\"${TRAILER}\"\n fi\n {\n echo \"value<<EOF_TRAILER\"\n printf '%s' \"$FINAL\"\n echo \"\"\n echo \"EOF_TRAILER\"\n } >> \"$GITHUB_OUTPUT\"\n env:\n COMMIT_MESSAGE: ${{ inputs.commit-message }}\n COMMIT_USER_NAME: ${{ inputs.commit-user-name }}\n COMMIT_USER_EMAIL: ${{ inputs.commit-user-email }}\n\n - uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0\n id: commit\n with:\n branch: ${{ inputs.branch }}\n commit_message: ${{ steps.msg.outputs.value }}\n commit_options: ${{ inputs.commit-options }}\n commit_user_name: ${{ inputs.commit-user-name }}\n commit_user_email: ${{ inputs.commit-user-email }}\n commit_author: ${{ inputs.commit-author }}\n";
1297
1389
  //#endregion
1298
1390
  //#region src/templates/reusable/actions/holocron.yml
1299
- var holocron_default = "name: Holocron\ndescription: >\n Run the Holocron CLI — `holocron run <task> [job]` by default, or any other\n subcommand via `command:` (`sync`, `sync-github`, …). Resolves\n `@theholocron/cli` from `node_modules` (published tarball) or builds it from\n source when it resolves to an unbuilt workspace checkout (the holocron repo\n itself). Requires dependencies to be installed already (run the setup action\n first).\n\ninputs:\n command:\n description: >\n Holocron subcommand. Default `run` — then `task` (and optional `job`)\n name the manifest task. Set to `sync` / `sync-github` / … to run that\n subcommand directly; `args` becomes everything after it.\n required: false\n default: run\n task:\n description: >\n Manifest task to run when command is run — typecheck, test, build, audit.\n required: false\n default: \"\"\n job:\n description: Optional sub-job within the task (e.g. knip, performance for audit).\n required: false\n default: \"\"\n args:\n description: >\n For `command: run`, extra arguments forwarded to the tool after `--`.\n For any other command, the arguments passed straight to it.\n required: false\n default: \"\"\n\nruns:\n using: composite\n\n steps:\n - name: Build the workspace when the CLI resolves to an unbuilt checkout\n shell: bash\n # The holocron repo carries @theholocron/cli as `workspace:*`; its bin\n # (dist/cli.mjs) does not exist on a fresh checkout. `pnpm build` is a\n # turbo run — cached and cheap once warm. A no-op everywhere else, where\n # the published tarball already ships dist/.\n #\n # pnpm creates every workspace package's node_modules/.bin/holocron shim\n # once, during the setup action's earlier `pnpm install` — before this\n # build has run, so every one of them is silently skipped (\"Failed to\n # create bin ... ENOENT\"), not just @theholocron/cli's own. That's a\n # one-time decision: a later build doesn't retroactively fix a shim that\n # was never created. Re-running `pnpm install` here (fast — the lockfile\n # hasn't changed, so it only re-links `.bin`) is what actually fixes it,\n # confirmed against a real cold install: `holocron` stays unresolvable\n # from inside any package until this second install runs.\n run: |\n if [ ! -f node_modules/@theholocron/cli/dist/cli.mjs ]; then\n echo \"→ @theholocron/cli dist/ missing — building the workspace from source\" >&2\n pnpm build\n echo \"→ re-linking node_modules/.bin — pnpm skipped it pre-build\" >&2\n pnpm install --frozen-lockfile\n fi\n\n - name: holocron ${{ inputs.command }} ${{ inputs.task }}\n shell: bash\n env:\n HOLOCRON_COMMAND: ${{ inputs.command }}\n HOLOCRON_TASK: ${{ inputs.task }}\n HOLOCRON_JOB: ${{ inputs.job }}\n HOLOCRON_ARGS: ${{ inputs.args }}\n run: |\n if [ \"$HOLOCRON_COMMAND\" = \"run\" ]; then\n set -- run \"$HOLOCRON_TASK\"\n [ -n \"$HOLOCRON_JOB\" ] && set -- \"$@\" \"$HOLOCRON_JOB\"\n # shellcheck disable=SC2086\n [ -n \"$HOLOCRON_ARGS\" ] && set -- \"$@\" -- $HOLOCRON_ARGS\n else\n # shellcheck disable=SC2086\n set -- \"$HOLOCRON_COMMAND\" $HOLOCRON_ARGS\n fi\n # Invoke the built entry directly. `pnpm exec holocron` relies on a\n # node_modules/.bin/holocron shim that pnpm does not create for a\n # workspace package whose dist/ was absent at install time (the holocron\n # repo itself). Resolving the package's main export gives dist/index.mjs;\n # the bin sits next to it as dist/cli.mjs. This is `require.resolve` from\n # node_modules — it works in the holocron monorepo AND in every consumer\n # repo (theholocron/holocron#655), unlike a hard-coded `packages/cli/…`.\n cli=\"$(dirname \"$(node -e 'process.stdout.write(require.resolve(\"@theholocron/cli\"))')\")/cli.mjs\"\n if [ ! -f \"$cli\" ]; then\n echo \"::error::@theholocron/cli entry not found at $cli — is the workspace built?\" >&2\n exit 1\n fi\n echo \"→ $cli $*\" >&2\n node \"$cli\" \"$@\"\n";
1391
+ var holocron_default = "name: Holocron\ndescription: >\n Run the Holocron CLI — `holocron run <task> [job]` by default, or any other\n subcommand via `command:` (`sync`, `sync-github`, …). Resolves\n `@theholocron/cli` from `node_modules` (published tarball) or builds it from\n source when it resolves to an unbuilt workspace checkout (the holocron repo\n itself). Requires dependencies to be installed already (run the setup action\n first).\n\ninputs:\n command:\n description: >\n Holocron subcommand. Default `run` — then `task` (and optional `job`)\n name the manifest task. Set to `sync` / `sync-github` / … to run that\n subcommand directly; `args` becomes everything after it.\n required: false\n default: run\n task:\n description: >\n Manifest task to run when command is run — typecheck, test, build, audit.\n required: false\n default: \"\"\n job:\n description: Optional sub-job within the task (e.g. knip, performance for audit).\n required: false\n default: \"\"\n args:\n description: >\n For `command: run`, extra arguments forwarded to the tool after `--`.\n For any other command, the arguments passed straight to it.\n required: false\n default: \"\"\n\noutputs:\n log-file:\n description: >\n Path to a file holding the full captured stdout+stderr of this\n invocation — the same text the live Actions log shows, saved for a\n caller that wants to ship it elsewhere (e.g. Sentinel's dispatched\n checks shipping it to Axiom, holocron#769/#794).\n value: ${{ steps.run.outputs.log-file }}\n\nruns:\n using: composite\n\n steps:\n - name: Build the workspace when the CLI resolves to an unbuilt checkout\n shell: bash\n # The holocron repo carries @theholocron/cli as `workspace:*`; its bin\n # (dist/cli.mjs) does not exist on a fresh checkout. `pnpm build` is a\n # turbo run — cached and cheap once warm. A no-op everywhere else, where\n # the published tarball already ships dist/.\n #\n # pnpm creates every workspace package's node_modules/.bin/holocron shim\n # once, during the setup action's earlier `pnpm install` — before this\n # build has run, so every one of them is silently skipped (\"Failed to\n # create bin ... ENOENT\"), not just @theholocron/cli's own. That's a\n # one-time decision: a later build doesn't retroactively fix a shim that\n # was never created. Re-running `pnpm install` here (fast — the lockfile\n # hasn't changed, so it only re-links `.bin`) is what actually fixes it,\n # confirmed against a real cold install: `holocron` stays unresolvable\n # from inside any package until this second install runs.\n run: |\n if [ ! -f node_modules/@theholocron/cli/dist/cli.mjs ]; then\n echo \"→ @theholocron/cli dist/ missing — building the workspace from source\" >&2\n pnpm build\n echo \"→ re-linking node_modules/.bin — pnpm skipped it pre-build\" >&2\n pnpm install --frozen-lockfile\n fi\n\n - name: holocron ${{ inputs.command }} ${{ inputs.task }}\n id: run\n shell: bash\n env:\n HOLOCRON_COMMAND: ${{ inputs.command }}\n HOLOCRON_TASK: ${{ inputs.task }}\n HOLOCRON_JOB: ${{ inputs.job }}\n HOLOCRON_ARGS: ${{ inputs.args }}\n run: |\n if [ \"$HOLOCRON_COMMAND\" = \"run\" ]; then\n set -- run \"$HOLOCRON_TASK\"\n [ -n \"$HOLOCRON_JOB\" ] && set -- \"$@\" \"$HOLOCRON_JOB\"\n # shellcheck disable=SC2086\n [ -n \"$HOLOCRON_ARGS\" ] && set -- \"$@\" -- $HOLOCRON_ARGS\n else\n # shellcheck disable=SC2086\n set -- \"$HOLOCRON_COMMAND\" $HOLOCRON_ARGS\n fi\n # Invoke the built entry directly. `pnpm exec holocron` relies on a\n # node_modules/.bin/holocron shim that pnpm does not create for a\n # workspace package whose dist/ was absent at install time (the holocron\n # repo itself). Resolving the package's main export gives dist/index.mjs;\n # the bin sits next to it as dist/cli.mjs. This is `require.resolve` from\n # node_modules — it works in the holocron monorepo AND in every consumer\n # repo (theholocron/holocron#655), unlike a hard-coded `packages/cli/…`.\n cli=\"$(dirname \"$(node -e 'process.stdout.write(require.resolve(\"@theholocron/cli\"))')\")/cli.mjs\"\n if [ ! -f \"$cli\" ]; then\n echo \"::error::@theholocron/cli entry not found at $cli — is the workspace built?\" >&2\n exit 1\n fi\n echo \"→ $cli $*\" >&2\n # `tee` keeps the live Actions log streaming exactly as before while\n # also capturing it to a file callers can ship elsewhere (log-file\n # output, above). PIPESTATUS[0] -- not $? -- is `node`'s own exit\n # code; $? after a pipe is tee's, which succeeds even when node fails.\n log_file=\"${RUNNER_TEMP}/holocron-output.log\"\n node \"$cli\" \"$@\" 2>&1 | tee \"$log_file\"\n exit_code=${PIPESTATUS[0]}\n echo \"log-file=$log_file\" >> \"$GITHUB_OUTPUT\"\n exit \"$exit_code\"\n";
1300
1392
  //#endregion
1301
1393
  //#region src/templates/reusable/actions/install.yml
1302
1394
  var install_default = "name: Install dependencies\ndescription: Install project dependencies with pnpm frozen lockfile.\n\nruns:\n using: composite\n\n steps:\n - name: Install dependencies\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n shell: bash\n run: pnpm install --frozen-lockfile\n";
@@ -1337,8 +1429,9 @@ const REUSABLE_WORKFLOWS = {
1337
1429
  "delivery.deploy": "name: Deploy\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n\njobs:\n deploy:\n name: Deploy\n runs-on: ubuntu-latest\n permissions:\n contents: read\n pages: write\n id-token: write\n environment:\n name: github-pages\n url: ${{ steps.deployment.outputs.page_url }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n cp -r docs/dist/. _site/\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1\n name: Upload pages artifact\n with:\n path: _site\n\n - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5\n id: deployment\n name: Deploy to GitHub Pages\n",
1338
1430
  "delivery.bundleSize": "name: Audit the Bundle Size\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n CODECOV_TOKEN:\n required: false\n TURBO_TOKEN:\n required: false\n\njobs:\n bundle-size:\n name: Upload bundle stats to Codecov\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 15\n env:\n TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}\n TURBO_TEAM: ${{ vars.TURBO_TEAM }}\n # Read by the bundle-stats uploader inside `holocron run delivery.build`.\n CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - uses: theholocron/.github/.github/actions/holocron@main\n name: Build and upload bundle stats\n with:\n task: delivery.build\n",
1339
1431
  "platform.repoSync": "name: Sync\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n steps:\n description: >\n Sync steps to run (default: all). Valid values:\n labels, properties, teams, topics, keywords, description, homepage, readme, workflows, wiki.\n Pass a space-separated list to run a subset, e.g. \"readme\" or \"readme wiki\".\n type: string\n required: false\n secrets:\n HOLOCRON_ADMIN_TOKEN:\n description: Fine-grained PAT with admin scopes (labels, properties, teams).\n required: false\n HOLOCRON_AXIOM_TOKEN:\n description: >\n Axiom API token. When set alongside the HOLOCRON_AXIOM_DATASET\n repo/org variable, the CLI ships this run's structured logs to Axiom.\n Falls back to the vendor-native AXIOM_TOKEN secret.\n required: false\n AXIOM_TOKEN:\n description: Vendor-native fallback for HOLOCRON_AXIOM_TOKEN.\n required: false\n HOLOCRON_DEPLOY_TOKEN:\n description: Fine-grained PAT for GitHub Pages configuration.\n required: false\n HOLOCRON_ISSUES_TOKEN:\n description: Fine-grained PAT for issue management.\n required: false\n HOLOCRON_ORG_TOKEN:\n description: Org-scoped fine-grained PAT for team sync and org properties.\n required: false\n HOLOCRON_READ_TOKEN:\n description: Fine-grained PAT for read-only GitHub API calls.\n required: false\n HOLOCRON_SYNC_TOKEN:\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for gh CLI calls. Used when\n HOLOCRON_SYNC_TOKEN is not set.\n required: false\n\njobs:\n sync:\n name: Run holocron sync\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: write\n pull-requests: write\n # Job-level so the `holocron` composite action's steps inherit them.\n env:\n HOLOCRON_ADMIN_TOKEN: ${{ secrets.HOLOCRON_ADMIN_TOKEN }}\n HOLOCRON_AXIOM_TOKEN: ${{ secrets.HOLOCRON_AXIOM_TOKEN || secrets.AXIOM_TOKEN }}\n HOLOCRON_AXIOM_DATASET: ${{ vars.HOLOCRON_AXIOM_DATASET || vars.AXIOM_DATASET }}\n POSTHOG_PROJECT_TOKEN: ${{ vars.POSTHOG_PROJECT_TOKEN }}\n HOLOCRON_DEPLOY_TOKEN: ${{ secrets.HOLOCRON_DEPLOY_TOKEN }}\n HOLOCRON_ISSUES_TOKEN: ${{ secrets.HOLOCRON_ISSUES_TOKEN }}\n HOLOCRON_ORG_TOKEN: ${{ secrets.HOLOCRON_ORG_TOKEN }}\n HOLOCRON_READ_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Run holocron sync\n # The `holocron` action resolves @theholocron/cli from node_modules (or\n # builds the workspace when it's an unbuilt checkout — the holocron repo\n # itself). `inputs.steps` is a space-separated list or empty; the\n # `--steps <list>` arg is omitted when empty.\n uses: theholocron/.github/.github/actions/holocron@main\n with:\n command: sync\n args: ${{ inputs.steps != '' && format('--steps {0}', inputs.steps) || '' }}\n\n - name: Format generated files\n run: |\n pnpm exec prettier --write README.md docs/src/content/docs/index.mdx 2>/dev/null || true\n # `holocron sync` writes package.json fields (keywords, description,\n # homepage) with a plain assignment, which appends new keys at the\n # end — re-apply the canonical order the lint config enforces.\n pnpm exec eslint --fix --no-warn-ignored package.json 2>/dev/null || true\n\n - uses: theholocron/.github/.github/actions/auto-commit@main\n id: auto-commit\n name: Commit sync changes\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n branch: chore/auto-sync\n commit-message: \"chore: sync from holocron.config\"\n commit-options: \"--no-verify\"\n\n - name: Open PR if changes were committed\n if: steps.auto-commit.outputs.changes-detected == 'true'\n run: |\n gh pr create \\\n --title \"chore: sync README and repo metadata\" \\\n --body \"Automated sync triggered by changes to config or package files. Merge to apply.\" \\\n --base main \\\n --head chore/auto-sync \\\n || echo \"PR already open — branch updated.\"\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n\n - name: Broadcast wiki sync if navbar changed\n if: >-\n steps.auto-commit.outputs.changes-detected == 'true' &&\n github.event_name == 'push' &&\n (inputs.steps == '' || contains(inputs.steps, 'wiki'))\n run: |\n if git diff HEAD~1 --name-only | grep -q 'fern/docs.yml'; then\n gh workflow run sync-dispatch.yml \\\n --repo theholocron/.github \\\n --field \"steps=wiki\" \\\n || echo \"skipping broadcast — insufficient permissions\"\n fi\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n",
1340
- "platform.commitStandards": "name: Commit Standards\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n TURBO_TOKEN:\n required: false\n\njobs:\n commit-standards:\n name: Run commitlint\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n env:\n TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}\n TURBO_TEAM: ${{ vars.TURBO_TEAM }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n # No local equivalent (linters.ts: commitlint has no localBin — enforced\n # only at commit time via the commit-msg hook, and here in CI over the\n # PR's commit range). Not run through the holocron composite action.\n - name: Run commitlint over the PR range\n if: github.event_name == 'pull_request'\n run: pnpm exec commitlint --from \"${{ github.event.pull_request.base.sha }}\" --to \"${{ github.sha }}\"\n",
1432
+ "platform.commitStandards": "name: Commit Standards\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n TURBO_TOKEN:\n required: false\n\njobs:\n commit-standards:\n name: Run commitlint\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n env:\n TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}\n TURBO_TEAM: ${{ vars.TURBO_TEAM }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n # No local equivalent (linters.ts: commitlint has no localBin — enforced\n # only at commit time via the commit-msg hook, and here in CI over the\n # PR's commit range). Not run through the holocron composite action.\n #\n # --config <resolved shared path> (config-resolution workstream, #676)\n # — falls back to commitlint's own auto-discovery of a local\n # commitlint.config.ts when the shared package's built dist/ isn't\n # present, same guard as the commit-msg hook.\n - name: Run commitlint over the PR range\n if: github.event_name == 'pull_request'\n run: |\n COMMITLINT_CONFIG=\"node_modules/@theholocron/commitlint-config/dist/index.js\"\n if [ -f \"$COMMITLINT_CONFIG\" ]; then\n pnpm exec commitlint --config \"$COMMITLINT_CONFIG\" --from \"${{ github.event.pull_request.base.sha }}\" --to \"${{ github.sha }}\"\n else\n pnpm exec commitlint --from \"${{ github.event.pull_request.base.sha }}\" --to \"${{ github.sha }}\"\n fi\n",
1341
1433
  "platform.repoValidation": "name: Repo Validation\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n TURBO_TOKEN:\n required: false\n\njobs:\n adrs:\n name: Validate ADRs and specs\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - uses: theholocron/.github/.github/actions/holocron@main\n name: Validate ADR and spec frontmatter\n with:\n task: platform.repoValidation\n job: adrs\n\n registry:\n name: Validate registry consistency\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - uses: theholocron/.github/.github/actions/holocron@main\n name: Validate registry consistency\n with:\n task: platform.repoValidation\n job: registry\n\n docs-presence:\n name: Validate docs presence\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - uses: theholocron/.github/.github/actions/holocron@main\n name: Validate docs presence for new packages\n with:\n task: platform.repoValidation\n job: docsPresence\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}\n\n conclusion:\n name: Conclusion\n runs-on: ubuntu-latest\n if: always()\n needs: [adrs, registry, docs-presence]\n steps:\n - name: Check job statuses\n run: |\n if [[ \"$RESULTS\" == *\"failure\"* ]] || [[ \"$RESULTS\" == *\"cancelled\"* ]]; then\n exit 1\n fi\n env:\n RESULTS: ${{ join(needs.*.result, ',') }}\n",
1434
+ "platform.dispatchedCheck": "name: Dispatched Check\n\non: # yamllint disable-line rule:truthy\n workflow_dispatch:\n inputs:\n repo:\n description: Target repo, \"owner/name\", to check out and run the task against.\n type: string\n required: true\n ref:\n description: Git ref (branch or commit SHA) to check out.\n type: string\n required: true\n task:\n description: The holocron task name to run (e.g. \"verification.typeSafety\").\n type: string\n required: true\n check-run-id:\n description: >\n ID of the check run already posted as \"queued\" on the target repo —\n patched to \"completed\" once the task finishes.\n type: string\n required: true\n\npermissions:\n contents: read\n\njobs:\n run-task:\n name: Run a Sentinel-dispatched task against another repo\n runs-on: ubuntu-latest\n timeout-minutes: 20\n env:\n TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}\n TURBO_TEAM: ${{ vars.TURBO_TEAM }}\n steps:\n # actions/create-github-app-token defaults to scoping its token to the\n # *current* repo (.github, where this workflow runs) when owner/\n # repositories are omitted — useless here, since every real use of this\n # token targets inputs.repo instead. Split \"owner/name\" up front so the\n # token step below can scope explicitly to the actual target.\n - name: Parse target repo\n id: target\n run: |\n echo \"owner=${INPUT_REPO%%/*}\" >> \"$GITHUB_OUTPUT\"\n echo \"name=${INPUT_REPO##*/}\" >> \"$GITHUB_OUTPUT\"\n env:\n INPUT_REPO: ${{ inputs.repo }}\n\n # Sentinel's own dispatch call only carries plain, non-secret inputs\n # (repo/ref/task/check-run-id — all visible in the Actions UI/logs).\n # This step mints a fresh, short-lived installation token here instead,\n # scoped by the same App's stored credentials — never a raw token\n # passed through a dispatch input.\n - name: Generate installation token\n id: app-token\n uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0\n with:\n app-id: ${{ secrets.SENTINEL_APP_ID }}\n private-key: ${{ secrets.SENTINEL_APP_PRIVATE_KEY }}\n owner: ${{ steps.target.outputs.owner }}\n repositories: ${{ steps.target.outputs.name }}\n\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout target repository\n with:\n repository: ${{ inputs.repo }}\n ref: ${{ inputs.ref }}\n token: ${{ steps.app-token.outputs.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Run the task\n id: run-task\n continue-on-error: true\n uses: theholocron/.github/.github/actions/holocron@main\n with:\n task: ${{ inputs.task }}\n\n # A bare .../actions/runs/<id> link lands on the run summary, one click\n # short of the actual step-by-step log a normal check's details_url\n # points at. This job is always alone in its run, so the first (only)\n # job in the run's own job list is unambiguously this one.\n - name: Resolve this job's numeric ID\n id: job-info\n if: always()\n run: |\n job_id=$(gh api \"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs\" --jq '.jobs[0].id')\n echo \"job_id=$job_id\" >> \"$GITHUB_OUTPUT\"\n env:\n GH_TOKEN: ${{ steps.app-token.outputs.token }}\n\n # Ships the task's real captured output (tsc/vitest/etc — whatever\n # `holocron run` actually printed) to the same Axiom dataset Sentinel's\n # own structured logs already flow into, tagged so it's queryable by\n # this exact run. Reuses the existing dataset rather than provisioning\n # a new one — a prototype-stage call, revisit if CI-log volume ever\n # warrants a dedicated dataset. Builds a permalink filtered to this\n # run's own logs (Axiom's own \"copy query link\" URL shape:\n # /<org>/query?initForm={\"apl\":\"...\"} ) so a viewer lands directly on\n # the relevant lines, not a bare dataset view.\n - name: Ship task output to Axiom\n id: axiom\n if: always()\n run: |\n message=$(tail -c 40000 \"$LOG_FILE\" 2>/dev/null || echo \"(no output captured)\")\n # \"level\" is one of the field names Axiom's Stream/Query tabs\n # recognize for severity-color highlighting (docs: level, @level,\n # severity, @severity, status.code) -- conclusion alone isn't.\n level=$([ \"$CONCLUSION\" = \"success\" ] && echo \"info\" || echo \"error\")\n event=$(jq -cn \\\n --arg time \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \\\n --arg repo \"$REPO\" --arg task \"$TASK\" --arg run_id \"$GITHUB_RUN_ID\" \\\n --arg job_id \"$JOB_ID\" --arg check_run_id \"$CHECK_RUN_ID\" \\\n --arg conclusion \"$CONCLUSION\" --arg level \"$level\" --arg message \"$message\" \\\n '{_time: $time, source: \"dispatched-check\", repo: $repo, task: $task,\n run_id: $run_id, job_id: $job_id, check_run_id: $check_run_id,\n conclusion: $conclusion, level: $level, message: $message}')\n curl -sf -X POST \"https://api.axiom.co/v1/datasets/${AXIOM_DATASET}/ingest\" \\\n -H \"Authorization: Bearer ${AXIOM_TOKEN}\" \\\n -H \"Content-Type: application/json\" \\\n -d \"[${event}]\" || echo \"::warning::Axiom ingest failed — continuing, the check itself still reports the real result\"\n\n # The ['...'] is Axiom's own APL array-dataset-reference syntax, not\n # a shell quote boundary — $AXIOM_DATASET/$CHECK_RUN_ID both expand\n # fine here; shellcheck's single-quote heuristic just can't tell.\n # shellcheck disable=SC2016\n apl=\"['${AXIOM_DATASET}'] | where check_run_id == \\\"${CHECK_RUN_ID}\\\"\"\n init_form=$(jq -cn --arg apl \"$apl\" '{apl: $apl}')\n encoded=$(jq -rn --arg s \"$init_form\" '$s | @uri')\n echo \"query-url=https://app.axiom.co/${AXIOM_ORG}/query?initForm=${encoded}\" >> \"$GITHUB_OUTPUT\"\n env:\n LOG_FILE: ${{ steps.run-task.outputs.log-file }}\n REPO: ${{ inputs.repo }}\n TASK: ${{ inputs.task }}\n JOB_ID: ${{ steps.job-info.outputs.job_id }}\n CHECK_RUN_ID: ${{ inputs.check-run-id }}\n CONCLUSION: ${{ steps.run-task.outcome == 'success' && 'success' || 'failure' }}\n AXIOM_ORG: the-holocron-7bbe\n AXIOM_DATASET: holocron-sentinel\n AXIOM_TOKEN: ${{ secrets.SENTINEL_AXIOM_INGEST_TOKEN }}\n\n # Always runs, success or failure — the queued check run on the target\n # repo must be resolved either way, not left hanging. Full JSON body via\n # jq + --input, not -f flags: output.text needs real nesting (gh api -f\n # has no dot-notation for that), and the captured task output can\n # contain characters (quotes, $, backticks) that would break a\n # hand-built -f string.\n - name: Report result to the target repo's check run\n if: always()\n run: |\n excerpt=$(tail -c 3000 \"$LOG_FILE\" 2>/dev/null || echo \"(no output captured)\")\n # The single-quoted format string is intentional — every %s fills\n # via printf's own args below, not shell expansion; nothing in it\n # needs to expand.\n # shellcheck disable=SC2016\n text=$(printf '**Task:** `%s`\\n\\n**Result:** %s\\n\\n[Full logs on Axiom](%s)\\n\\n### Last output\\n\\n```\\n%s\\n```\\n' \\\n \"$TASK\" \"$CONCLUSION\" \"$QUERY_URL\" \"$excerpt\")\n body=$(jq -n \\\n --arg conclusion \"$CONCLUSION\" \\\n --arg details_url \"$RUN_URL\" \\\n --arg title \"$TASK ($CONCLUSION)\" \\\n --arg summary \"Dispatched via theholocron/.github — see the log excerpt below, or the full run/Axiom links.\" \\\n --arg text \"$text\" \\\n '{status: \"completed\", conclusion: $conclusion, details_url: $details_url,\n output: {title: $title, summary: $summary, text: $text}}')\n echo \"$body\" | gh api --method PATCH \"/repos/${REPO}/check-runs/${CHECK_RUN_ID}\" --input -\n env:\n # `GH_TOKEN` is the literal name `gh api` requires to auto-authenticate\n # -- not a stand-in for a shared/blanket credential. Its value is the\n # short-lived, Sentinel-App-scoped token minted above, same as the\n # checkout step uses; never secrets.HOLOCRON_SYNC_TOKEN (the one\n # genuinely shared PAT in this org, used by sync-dispatch.yml).\n GH_TOKEN: ${{ steps.app-token.outputs.token }}\n REPO: ${{ inputs.repo }}\n TASK: ${{ inputs.task }}\n CHECK_RUN_ID: ${{ inputs.check-run-id }}\n CONCLUSION: ${{ steps.run-task.outcome == 'success' && 'success' || 'failure' }}\n LOG_FILE: ${{ steps.run-task.outputs.log-file }}\n QUERY_URL: ${{ steps.axiom.outputs.query-url }}\n RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/job/${{ steps.job-info.outputs.job_id }}\n",
1342
1435
  "knowledge.wiki": "name: Wiki\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n fern-version:\n description: >\n Fern CLI version to install. Pin this to avoid breaking changes when\n Fern updates their config schema.\n type: string\n required: false\n default: \"5.114.1\"\n preview:\n description: >\n When true, publishes a preview instead of production.\n Requires preview-id to be set.\n type: boolean\n required: false\n default: false\n preview-id:\n description: >\n Stable ID for the preview build. Use the PR number (e.g. \"pr-123\")\n so the same preview URL is reused on every push to the branch.\n type: string\n required: false\n default: \"\"\n fern-org:\n description: >\n DEPRECATED — the preview URL is now read from Fern's own output.\n Kept as a fallback for building the URL when that parse fails, and so\n existing callers do not error.\n type: string\n required: false\n default: \"\"\n base-path:\n description: >\n DEPRECATED — see fern-org. Basepath appended to the fallback preview\n URL (e.g. \"holocron\" for multi-source routing).\n type: string\n required: false\n default: \"\"\n secrets:\n HOLOCRON_FERN_TOKEN:\n required: false\n FERN_TOKEN:\n required: false\n\njobs:\n publish:\n name: Publish to Fern\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: read\n deployments: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - name: Install Fern CLI\n run: npm install -g \"fern-api@$FERN_VERSION\"\n env:\n FERN_VERSION: ${{ inputs.fern-version }}\n\n - name: Publish docs\n if: ${{ !inputs.preview }}\n run: fern generate --docs\n env:\n FERN_TOKEN: ${{ secrets.HOLOCRON_FERN_TOKEN || secrets.FERN_TOKEN }}\n\n - name: Preview docs\n id: preview\n if: ${{ inputs.preview && inputs.preview-id != '' }}\n env:\n FERN_TOKEN: ${{ secrets.HOLOCRON_FERN_TOKEN || secrets.FERN_TOKEN }}\n PREVIEW_ID: ${{ inputs.preview-id }}\n run: |\n out=$(fern generate --docs --preview --id \"$PREVIEW_ID\" 2>&1) || { printf '%s\\n' \"$out\"; exit 1; }\n printf '%s\\n' \"$out\"\n # Fern prints \"Published docs to https://<org>-preview-<id>.docs.buildwithfern.com/<path>\"\n url=$(printf '%s\\n' \"$out\" | grep -oiE 'https://[a-z0-9.-]+\\.docs\\.buildwithfern\\.com[^ )]*' | head -n1)\n echo \"url=$url\" >> \"$GITHUB_OUTPUT\"\n\n - name: Report preview URL\n if: ${{ inputs.preview && inputs.preview-id != '' }}\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n CAPTURED_URL: ${{ steps.preview.outputs.url }}\n FERN_ORG: ${{ inputs.fern-org }}\n PREVIEW_ID: ${{ inputs.preview-id }}\n BASE_PATH: ${{ inputs.base-path }}\n run: |\n PREVIEW_URL=\"$CAPTURED_URL\"\n if [ -z \"$PREVIEW_URL\" ] && [ -n \"$FERN_ORG\" ]; then\n PREVIEW_URL=\"https://${FERN_ORG}-preview-${PREVIEW_ID}.docs.buildwithfern.com${BASE_PATH:+/${BASE_PATH}}\"\n fi\n if [ -z \"$PREVIEW_URL\" ]; then\n echo \"::warning::no preview URL parsed from Fern output and no fern-org fallback — skipping the deployment widget\"\n exit 0\n fi\n PAYLOAD=$(printf '{\"ref\":\"%s\",\"environment\":\"wiki (Preview)\",\"description\":\"Wiki\",\"production_environment\":false,\"auto_merge\":false,\"required_contexts\":[]}' \\\n \"$GITHUB_HEAD_REF\")\n DEPLOY_ID=$(printf '%s\\n' \"$PAYLOAD\" | gh api \"repos/${GITHUB_REPOSITORY}/deployments\" \\\n --method POST --input - | jq -r '.id')\n gh api \"repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses\" \\\n --method POST --field state=success --field environment_url=\"$PREVIEW_URL\"\n",
1343
1436
  bookkeeping: "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n configuration-path:\n description: Path to the labeler configuration file in the calling repo\n type: string\n required: false\n default: .github/labeler.yml\n\njobs:\n label:\n name: Apply Labels\n permissions:\n contents: read\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n with:\n sparse-checkout: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n sparse-checkout-cone-mode: false\n\n - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4\n if: ${{ github.event_name == 'pull_request' && hashFiles(inputs.configuration-path || '.github/labeler.yml') != '' }}\n # v3.4 bundles Node 20; allow it to run under Actions' current default.\n env:\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true\n with:\n # Fall back to default path when triggered directly (not via workflow_call)\n # because inputs.* defaults only apply on workflow_call events.\n configuration-path: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n include-title: 1\n include-body: 0\n sync-labels: 1\n enable-versioned-regex: 0\n repo-token: ${{ github.token }}\n",
1344
1437
  dependencies: "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n merge-token:\n description: >\n Optional privileged token for auto-merge. Falls back to GITHUB_TOKEN.\n Required when branch protection enforces required reviews — GITHUB_TOKEN\n cannot approve its own PRs.\n required: false\n\njobs:\n dependabot:\n name: Update the dependencies\n permissions:\n contents: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n if: github.event.pull_request.user.login == 'dependabot[bot]'\n steps:\n - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0\n name: Fetch Dependabot metadata\n id: metadata\n\n - run: gh pr merge --auto --squash \"$PR_URL\"\n # --squash is intentional: repo protection sets allow_merge_commit: false,\n # so --merge would fail on any repo using the standard preset.\n name: Enable auto-merge for Dependabot PRs\n if: steps.metadata.outputs.update-type == 'version-update:semver-patch'\n env:\n PR_URL: ${{ github.event.pull_request.html_url }}\n GH_TOKEN: ${{ secrets.merge-token || github.token }}\n",
@@ -1444,6 +1537,149 @@ function turboConfig(config) {
1444
1537
  tasks
1445
1538
  }, null, 2)}\n`;
1446
1539
  }
1540
+ /**
1541
+ * Fixes #692: `turboConfig()` writes a `turbo.json` the moment *any* task in
1542
+ * the manifest has fan-out config — with no check for whether the repo's own
1543
+ * root package is actually covered by `pnpm-workspace.yaml`'s `packages:`
1544
+ * list. A repo shaped like `observability` (the library lives at repo root;
1545
+ * `packages:` lists only an unrelated `docs` site) silently breaks the
1546
+ * instant `turbo.json` exists: `holocron run <task>` switches from running
1547
+ * root's own script directly to `turbo run <task>`, which only sees declared
1548
+ * workspace members — root vanishes, the task "succeeds" with zero real work
1549
+ * done (confirmed empirically: `Packages in scope: docs`, root never
1550
+ * mentioned, exit 0).
1551
+ *
1552
+ * Detection is narrow and specific: root only needs to be an explicit
1553
+ * workspace member if its *own* `package.json` has a script literally named
1554
+ * after one of the tasks turbo.json is about to fan out — a plain
1555
+ * orchestrator root (`holocron`'s own `"build": "turbo run delivery.build"`,
1556
+ * which has no `delivery.build` script itself) never trips this; only a root
1557
+ * that's genuinely a directly-buildable package does. Confirmed the fix
1558
+ * empirically too — adding `.` to `packages:` makes turbo pick root back up
1559
+ * (`Packages in scope: docs, root-lib`) without disturbing the sibling
1560
+ * package's own caching.
1561
+ *
1562
+ * A matching script key alone isn't enough — its *value* has to be a real
1563
+ * command, not the generated `holocron run <task> --` wrapper (#846, the
1564
+ * counterpart `mergePackageJsonScripts()` already had to learn for #747/
1565
+ * #748). A repo whose root predates this tooling can already have that exact
1566
+ * wrapper sitting in a task-name-matching script with nothing ever having
1567
+ * generated it this session; adding `.` there fires #747's recursion
1568
+ * immediately (`turbo run <task>` → root's own `<task>` script → the wrapper
1569
+ * → `holocron run <task>` sees `turbo.json` still defines it → `turbo run
1570
+ * <task>` again, forever). Only a matching script whose value is *not* that
1571
+ * wrapper counts as "root is genuinely a directly-buildable package".
1572
+ *
1573
+ * A targeted line-based edit, not a full YAML parse/reserialize — same
1574
+ * pattern `codecov.ts`'s `mergeCodecovComponents()` already uses for editing
1575
+ * an existing generated file. `pnpm-workspace.yaml` routinely carries a
1576
+ * `catalog:`/`catalogs:`/`overrides:` block after `packages:`; a real parser
1577
+ * round-trip risks reformatting or reordering content nobody asked to touch.
1578
+ * Only ever *adds* a line — never rewrites or reorders anything already
1579
+ * there — and is a no-op (`changed: false`) whenever root doesn't need it,
1580
+ * root is already listed, or `packages:` isn't in the plain block-list form
1581
+ * every repo checked actually uses.
1582
+ */
1583
+ function ensureRootWorkspaceMember(workspaceYaml, rootScripts, taskNames) {
1584
+ if (!taskNames.some((t) => {
1585
+ const script = rootScripts[t];
1586
+ return script !== void 0 && !isGeneratedRunWrapper(script, t);
1587
+ })) return {
1588
+ content: workspaceYaml,
1589
+ changed: false
1590
+ };
1591
+ const lines = workspaceYaml.split("\n");
1592
+ const packagesLineIdx = lines.findIndex((l) => /^packages:\s*$/.test(l));
1593
+ if (packagesLineIdx === -1) return {
1594
+ content: workspaceYaml,
1595
+ changed: false
1596
+ };
1597
+ const items = [];
1598
+ let cursor = packagesLineIdx + 1;
1599
+ while (cursor < lines.length && /^\s*-\s*/.test(lines[cursor])) {
1600
+ items.push(lines[cursor]);
1601
+ cursor++;
1602
+ }
1603
+ if (items.some((item) => {
1604
+ const value = item.replace(/^\s*-\s*/, "").replace(/^["']|["']$/g, "").trim();
1605
+ return value === "." || value === "";
1606
+ })) return {
1607
+ content: workspaceYaml,
1608
+ changed: false
1609
+ };
1610
+ const newLine = `${items[0]?.match(/^(\s*-\s*)/)?.[1] ?? " - "}"."`;
1611
+ return {
1612
+ content: [
1613
+ ...lines.slice(0, packagesLineIdx + 1),
1614
+ newLine,
1615
+ ...lines.slice(packagesLineIdx + 1)
1616
+ ].join("\n"),
1617
+ changed: true
1618
+ };
1619
+ }
1620
+ /**
1621
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
1622
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
1623
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
1624
+ * packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
1625
+ * rather than the whole string means an old wrapper survives a
1626
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
1627
+ * to update to the new one), not mistaken for a real command. Mirrors
1628
+ * `sync.ts`'s private `isGeneratedRunWrapper()` — same predicate, needed on
1629
+ * both sides of #692/#747: whether to *write* the wrapper, and here, whether
1630
+ * an *existing* one should count as "root is directly buildable".
1631
+ */
1632
+ function isGeneratedRunWrapper(command, taskName) {
1633
+ return command.endsWith(` run ${taskName} --`);
1634
+ }
1635
+ /**
1636
+ * Fallback pin when a repo's own `pnpm-workspace.yaml` has no `turbo`
1637
+ * catalog entry yet — matches this repo's own catalog version (the
1638
+ * canonical source-of-truth repo for the whole org's tooling).
1639
+ */
1640
+ const DEFAULT_TURBO_VERSION = "^2.10.12";
1641
+ /**
1642
+ * Ensures `turbo` is declared in `package.json#devDependencies` — found
1643
+ * the hard way rolling this out to `observability` (predates #691):
1644
+ * `turboConfig()` writes a `turbo.json` the moment any task has fan-out
1645
+ * config, but never touches `package.json`, so a repo that predates this
1646
+ * feature (or never had `turbo` installed for any other reason) ends up
1647
+ * with a `turbo.json` and nothing local to run it. `resolveBin()`
1648
+ * (`run.ts`) falls back to a bare `turbo` on `PATH` when
1649
+ * `node_modules/.bin/turbo` doesn't exist — on a machine with no global
1650
+ * `turbo` at all that's a hard failure for every task; on one that
1651
+ * happens to have an unrelated global install, silent version skew
1652
+ * (confirmed empirically: a global 2.6.0 rejected this repo's generated
1653
+ * `turbo.json` outright — `Found an unknown key "globalDependencies"`,
1654
+ * a schema key from a newer version than the one actually resolved).
1655
+ *
1656
+ * Prefers `"catalog:"` when the repo's own `pnpm-workspace.yaml` already
1657
+ * has a `turbo` catalog entry — matches every already-migrated repo
1658
+ * (`holocron`, `clients`, …) — falling back to a real pinned version
1659
+ * otherwise. Never touches an existing `devDependencies.turbo` entry, so
1660
+ * a repo pinning its own version on purpose is left alone — idempotent,
1661
+ * a no-op once set either way.
1662
+ */
1663
+ function ensureTurboDependency(packageJson, workspaceYaml) {
1664
+ const pkg = JSON.parse(packageJson);
1665
+ if (typeof pkg.devDependencies?.turbo === "string") return {
1666
+ content: packageJson,
1667
+ changed: false
1668
+ };
1669
+ const hasTurboCatalogEntry = /^\s*turbo:\s*\S/m.test(workspaceYaml);
1670
+ const devDependencies = {
1671
+ ...pkg.devDependencies,
1672
+ turbo: hasTurboCatalogEntry ? "catalog:" : DEFAULT_TURBO_VERSION
1673
+ };
1674
+ const updated = {
1675
+ ...pkg,
1676
+ devDependencies
1677
+ };
1678
+ return {
1679
+ content: `${JSON.stringify(updated, null, 2)}\n`,
1680
+ changed: true
1681
+ };
1682
+ }
1447
1683
  //#endregion
1448
1684
  //#region src/astromech.ts
1449
1685
  /**
@@ -1546,7 +1782,11 @@ function createAstromech(options) {
1546
1782
  reusableTemplates: () => reusableTemplates(),
1547
1783
  requiredChecks: () => requiredChecks(options.config ?? {}),
1548
1784
  codecovConfig: (existing) => codecovConfig(options.cwd, existing),
1549
- turboConfig: () => turboConfig(options.config ?? {})
1785
+ turboConfig: () => turboConfig(options.config ?? {}),
1786
+ ensureRootWorkspaceMember: (workspaceYaml, rootScripts) => {
1787
+ return ensureRootWorkspaceMember(workspaceYaml, rootScripts, (options.config?.tasks ?? []).map(normalizeTaskEntry).filter((e) => e.local !== false).map((e) => e.name));
1788
+ },
1789
+ ensureTurboDependency: (packageJson, workspaceYaml) => ensureTurboDependency(packageJson, workspaceYaml)
1550
1790
  };
1551
1791
  }
1552
1792
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "5.0.0-alpha.8",
3
+ "version": "5.0.0-alpha.81",
4
4
  "description": "The Holocron task runner — one task manifest drives `holocron run`, `holocron ci`, the CI workflows, package.json scripts, linters, and required checks.",
5
5
  "keywords": [
6
6
  "ci",
@@ -37,13 +37,13 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "@theholocron/datapad": "5.0.0-alpha.8"
40
+ "@theholocron/datapad": "5.0.0-alpha.81"
41
41
  },
42
42
  "devDependencies": {
43
- "@theholocron/eslint-config": "^8.0.0",
44
- "@theholocron/tsconfig": "^8.0.0",
45
- "@theholocron/tsdown-config": "^8.0.0",
46
- "@theholocron/vitest-config": "^8.0.0",
43
+ "@theholocron/eslint-config": "^8.4.5",
44
+ "@theholocron/tsconfig": "^8.4.5",
45
+ "@theholocron/tsdown-config": "^8.4.5",
46
+ "@theholocron/vitest-config": "^8.4.5",
47
47
  "@types/node": "^26",
48
48
  "@vitest/coverage-v8": "^4.1.11",
49
49
  "@vitest/eslint-plugin": "^1.6.27",
@@ -53,7 +53,7 @@
53
53
  "tsdown": "^0.22.14",
54
54
  "typescript": "^5.9.3",
55
55
  "vitest": "^4.1.11",
56
- "@theholocron/rollup-plugin-transform-template": "5.0.0-alpha.8"
56
+ "@theholocron/rollup-plugin-transform-template": "5.0.0-alpha.81"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"