@theholocron/astromech 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,8 +91,15 @@ const astromech = createAstromech({ cwd, config, orgContext: { org, domain } });
91
91
  astromech.thinCallers(); // Map<"<name>.yml", yaml> — one per templated, ci-enabled task
92
92
  astromech.packageScripts(); // { holocron: "holocron", lint: "holocron run lint", … }
93
93
  astromech.requiredChecks(); // ["Lint / Conclusion", "codecov/patch", …] — branch-protection contexts
94
+ astromech.ci({ scope: "required" }); // CiReport — run the gating checks locally, in CI order
94
95
  ```
95
96
 
97
+ `ci()` runs every `required: true` task (else every `ci: true` task) through the
98
+ same resolution as `run()`, in `CI_ORDER`, and returns `{ status, jobs }`. A
99
+ `required` task whose local runner can't run is a failure; `local: null` tasks
100
+ (`audit` / `codeql` / `deploy`) are reported skipped. `holocron ci` sets the
101
+ process exit code from `status`.
102
+
96
103
  `thinCallers()` returns the raw `.github/workflows/*.yml` content (no
97
104
  generated-by header — the caller prefixes its own). `deploy` with
98
105
  `preview:` shorthand produces the combined push-to-Pages / PR-to-preview
package/dist/index.d.mts CHANGED
@@ -10,7 +10,8 @@ import { r as TasksConfig } from "./schema-uPSn69gZ.mjs";
10
10
  * 2. package.json has a `<task>` script → `<pm> run <task>`
11
11
  * (unless it's the `holocron run …` thin caller — that recurses)
12
12
  * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
13
- * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
13
+ * 4. TASKS[task].local === null → "enforced in CI" (skip, even with --required)
14
+ * 4b. known task, nothing resolved → "no <task> task" (exit 0, or 1 with --required)
14
15
  * 5. unknown task → "unknown task" (exit 1)
15
16
  *
16
17
  * `lint` runs the resolved linter set (`config.tasks` `linters`, else
@@ -58,6 +59,8 @@ interface RunTaskInput extends RunDeps {
58
59
  required?: boolean;
59
60
  /** The `lint` task's explicit linter list from `config.tasks`, if any. */
60
61
  linters?: string[];
62
+ /** `turbo --filter=<pkg>` passthrough (monorepo). Ignored when the repo has no `turbo.json`. */
63
+ filter?: string;
61
64
  }
62
65
  interface RunTaskReport {
63
66
  status: "ok" | "fail" | "skip" | "dry-run" | "unknown";
@@ -67,6 +70,35 @@ interface RunTaskReport {
67
70
  }
68
71
  declare function runTask(input: RunTaskInput): RunTaskReport;
69
72
  //#endregion
73
+ //#region src/ci.d.ts
74
+ interface CiOptions {
75
+ /** Print the plan without running anything. */
76
+ dryRun?: boolean;
77
+ /** `turbo --filter=<pkg>` passthrough (monorepo). */
78
+ filter?: string;
79
+ /** `"required"` (default) or `"all"` (every `ci: true` task). */
80
+ scope?: "required" | "all";
81
+ }
82
+ interface CiJobReport {
83
+ task: string;
84
+ /** The branch-protection check context, or `null` for a non-gating task. */
85
+ checkContext: string | null;
86
+ status: "ok" | "fail" | "skip" | "dry-run";
87
+ command?: string;
88
+ message?: string;
89
+ }
90
+ interface CiReport {
91
+ status: "ok" | "fail";
92
+ jobs: CiJobReport[];
93
+ }
94
+ interface CiInput extends RunDeps, CiOptions {
95
+ cwd: string;
96
+ config: TasksConfig;
97
+ /** Explicit linter list for the `lint` task (the config's lint entry `linters`). */
98
+ linters?: string[];
99
+ }
100
+ declare function runCi(input: CiInput): CiReport;
101
+ //#endregion
70
102
  //#region src/super-linter.d.ts
71
103
  /**
72
104
  * `superLinterConfig()` — turn the resolved linter set into the exact
@@ -264,10 +296,18 @@ interface RunOptions {
264
296
  dryRun?: boolean;
265
297
  /** Fail (exit 1) instead of skipping when the repo has no such task. */
266
298
  required?: boolean;
299
+ /** `turbo --filter=<pkg>` passthrough (monorepo). */
300
+ filter?: string;
267
301
  }
268
302
  interface Astromech {
269
303
  /** Run one task locally. */
270
304
  run(task: string, opts?: RunOptions): RunTaskReport;
305
+ /**
306
+ * Run the merge-gating checks locally, in CI order — "will CI pass?".
307
+ * Default scope: `required: true` tasks (falls back to every `ci: true`
308
+ * task when nothing is marked required). Exit non-zero on any failure.
309
+ */
310
+ ci(opts?: CiOptions): CiReport;
271
311
  /**
272
312
  * The `.github/workflows/*.yml` thin callers for this repo's manifest —
273
313
  * `filename` → YAML content (no generated-by header; the caller adds it).
@@ -395,8 +435,11 @@ interface LocalRunner {
395
435
  }
396
436
  interface TaskDef {
397
437
  /**
398
- * `null` — no local equivalent (CodeQL, deploys). `holocron ci` reports
399
- * it as skipped; `holocron run` treats it as "nothing to do".
438
+ * `null` — the registry has no built-in runner (CodeQL, deploys, audit's
439
+ * server / baseline jobs). An explicit turbo task or `package.json` script
440
+ * still runs (resolution steps 1–2); with neither, `holocron run` does
441
+ * nothing and `holocron ci` skips it — never a failure, even when the task
442
+ * is `required` (a CI-only check isn't a local one).
400
443
  */
401
444
  local: LocalRunner | null;
402
445
  /** Sub-jobs, keyed by slug — `holocron run audit performance`. */
@@ -427,4 +470,4 @@ declare const CI_ORDER: string[];
427
470
  /** Ordered (task contexts in {@link CI_ORDER}, then extras), de-duplicated. */
428
471
  declare function requiredChecks(config: TasksConfig): string[];
429
472
  //#endregion
430
- export { type Astromech, type AstromechOptions, CI_ORDER, type ExecFn, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, type LinterDef, type LocalRunner, type OrgContext, type PreviewConfig, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, type SuperLinterConfig, TASKS, type TaskDef, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, requiredChecks, resolveLinters, runTask, superLinterConfig };
473
+ export { type Astromech, type AstromechOptions, CI_ORDER, type CiJobReport, type CiOptions, type CiReport, type ExecFn, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, type LinterDef, type LocalRunner, type OrgContext, type PreviewConfig, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, type SuperLinterConfig, TASKS, type TaskDef, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, requiredChecks, resolveLinters, runCi, runTask, superLinterConfig };
package/dist/index.mjs CHANGED
@@ -67,297 +67,6 @@ const CI_ORDER = [
67
67
  "deploy"
68
68
  ];
69
69
  //#endregion
70
- //#region src/thin-callers.ts
71
- /**
72
- * Workflow templates + thin-caller generation.
73
- *
74
- * `WORKFLOW_TEMPLATES` holds each reusable `.github/workflows/<name>.yml`
75
- * (synced to `theholocron/.github`); `generateThinCallerContent` wraps one
76
- * into the thin caller `holocron setup` / `holocron sync` write locally.
77
- */
78
- const WORKFLOW_TEMPLATES = {
79
- lint: "name: Lint\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: lint-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n issues: write\n statuses: write\n\njobs:\n lint:\n name: Lint\n uses: theholocron/.github/.github/workflows/lint.yml@main\n secrets: inherit\n",
80
- test: "name: Test\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: test-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n id-token: write\n statuses: write\n\njobs:\n test:\n name: Test\n uses: theholocron/.github/.github/workflows/test.yml@main\n with:\n run-unit: true\n secrets: inherit\n",
81
- typecheck: "name: Typecheck\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: typecheck-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n typecheck:\n name: Typecheck\n uses: theholocron/.github/.github/workflows/typecheck.yml@main\n secrets: inherit\n",
82
- security: "name: Security\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n schedule:\n - cron: \"0 0 * * 1\"\n\npermissions:\n actions: read\n contents: read\n security-events: write\n\njobs:\n security:\n uses: theholocron/.github/.github/workflows/security.yml@main\n secrets: inherit\n",
83
- 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",
84
- 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: Review\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n",
85
- release: "name: Release\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n - alpha\n workflow_dispatch:\n inputs:\n dry_run:\n description: >\n Dry run — analyze commits and preview the release without git writes\n or publish. Push-triggered runs always run fully; this only applies\n to manual workflow_dispatch triggers.\n required: false\n default: true\n type: boolean\n\npermissions:\n contents: write\n id-token: write\n issues: write\n pull-requests: write\n\nconcurrency:\n group: ${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: false\n\njobs:\n release:\n uses: theholocron/.github/.github/workflows/release.yml@main\n with:\n dry-run: ${{ inputs.dry_run == true }}\n secrets: inherit\n",
86
- 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",
87
- sync: "name: Sync\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n paths:\n - holocron.config.ts\n - package.json\n - pnpm-workspace.yaml\n workflow_dispatch:\n inputs:\n steps:\n description: \"Sync steps to run (default: all)\"\n type: string\n required: false\n\nconcurrency:\n group: sync-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n sync:\n name: Sync\n uses: theholocron/.github/.github/workflows/sync.yml@main\n with:\n steps: ${{ inputs.steps }}\n secrets: inherit\n",
88
- greetings: "name: Greetings\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 uses: theholocron/.github/.github/workflows/greetings.yml@main\n secrets: inherit\n",
89
- dependencies: "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n dependencies:\n uses: theholocron/.github/.github/workflows/dependencies.yml@main\n secrets: inherit\n",
90
- bookkeeping: "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n pull_request:\n types:\n - opened\n - edited\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\n\njobs:\n bookkeeping:\n uses: theholocron/.github/.github/workflows/bookkeeping.yml@main\n secrets: inherit\n",
91
- audit: "name: Audit\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\npermissions:\n contents: read\n\njobs:\n audit:\n uses: theholocron/.github/.github/workflows/audit.yml@main\n secrets: inherit\n",
92
- deploy: "name: Deploy\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n workflow_dispatch:\n\nconcurrency:\n group: pages\n cancel-in-progress: false\n\npermissions:\n contents: read\n pages: write\n id-token: write\n\njobs:\n deploy:\n name: Deploy\n uses: theholocron/.github/.github/workflows/deploy.yml@main\n secrets: inherit\n",
93
- wiki: "name: Wiki\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/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/wiki.yml@main\n with:\n preview: true\n preview-id: pr-${{ github.event.pull_request.number }}\n secrets: inherit\n"
94
- };
95
- const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
96
- /**
97
- * The GitHub status-check context a `required` task contributes to branch
98
- * protection. Format: `"{workflow name} / {job name}"`.
99
- *
100
- * These name the **aggregate `Conclusion` job** (fan-in, `if: always()`), not
101
- * an individual inner job — `test` has several conditionally-run sub-jobs, so
102
- * `"Test / Conclusion"` is the only stable gate. Only merge-gating workflows
103
- * are listed. `astro.requiredChecks()` reads this for every `required` task.
104
- */
105
- const WORKFLOW_CHECK_CONTEXTS = {
106
- lint: "Lint / Conclusion",
107
- test: "Test / Conclusion",
108
- typecheck: "Typecheck / Conclusion",
109
- audit: "audit / Conclusion"
110
- };
111
- /**
112
- * Generate the thin caller content for a workflow, optionally injecting or
113
- * merging `with:` overrides into the jobs block.
114
- *
115
- * Two strategies are used depending on the template:
116
- * - Templates that already have a `with:` block (e.g. lint):
117
- * the override entries are merged in, replacing existing keys and appending
118
- * new ones.
119
- * - Templates that end with ` secrets: inherit`: a new `with:` block is
120
- * injected immediately before `secrets: inherit`.
121
- * If neither pattern matches the template, a warning is emitted and the
122
- * base template is returned unchanged.
123
- */
124
- function generateThinCallerContent(name, withOverrides, additionalPaths, logger, comments) {
125
- const base = WORKFLOW_TEMPLATES[name];
126
- if (!base) return "";
127
- const yamlScalar = (v) => {
128
- if (v === true) return "true";
129
- if (v === false) return "false";
130
- const s = String(v);
131
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
132
- };
133
- const fmt = (k, v) => {
134
- const line = ` ${k}: ${yamlScalar(v)}`;
135
- return comments?.[k] ? ` # ${comments[k]}\n${line}` : line;
136
- };
137
- let result = base;
138
- if (additionalPaths && additionalPaths.length > 0) {
139
- const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
140
- if (pathsBlockRe.test(result)) result = result.replace(pathsBlockRe, (_, header, existing) => {
141
- const existingPaths = new Set([...existing.matchAll(/- (.+)/g)].map((m) => m[1]));
142
- const newEntries = additionalPaths.filter((p) => !existingPaths.has(p)).map((p) => ` - ${p}\n`).join("");
143
- return header + existing + newEntries;
144
- });
145
- else {
146
- const pathsBlock = ` paths:\n${additionalPaths.map((p) => ` - ${p}\n`).join("")}`;
147
- result = result.replace(/( {4}branches: \[main\]\n)/, `$1${pathsBlock}`);
148
- }
149
- }
150
- if (!withOverrides || Object.keys(withOverrides).length === 0) return result;
151
- const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
152
- const existingMatch = result.match(withBlockRe);
153
- if (existingMatch) {
154
- const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).filter((line) => !line.trim().startsWith("#")).map((line) => {
155
- const m = line.match(/^ {6}([^:]+):\s*(.*)/);
156
- return m ? [m[1].trim(), m[2].trim()] : null;
157
- }).filter((e) => e !== null));
158
- for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, yamlScalar(v));
159
- const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
160
- return result.replace(withBlockRe, ` with:\n${merged}\n`);
161
- }
162
- const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
163
- const injected = result.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
164
- if (injected === result) logger?.warn({ template: name }, "generateThinCallerContent: could not inject `with:` overrides");
165
- return injected;
166
- }
167
- /**
168
- * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
169
- *
170
- * Accepts three forms:
171
- * - `preview: true` — derive both project and domain from org context
172
- * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
173
- * - `preview: { project: "...", domain: "..." }` — fully explicit
174
- *
175
- * Returns null when `preview:` is absent, false, or can't be resolved.
176
- */
177
- function extractPreviewConfig(raw, ctx = {}) {
178
- const preview = raw["preview"];
179
- if (!preview) return null;
180
- if (preview === true) {
181
- const project = ctx.org ? `${ctx.org}-preview` : null;
182
- const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
183
- if (!project) return null;
184
- return {
185
- project,
186
- ...domain ? { domain } : {}
187
- };
188
- }
189
- if (typeof preview !== "object") return null;
190
- const p = preview;
191
- const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
192
- if (!project) return null;
193
- const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
194
- return {
195
- project,
196
- ...domain ? { domain } : {}
197
- };
198
- }
199
- /**
200
- * Generate the full thin-caller YAML for a `deploy.yml` that handles both
201
- * production (push to main → GitHub Pages) and preview (pull_request →
202
- * Cloudflare Pages) in a single file.
203
- *
204
- * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
205
- * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
206
- * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
207
- * all repos with a `deploy` workflow get previews without per-repo config.
208
- */
209
- function generateCombinedDeployContent(deployWith, paths, preview) {
210
- const yamlScalar = (v) => {
211
- if (v === true) return "true";
212
- if (v === false) return "false";
213
- const s = String(v);
214
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
215
- };
216
- const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
217
- const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
218
- const previewWith = {
219
- ...deployWith,
220
- "cloudflare-project": preview.project
221
- };
222
- const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
223
- const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
224
- return [
225
- `name: Deploy`,
226
- ``,
227
- `on: # yamllint disable-line rule:truthy`,
228
- ` push:`,
229
- ` branches: [main]`,
230
- ...pathsBlock ? [`${pathsBlock}`] : [],
231
- ` pull_request:`,
232
- ` branches: [main]`,
233
- ` types: [opened, synchronize, reopened, closed]`,
234
- ...pathsBlock ? [`${pathsBlock}`] : [],
235
- ` workflow_dispatch:`,
236
- ``,
237
- `concurrency:`,
238
- ` group: $\{{ github.event_name == 'pull_request' && format('preview-{0}', github.event.pull_request.number) || 'pages' }}`,
239
- ` cancel-in-progress: $\{{ github.event_name == 'pull_request' && github.event.action != 'closed' }}`,
240
- ``,
241
- `permissions:`,
242
- ` contents: read`,
243
- ` deployments: write`,
244
- ` pages: write`,
245
- ` id-token: write`,
246
- ` pull-requests: write`,
247
- ``,
248
- `jobs:`,
249
- ` deploy:`,
250
- ` name: Deploy`,
251
- ` if: \${{ github.event_name != 'pull_request' }}`,
252
- ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
253
- ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
254
- ` secrets: inherit`,
255
- ``,
256
- ` preview:`,
257
- ` name: Preview`,
258
- ` if: \${{ github.event_name == 'pull_request' }}`,
259
- ` uses: theholocron/.github/.github/workflows/preview.yml@main`,
260
- previewWithBlock.trimEnd(),
261
- ` secrets: inherit`,
262
- ``
263
- ].join("\n");
264
- }
265
- /**
266
- * Expand structured with-values to flat GitHub Actions inputs before
267
- * generating the thin caller. Handles:
268
- * - deploy shorthand: docs/storybook → type + storybook-projects
269
- * - preview: stripped (handled separately via extractPreviewConfig)
270
- * - run-chromatic object → run-chromatic: true + chromatic-projects
271
- * - plain arrays → JSON-stringified for YAML scalar quoting
272
- *
273
- * Used by both `holocron setup` and `sync-workflow-templates`.
274
- */
275
- function normalizeWorkflowWith(raw) {
276
- const result = { ...raw };
277
- delete result["preview"];
278
- const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
279
- const storybookProjects = raw["storybook"];
280
- if (hasDocs) {
281
- result["type"] = "docs";
282
- delete result["docs"];
283
- }
284
- if (Array.isArray(storybookProjects)) {
285
- if (!hasDocs) result["type"] = "storybook";
286
- result["storybook-projects"] = JSON.stringify(storybookProjects.map(({ name, path = "." }) => ({
287
- name,
288
- workingDir: path
289
- })));
290
- delete result["storybook"];
291
- }
292
- const runChromatic = raw["run-chromatic"];
293
- if (runChromatic !== null && typeof runChromatic === "object" && "projects" in runChromatic) {
294
- result["run-chromatic"] = true;
295
- const projects = runChromatic.projects.map((p) => ({
296
- ...p,
297
- ...Array.isArray(p.untraced) ? { untraced: p.untraced.join("\n") } : {}
298
- }));
299
- result["chromatic-projects"] = JSON.stringify(projects);
300
- }
301
- for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) result[k] = JSON.stringify(v);
302
- return result;
303
- }
304
- /**
305
- * Derive on.push.paths entries from the deploy with: shorthand.
306
- * Used by both `holocron setup` and `sync-workflow-templates`.
307
- */
308
- function deriveDeployPaths(raw) {
309
- const paths = [];
310
- const docs = raw["docs"];
311
- if (docs === true) {
312
- paths.push("docs/**");
313
- paths.push("astro.config.ts");
314
- paths.push("pnpm-workspace.yaml");
315
- paths.push("pnpm-lock.yaml");
316
- } else if (docs !== null && typeof docs === "object" && "path" in docs) {
317
- const p = docs.path;
318
- if (p && p !== ".") paths.push(`${p}/**`);
319
- }
320
- const storybookProjects = raw["storybook"];
321
- if (Array.isArray(storybookProjects)) for (const s of storybookProjects) {
322
- const p = s.path || ".";
323
- if (p === ".") {
324
- paths.push("src/**");
325
- paths.push(".storybook/**");
326
- } else paths.push(`${p}/**`);
327
- }
328
- return paths;
329
- }
330
- //#endregion
331
- //#region src/required-checks.ts
332
- /**
333
- * `requiredChecks(config)` — the branch-protection required-status-check list,
334
- * derived from the task manifest. Every `required: true` task contributes its
335
- * {@link WORKFLOW_CHECK_CONTEXTS} entry; `config.extraRequiredChecks` adds
336
- * contexts not backed by a task (codecov, DCO is prepended by the caller).
337
- *
338
- * `@theholocron/cli`'s `holocron setup` calls this instead of the old
339
- * hand-maintained `repo.requiredChecks` array. Policy-free — it only knows the
340
- * manifest.
341
- */
342
- /** Ordered (task contexts in {@link CI_ORDER}, then extras), de-duplicated. */
343
- function requiredChecks(config) {
344
- const entries = (config.tasks ?? []).map(normalizeTaskEntry);
345
- const seen = /* @__PURE__ */ new Set();
346
- const out = [];
347
- for (const name of CI_ORDER) {
348
- const context = entries.some((e) => e.name === name && e.required === true) ? WORKFLOW_CHECK_CONTEXTS[name] : void 0;
349
- if (context && !seen.has(context)) {
350
- seen.add(context);
351
- out.push(context);
352
- }
353
- }
354
- for (const context of config.extraRequiredChecks ?? []) if (!seen.has(context)) {
355
- seen.add(context);
356
- out.push(context);
357
- }
358
- return out;
359
- }
360
- //#endregion
361
70
  //#region src/linters.ts
362
71
  /**
363
72
  * Known linters, in execution order. `always` entries are the current
@@ -476,258 +185,636 @@ function resolveLinters(opts) {
476
185
  def: LINTERS[name]
477
186
  }));
478
187
  }
479
- const present = new Set(opts.rootFiles);
480
- return order.filter((name) => {
481
- const def = LINTERS[name];
482
- return def.always === true || def.detect.some((f) => present.has(f));
483
- }).map((name) => ({
484
- name,
485
- def: LINTERS[name]
486
- }));
188
+ const present = new Set(opts.rootFiles);
189
+ return order.filter((name) => {
190
+ const def = LINTERS[name];
191
+ return def.always === true || def.detect.some((f) => present.has(f));
192
+ }).map((name) => ({
193
+ name,
194
+ def: LINTERS[name]
195
+ }));
196
+ }
197
+ //#endregion
198
+ //#region src/run.ts
199
+ /**
200
+ * `holocron run <task> [-- <passthrough>]` — run a registry task locally.
201
+ *
202
+ * Resolution:
203
+ *
204
+ * 0. task === "lint" → the linter aggregate (see below)
205
+ * 1. turbo.json defines the task → `turbo run <task>`
206
+ * 2. package.json has a `<task>` script → `<pm> run <task>`
207
+ * (unless it's the `holocron run …` thin caller — that recurses)
208
+ * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
209
+ * 4. TASKS[task].local === null → "enforced in CI" (skip, even with --required)
210
+ * 4b. known task, nothing resolved → "no <task> task" (exit 0, or 1 with --required)
211
+ * 5. unknown task → "unknown task" (exit 1)
212
+ *
213
+ * `lint` runs the resolved linter set (`config.tasks` `linters`, else
214
+ * auto-detected): the eslint slot goes through the standard turbo / script /
215
+ * `eslint .` resolution (so turbo caching is kept); every other linter runs
216
+ * its `localBin` when found on PATH. Missing tools are flagged; the exit code
217
+ * is the worst of the lot.
218
+ */
219
+ /** The per-command runner — `runTask` and `runLintAggregate` share it. */
220
+ function makeRunOne(input) {
221
+ const { print, logger, exec, task, cwd } = input;
222
+ const dryRun = input.dryRun ?? false;
223
+ return (cmd, args) => {
224
+ const command = [cmd, ...args].join(" ");
225
+ if (dryRun) {
226
+ print(`would run: ${command}`);
227
+ logger.debug({
228
+ task,
229
+ command,
230
+ status: "dry-run"
231
+ }, `run: ${task}`);
232
+ return {
233
+ status: "dry-run",
234
+ command
235
+ };
236
+ }
237
+ print(`→ ${command}`);
238
+ const { exitCode } = exec(cmd, args, { cwd });
239
+ const status = exitCode === 0 ? "ok" : "fail";
240
+ logger[status === "fail" ? "warn" : "debug"]({
241
+ task,
242
+ command,
243
+ exitCode,
244
+ status
245
+ }, `run: ${task}`);
246
+ return {
247
+ status,
248
+ command,
249
+ ...status === "fail" ? { message: `\`${command}\` exited ${exitCode}` } : {}
250
+ };
251
+ };
252
+ }
253
+ function runTask(input) {
254
+ const { print, logger, readFile, fileExists, listDir, task, cwd } = input;
255
+ const passthrough = input.passthrough ?? [];
256
+ const run = makeRunOne(input);
257
+ if (task === "lint") return runLintAggregate(input);
258
+ if (turboDefinesTask(cwd, task, readFile, fileExists)) {
259
+ const args = [
260
+ "run",
261
+ task,
262
+ ...input.filter ? [`--filter=${input.filter}`] : [],
263
+ ...passthrough.length ? ["--", ...passthrough] : []
264
+ ];
265
+ return run(resolveBin(cwd, "turbo", fileExists), args);
266
+ }
267
+ const def = TASKS[task];
268
+ const script = packageJsonScript(cwd, task, readFile, fileExists);
269
+ if (script && !/^holocron run\b/.test(script.trim())) return run(packageManager(cwd, readFile, fileExists), [
270
+ "run",
271
+ task,
272
+ ...passthrough.length ? ["--", ...passthrough] : []
273
+ ]);
274
+ if (def?.local) {
275
+ if (def.local.command) return run(process.execPath, [
276
+ process.argv[1],
277
+ def.local.command,
278
+ ...passthrough
279
+ ]);
280
+ const runner = resolveRunner(def.local, cwd, listDir);
281
+ if (runner) {
282
+ const flags = def.flags?.[runner.tool] ?? [];
283
+ return run(resolveBin(cwd, runner.tool, fileExists), [
284
+ ...runner.args,
285
+ ...flags,
286
+ ...passthrough
287
+ ]);
288
+ }
289
+ }
290
+ if (def?.local === null) {
291
+ const msg = `no local equivalent for ${task} — enforced in CI`;
292
+ print(`· ${msg}`);
293
+ logger.debug({
294
+ task,
295
+ status: "skip"
296
+ }, `run: ${task}`);
297
+ return {
298
+ status: "skip",
299
+ message: msg
300
+ };
301
+ }
302
+ if (KNOWN_TASKS.has(task)) {
303
+ const msg = `no ${task} task for this repo`;
304
+ print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
305
+ logger[input.required ? "warn" : "debug"]({
306
+ task,
307
+ status: input.required ? "fail" : "skip"
308
+ }, `run: ${task}`);
309
+ return {
310
+ status: input.required ? "fail" : "skip",
311
+ message: msg
312
+ };
313
+ }
314
+ print(`✗ unknown task "${task}"`);
315
+ logger.warn({
316
+ task,
317
+ status: "unknown"
318
+ }, `run: ${task}`);
319
+ return {
320
+ status: "unknown",
321
+ message: `unknown task "${task}"`
322
+ };
323
+ }
324
+ /**
325
+ * `holocron run lint` — the resolved linter set, run natively. The eslint
326
+ * slot reuses turbo / the `lint` script / `eslint .`; the rest run their
327
+ * `localBin` when it resolves on PATH. Worst exit code wins.
328
+ */
329
+ function runLintAggregate(input) {
330
+ const { print, logger, readFile, fileExists, listDir, lookPath, cwd } = input;
331
+ const passthrough = input.passthrough ?? [];
332
+ const dryRun = input.dryRun ?? false;
333
+ const runOne = makeRunOne(input);
334
+ const pass = passthrough.length ? ["--", ...passthrough] : [];
335
+ let rootFiles;
336
+ try {
337
+ rootFiles = listDir(cwd);
338
+ } catch {
339
+ rootFiles = [];
340
+ }
341
+ const resolved = resolveLinters({
342
+ explicit: input.linters,
343
+ rootFiles
344
+ });
345
+ const reports = [];
346
+ /** Run one linter's `localBin` natively, or flag it. */
347
+ const runLinter = (name, bin, args, hint) => {
348
+ if (!bin) {
349
+ print(`· ${name} (CI only)`);
350
+ return;
351
+ }
352
+ const found = lookPath(cwd, bin);
353
+ if (!found) {
354
+ print(`! ${name} — ${bin} not on PATH${hint ? `. ${hint}` : ""} (enforced in CI)`);
355
+ return;
356
+ }
357
+ reports.push(runOne(found, [...args, ...passthrough]));
358
+ };
359
+ if (resolved.some((r) => r.name === "eslint")) {
360
+ const script = packageJsonScript(cwd, "lint", readFile, fileExists);
361
+ const filterArg = input.filter ? [`--filter=${input.filter}`] : [];
362
+ if (turboDefinesTask(cwd, "lint", readFile, fileExists)) reports.push(runOne(resolveBin(cwd, "turbo", fileExists), [
363
+ "run",
364
+ "lint",
365
+ ...filterArg,
366
+ ...pass
367
+ ]));
368
+ else if (script && !/^holocron run\b/.test(script.trim())) reports.push(runOne(packageManager(cwd, readFile, fileExists), [
369
+ "run",
370
+ "lint",
371
+ ...pass
372
+ ]));
373
+ else runLinter("eslint", "eslint", ["."]);
374
+ }
375
+ for (const { name, def } of resolved) {
376
+ if (name === "eslint") continue;
377
+ runLinter(name, def.localBin, def.localArgs ?? [], def.installHint);
378
+ }
379
+ if (reports.length === 0) {
380
+ const msg = "no lint tooling available locally — every resolved linter is CI-only here";
381
+ print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
382
+ logger[input.required ? "warn" : "debug"]({
383
+ task: "lint",
384
+ status: input.required ? "fail" : "skip"
385
+ }, "run: lint");
386
+ return {
387
+ status: input.required ? "fail" : "skip",
388
+ message: msg
389
+ };
390
+ }
391
+ const command = reports.map((r) => r.command).filter((c) => Boolean(c)).join(" && ");
392
+ if (dryRun) return {
393
+ status: "dry-run",
394
+ command
395
+ };
396
+ return reports.some((r) => r.status === "fail") ? {
397
+ status: "fail",
398
+ command,
399
+ message: "one or more linters failed"
400
+ } : {
401
+ status: "ok",
402
+ command
403
+ };
404
+ }
405
+ const mkRunner = (tool, args = []) => ({
406
+ tool,
407
+ args
408
+ });
409
+ /**
410
+ * Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
411
+ * files. Only called for `tool` / `detect` runners — the caller handles
412
+ * `command` runners itself, so `local.detect` is present whenever
413
+ * `local.tool` is not.
414
+ */
415
+ function resolveRunner(local, cwd, listDir) {
416
+ if (local.tool) return mkRunner(local.tool, local.args);
417
+ let files;
418
+ try {
419
+ files = listDir(cwd);
420
+ } catch {
421
+ return;
422
+ }
423
+ for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f))) return mkRunner(candidate.tool, candidate.args);
424
+ }
425
+ function resolveBin(cwd, tool, fileExists) {
426
+ const local = join(cwd, "node_modules", ".bin", tool);
427
+ return fileExists(local) ? local : tool;
428
+ }
429
+ function packageManager(cwd, readFile, fileExists) {
430
+ try {
431
+ const pkg = JSON.parse(readFile(join(cwd, "package.json")));
432
+ if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
433
+ } catch {}
434
+ if (fileExists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
435
+ if (fileExists(join(cwd, "bun.lockb"))) return "bun";
436
+ if (fileExists(join(cwd, "yarn.lock"))) return "yarn";
437
+ if (fileExists(join(cwd, "package-lock.json"))) return "npm";
438
+ return "pnpm";
439
+ }
440
+ function turboDefinesTask(cwd, task, readFile, fileExists) {
441
+ if (!fileExists(join(cwd, "turbo.json"))) return false;
442
+ try {
443
+ const turbo = JSON.parse(readFile(join(cwd, "turbo.json")));
444
+ return Boolean(turbo.tasks?.[task] ?? turbo.pipeline?.[task]);
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ function packageJsonScript(cwd, task, readFile, fileExists) {
450
+ if (!fileExists(join(cwd, "package.json"))) return void 0;
451
+ try {
452
+ return JSON.parse(readFile(join(cwd, "package.json"))).scripts?.[task];
453
+ } catch {
454
+ return;
455
+ }
487
456
  }
488
457
  //#endregion
489
- //#region src/run.ts
458
+ //#region src/thin-callers.ts
490
459
  /**
491
- * `holocron run <task> [-- <passthrough>]` — run a registry task locally.
460
+ * Workflow templates + thin-caller generation.
492
461
  *
493
- * Resolution:
462
+ * `WORKFLOW_TEMPLATES` holds each reusable `.github/workflows/<name>.yml`
463
+ * (synced to `theholocron/.github`); `generateThinCallerContent` wraps one
464
+ * into the thin caller `holocron setup` / `holocron sync` write locally.
465
+ */
466
+ const WORKFLOW_TEMPLATES = {
467
+ lint: "name: Lint\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: lint-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n issues: write\n statuses: write\n\njobs:\n lint:\n name: Lint\n uses: theholocron/.github/.github/workflows/lint.yml@main\n secrets: inherit\n",
468
+ test: "name: Test\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: test-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n id-token: write\n statuses: write\n\njobs:\n test:\n name: Test\n uses: theholocron/.github/.github/workflows/test.yml@main\n with:\n run-unit: true\n secrets: inherit\n",
469
+ typecheck: "name: Typecheck\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: typecheck-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n typecheck:\n name: Typecheck\n uses: theholocron/.github/.github/workflows/typecheck.yml@main\n secrets: inherit\n",
470
+ security: "name: Security\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n schedule:\n - cron: \"0 0 * * 1\"\n\npermissions:\n actions: read\n contents: read\n security-events: write\n\njobs:\n security:\n uses: theholocron/.github/.github/workflows/security.yml@main\n secrets: inherit\n",
471
+ 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",
472
+ 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: Review\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n",
473
+ release: "name: Release\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n - alpha\n workflow_dispatch:\n inputs:\n dry_run:\n description: >\n Dry run — analyze commits and preview the release without git writes\n or publish. Push-triggered runs always run fully; this only applies\n to manual workflow_dispatch triggers.\n required: false\n default: true\n type: boolean\n\npermissions:\n contents: write\n id-token: write\n issues: write\n pull-requests: write\n\nconcurrency:\n group: ${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: false\n\njobs:\n release:\n uses: theholocron/.github/.github/workflows/release.yml@main\n with:\n dry-run: ${{ inputs.dry_run == true }}\n secrets: inherit\n",
474
+ 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",
475
+ sync: "name: Sync\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n paths:\n - holocron.config.ts\n - package.json\n - pnpm-workspace.yaml\n workflow_dispatch:\n inputs:\n steps:\n description: \"Sync steps to run (default: all)\"\n type: string\n required: false\n\nconcurrency:\n group: sync-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n sync:\n name: Sync\n uses: theholocron/.github/.github/workflows/sync.yml@main\n with:\n steps: ${{ inputs.steps }}\n secrets: inherit\n",
476
+ greetings: "name: Greetings\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 uses: theholocron/.github/.github/workflows/greetings.yml@main\n secrets: inherit\n",
477
+ dependencies: "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n dependencies:\n uses: theholocron/.github/.github/workflows/dependencies.yml@main\n secrets: inherit\n",
478
+ bookkeeping: "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n pull_request:\n types:\n - opened\n - edited\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\n\njobs:\n bookkeeping:\n uses: theholocron/.github/.github/workflows/bookkeeping.yml@main\n secrets: inherit\n",
479
+ audit: "name: Audit\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\npermissions:\n contents: read\n\njobs:\n audit:\n uses: theholocron/.github/.github/workflows/audit.yml@main\n secrets: inherit\n",
480
+ deploy: "name: Deploy\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n workflow_dispatch:\n\nconcurrency:\n group: pages\n cancel-in-progress: false\n\npermissions:\n contents: read\n pages: write\n id-token: write\n\njobs:\n deploy:\n name: Deploy\n uses: theholocron/.github/.github/workflows/deploy.yml@main\n secrets: inherit\n",
481
+ wiki: "name: Wiki\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/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/wiki.yml@main\n with:\n preview: true\n preview-id: pr-${{ github.event.pull_request.number }}\n secrets: inherit\n"
482
+ };
483
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
484
+ /**
485
+ * The GitHub status-check context a `required` task contributes to branch
486
+ * protection. Format: `"{workflow name} / {job name}"`.
494
487
  *
495
- * 0. task === "lint" → the linter aggregate (see below)
496
- * 1. turbo.json defines the task → `turbo run <task>`
497
- * 2. package.json has a `<task>` script → `<pm> run <task>`
498
- * (unless it's the `holocron run …` thin caller — that recurses)
499
- * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
500
- * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
501
- * 5. unknown task → "unknown task" (exit 1)
488
+ * These name the **aggregate `Conclusion` job** (fan-in, `if: always()`), not
489
+ * an individual inner job — `test` has several conditionally-run sub-jobs, so
490
+ * `"Test / Conclusion"` is the only stable gate. Only merge-gating workflows
491
+ * are listed. `astro.requiredChecks()` reads this for every `required` task.
492
+ */
493
+ const WORKFLOW_CHECK_CONTEXTS = {
494
+ lint: "Lint / Conclusion",
495
+ test: "Test / Conclusion",
496
+ typecheck: "Typecheck / Conclusion",
497
+ audit: "audit / Conclusion"
498
+ };
499
+ /**
500
+ * Generate the thin caller content for a workflow, optionally injecting or
501
+ * merging `with:` overrides into the jobs block.
502
502
  *
503
- * `lint` runs the resolved linter set (`config.tasks` `linters`, else
504
- * auto-detected): the eslint slot goes through the standard turbo / script /
505
- * `eslint .` resolution (so turbo caching is kept); every other linter runs
506
- * its `localBin` when found on PATH. Missing tools are flagged; the exit code
507
- * is the worst of the lot.
503
+ * Two strategies are used depending on the template:
504
+ * - Templates that already have a `with:` block (e.g. lint):
505
+ * the override entries are merged in, replacing existing keys and appending
506
+ * new ones.
507
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
508
+ * injected immediately before `secrets: inherit`.
509
+ * If neither pattern matches the template, a warning is emitted and the
510
+ * base template is returned unchanged.
508
511
  */
509
- /** The per-command runner — `runTask` and `runLintAggregate` share it. */
510
- function makeRunOne(input) {
511
- const { print, logger, exec, task, cwd } = input;
512
- const dryRun = input.dryRun ?? false;
513
- return (cmd, args) => {
514
- const command = [cmd, ...args].join(" ");
515
- if (dryRun) {
516
- print(`would run: ${command}`);
517
- logger.debug({
518
- task,
519
- command,
520
- status: "dry-run"
521
- }, `run: ${task}`);
522
- return {
523
- status: "dry-run",
524
- command
525
- };
526
- }
527
- print(`→ ${command}`);
528
- const { exitCode } = exec(cmd, args, { cwd });
529
- const status = exitCode === 0 ? "ok" : "fail";
530
- logger[status === "fail" ? "warn" : "debug"]({
531
- task,
532
- command,
533
- exitCode,
534
- status
535
- }, `run: ${task}`);
536
- return {
537
- status,
538
- command,
539
- ...status === "fail" ? { message: `\`${command}\` exited ${exitCode}` } : {}
540
- };
512
+ function generateThinCallerContent(name, withOverrides, additionalPaths, logger, comments) {
513
+ const base = WORKFLOW_TEMPLATES[name];
514
+ if (!base) return "";
515
+ const yamlScalar = (v) => {
516
+ if (v === true) return "true";
517
+ if (v === false) return "false";
518
+ const s = String(v);
519
+ return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
541
520
  };
542
- }
543
- function runTask(input) {
544
- const { print, logger, readFile, fileExists, listDir, task, cwd } = input;
545
- const passthrough = input.passthrough ?? [];
546
- const run = makeRunOne(input);
547
- if (task === "lint") return runLintAggregate(input);
548
- if (turboDefinesTask(cwd, task, readFile, fileExists)) {
549
- const args = [
550
- "run",
551
- task,
552
- ...passthrough.length ? ["--", ...passthrough] : []
553
- ];
554
- return run(resolveBin(cwd, "turbo", fileExists), args);
555
- }
556
- const def = TASKS[task];
557
- const script = packageJsonScript(cwd, task, readFile, fileExists);
558
- if (script && !/^holocron run\b/.test(script.trim())) return run(packageManager(cwd, readFile, fileExists), [
559
- "run",
560
- task,
561
- ...passthrough.length ? ["--", ...passthrough] : []
562
- ]);
563
- if (def?.local) {
564
- if (def.local.command) return run(process.execPath, [
565
- process.argv[1],
566
- def.local.command,
567
- ...passthrough
568
- ]);
569
- const runner = resolveRunner(def.local, cwd, listDir);
570
- if (runner) {
571
- const flags = def.flags?.[runner.tool] ?? [];
572
- return run(resolveBin(cwd, runner.tool, fileExists), [
573
- ...runner.args,
574
- ...flags,
575
- ...passthrough
576
- ]);
521
+ const fmt = (k, v) => {
522
+ const line = ` ${k}: ${yamlScalar(v)}`;
523
+ return comments?.[k] ? ` # ${comments[k]}\n${line}` : line;
524
+ };
525
+ let result = base;
526
+ if (additionalPaths && additionalPaths.length > 0) {
527
+ const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
528
+ if (pathsBlockRe.test(result)) result = result.replace(pathsBlockRe, (_, header, existing) => {
529
+ const existingPaths = new Set([...existing.matchAll(/- (.+)/g)].map((m) => m[1]));
530
+ const newEntries = additionalPaths.filter((p) => !existingPaths.has(p)).map((p) => ` - ${p}\n`).join("");
531
+ return header + existing + newEntries;
532
+ });
533
+ else {
534
+ const pathsBlock = ` paths:\n${additionalPaths.map((p) => ` - ${p}\n`).join("")}`;
535
+ result = result.replace(/( {4}branches: \[main\]\n)/, `$1${pathsBlock}`);
577
536
  }
578
537
  }
579
- if (KNOWN_TASKS.has(task) || def?.local === null) {
580
- const msg = `no ${task} task for this repo`;
581
- print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
582
- logger[input.required ? "warn" : "debug"]({
583
- task,
584
- status: input.required ? "fail" : "skip"
585
- }, `run: ${task}`);
538
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return result;
539
+ const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
540
+ const existingMatch = result.match(withBlockRe);
541
+ if (existingMatch) {
542
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).filter((line) => !line.trim().startsWith("#")).map((line) => {
543
+ const m = line.match(/^ {6}([^:]+):\s*(.*)/);
544
+ return m ? [m[1].trim(), m[2].trim()] : null;
545
+ }).filter((e) => e !== null));
546
+ for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, yamlScalar(v));
547
+ const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
548
+ return result.replace(withBlockRe, ` with:\n${merged}\n`);
549
+ }
550
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
551
+ const injected = result.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
552
+ if (injected === result) logger?.warn({ template: name }, "generateThinCallerContent: could not inject `with:` overrides");
553
+ return injected;
554
+ }
555
+ /**
556
+ * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
557
+ *
558
+ * Accepts three forms:
559
+ * - `preview: true` — derive both project and domain from org context
560
+ * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
561
+ * - `preview: { project: "...", domain: "..." }` — fully explicit
562
+ *
563
+ * Returns null when `preview:` is absent, false, or can't be resolved.
564
+ */
565
+ function extractPreviewConfig(raw, ctx = {}) {
566
+ const preview = raw["preview"];
567
+ if (!preview) return null;
568
+ if (preview === true) {
569
+ const project = ctx.org ? `${ctx.org}-preview` : null;
570
+ const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
571
+ if (!project) return null;
586
572
  return {
587
- status: input.required ? "fail" : "skip",
588
- message: msg
573
+ project,
574
+ ...domain ? { domain } : {}
589
575
  };
590
576
  }
591
- print(`✗ unknown task "${task}"`);
592
- logger.warn({
593
- task,
594
- status: "unknown"
595
- }, `run: ${task}`);
577
+ if (typeof preview !== "object") return null;
578
+ const p = preview;
579
+ const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
580
+ if (!project) return null;
581
+ const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
596
582
  return {
597
- status: "unknown",
598
- message: `unknown task "${task}"`
583
+ project,
584
+ ...domain ? { domain } : {}
585
+ };
586
+ }
587
+ /**
588
+ * Generate the full thin-caller YAML for a `deploy.yml` that handles both
589
+ * production (push to main → GitHub Pages) and preview (pull_request →
590
+ * Cloudflare Pages) in a single file.
591
+ *
592
+ * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
593
+ * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
594
+ * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
595
+ * all repos with a `deploy` workflow get previews without per-repo config.
596
+ */
597
+ function generateCombinedDeployContent(deployWith, paths, preview) {
598
+ const yamlScalar = (v) => {
599
+ if (v === true) return "true";
600
+ if (v === false) return "false";
601
+ const s = String(v);
602
+ return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
603
+ };
604
+ const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
605
+ const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
606
+ const previewWith = {
607
+ ...deployWith,
608
+ "cloudflare-project": preview.project
599
609
  };
610
+ const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
611
+ const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
612
+ return [
613
+ `name: Deploy`,
614
+ ``,
615
+ `on: # yamllint disable-line rule:truthy`,
616
+ ` push:`,
617
+ ` branches: [main]`,
618
+ ...pathsBlock ? [`${pathsBlock}`] : [],
619
+ ` pull_request:`,
620
+ ` branches: [main]`,
621
+ ` types: [opened, synchronize, reopened, closed]`,
622
+ ...pathsBlock ? [`${pathsBlock}`] : [],
623
+ ` workflow_dispatch:`,
624
+ ``,
625
+ `concurrency:`,
626
+ ` group: $\{{ github.event_name == 'pull_request' && format('preview-{0}', github.event.pull_request.number) || 'pages' }}`,
627
+ ` cancel-in-progress: $\{{ github.event_name == 'pull_request' && github.event.action != 'closed' }}`,
628
+ ``,
629
+ `permissions:`,
630
+ ` contents: read`,
631
+ ` deployments: write`,
632
+ ` pages: write`,
633
+ ` id-token: write`,
634
+ ` pull-requests: write`,
635
+ ``,
636
+ `jobs:`,
637
+ ` deploy:`,
638
+ ` name: Deploy`,
639
+ ` if: \${{ github.event_name != 'pull_request' }}`,
640
+ ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
641
+ ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
642
+ ` secrets: inherit`,
643
+ ``,
644
+ ` preview:`,
645
+ ` name: Preview`,
646
+ ` if: \${{ github.event_name == 'pull_request' }}`,
647
+ ` uses: theholocron/.github/.github/workflows/preview.yml@main`,
648
+ previewWithBlock.trimEnd(),
649
+ ` secrets: inherit`,
650
+ ``
651
+ ].join("\n");
600
652
  }
601
653
  /**
602
- * `holocron run lint` — the resolved linter set, run natively. The eslint
603
- * slot reuses turbo / the `lint` script / `eslint .`; the rest run their
604
- * `localBin` when it resolves on PATH. Worst exit code wins.
654
+ * Expand structured with-values to flat GitHub Actions inputs before
655
+ * generating the thin caller. Handles:
656
+ * - deploy shorthand: docs/storybook → type + storybook-projects
657
+ * - preview: stripped (handled separately via extractPreviewConfig)
658
+ * - run-chromatic object → run-chromatic: true + chromatic-projects
659
+ * - plain arrays → JSON-stringified for YAML scalar quoting
660
+ *
661
+ * Used by both `holocron setup` and `sync-workflow-templates`.
605
662
  */
606
- function runLintAggregate(input) {
607
- const { print, logger, readFile, fileExists, listDir, lookPath, cwd } = input;
608
- const passthrough = input.passthrough ?? [];
609
- const dryRun = input.dryRun ?? false;
610
- const runOne = makeRunOne(input);
611
- const pass = passthrough.length ? ["--", ...passthrough] : [];
612
- let rootFiles;
613
- try {
614
- rootFiles = listDir(cwd);
615
- } catch {
616
- rootFiles = [];
617
- }
618
- const resolved = resolveLinters({
619
- explicit: input.linters,
620
- rootFiles
621
- });
622
- const reports = [];
623
- /** Run one linter's `localBin` natively, or flag it. */
624
- const runLinter = (name, bin, args, hint) => {
625
- if (!bin) {
626
- print(`· ${name} (CI only)`);
627
- return;
628
- }
629
- const found = lookPath(cwd, bin);
630
- if (!found) {
631
- print(`! ${name} — ${bin} not on PATH${hint ? `. ${hint}` : ""} (enforced in CI)`);
632
- return;
633
- }
634
- reports.push(runOne(found, [...args, ...passthrough]));
635
- };
636
- if (resolved.some((r) => r.name === "eslint")) {
637
- const script = packageJsonScript(cwd, "lint", readFile, fileExists);
638
- if (turboDefinesTask(cwd, "lint", readFile, fileExists)) reports.push(runOne(resolveBin(cwd, "turbo", fileExists), [
639
- "run",
640
- "lint",
641
- ...pass
642
- ]));
643
- else if (script && !/^holocron run\b/.test(script.trim())) reports.push(runOne(packageManager(cwd, readFile, fileExists), [
644
- "run",
645
- "lint",
646
- ...pass
647
- ]));
648
- else runLinter("eslint", "eslint", ["."]);
663
+ function normalizeWorkflowWith(raw) {
664
+ const result = { ...raw };
665
+ delete result["preview"];
666
+ const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
667
+ const storybookProjects = raw["storybook"];
668
+ if (hasDocs) {
669
+ result["type"] = "docs";
670
+ delete result["docs"];
649
671
  }
650
- for (const { name, def } of resolved) {
651
- if (name === "eslint") continue;
652
- runLinter(name, def.localBin, def.localArgs ?? [], def.installHint);
672
+ if (Array.isArray(storybookProjects)) {
673
+ if (!hasDocs) result["type"] = "storybook";
674
+ result["storybook-projects"] = JSON.stringify(storybookProjects.map(({ name, path = "." }) => ({
675
+ name,
676
+ workingDir: path
677
+ })));
678
+ delete result["storybook"];
653
679
  }
654
- if (reports.length === 0) {
655
- const msg = "no lint tooling available locally — every resolved linter is CI-only here";
656
- print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
657
- logger[input.required ? "warn" : "debug"]({
658
- task: "lint",
659
- status: input.required ? "fail" : "skip"
660
- }, "run: lint");
661
- return {
662
- status: input.required ? "fail" : "skip",
663
- message: msg
664
- };
680
+ const runChromatic = raw["run-chromatic"];
681
+ if (runChromatic !== null && typeof runChromatic === "object" && "projects" in runChromatic) {
682
+ result["run-chromatic"] = true;
683
+ const projects = runChromatic.projects.map((p) => ({
684
+ ...p,
685
+ ...Array.isArray(p.untraced) ? { untraced: p.untraced.join("\n") } : {}
686
+ }));
687
+ result["chromatic-projects"] = JSON.stringify(projects);
665
688
  }
666
- const command = reports.map((r) => r.command).filter((c) => Boolean(c)).join(" && ");
667
- if (dryRun) return {
668
- status: "dry-run",
669
- command
670
- };
671
- return reports.some((r) => r.status === "fail") ? {
672
- status: "fail",
673
- command,
674
- message: "one or more linters failed"
675
- } : {
676
- status: "ok",
677
- command
678
- };
689
+ for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) result[k] = JSON.stringify(v);
690
+ return result;
679
691
  }
680
- const mkRunner = (tool, args = []) => ({
681
- tool,
682
- args
683
- });
684
692
  /**
685
- * Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
686
- * files. Only called for `tool` / `detect` runners — the caller handles
687
- * `command` runners itself, so `local.detect` is present whenever
688
- * `local.tool` is not.
693
+ * Derive on.push.paths entries from the deploy with: shorthand.
694
+ * Used by both `holocron setup` and `sync-workflow-templates`.
689
695
  */
690
- function resolveRunner(local, cwd, listDir) {
691
- if (local.tool) return mkRunner(local.tool, local.args);
692
- let files;
693
- try {
694
- files = listDir(cwd);
695
- } catch {
696
- return;
696
+ function deriveDeployPaths(raw) {
697
+ const paths = [];
698
+ const docs = raw["docs"];
699
+ if (docs === true) {
700
+ paths.push("docs/**");
701
+ paths.push("astro.config.ts");
702
+ paths.push("pnpm-workspace.yaml");
703
+ paths.push("pnpm-lock.yaml");
704
+ } else if (docs !== null && typeof docs === "object" && "path" in docs) {
705
+ const p = docs.path;
706
+ if (p && p !== ".") paths.push(`${p}/**`);
697
707
  }
698
- for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f))) return mkRunner(candidate.tool, candidate.args);
708
+ const storybookProjects = raw["storybook"];
709
+ if (Array.isArray(storybookProjects)) for (const s of storybookProjects) {
710
+ const p = s.path || ".";
711
+ if (p === ".") {
712
+ paths.push("src/**");
713
+ paths.push(".storybook/**");
714
+ } else paths.push(`${p}/**`);
715
+ }
716
+ return paths;
699
717
  }
700
- function resolveBin(cwd, tool, fileExists) {
701
- const local = join(cwd, "node_modules", ".bin", tool);
702
- return fileExists(local) ? local : tool;
718
+ //#endregion
719
+ //#region src/ci.ts
720
+ /**
721
+ * `holocron ci` / `astro.ci()` — run the merge-gating checks locally, in CI
722
+ * order, and exit non-zero on the first failure. The "will CI pass?" command a
723
+ * `pre-push` hook and the agent skills point at.
724
+ *
725
+ * Default scope: every `required: true` task. If the manifest marks nothing
726
+ * required (an un-migrated repo), fall back to every `ci: true` task. `--all`
727
+ * forces the full set. Each task runs through {@link runTask}: a `required`
728
+ * task whose local runner can't run is a failure; a `local: null` task with
729
+ * no turbo task / `package.json` script is skipped, never failed — even when
730
+ * `required` (it's a CI-only check, not a local one).
731
+ */
732
+ function runCi(input) {
733
+ const { print } = input;
734
+ const byName = /* @__PURE__ */ new Map();
735
+ for (const item of input.config?.tasks ?? []) byName.set(taskName(item), normalizeTaskEntry(item));
736
+ const gating = [...byName.values()].filter((e) => CI_ORDER.includes(e.name));
737
+ const all = gating.filter((e) => e.ci !== false);
738
+ const required = gating.filter((e) => e.required === true);
739
+ let selected = input.scope === "all" ? all : required;
740
+ const fellBack = input.scope !== "all" && selected.length === 0 && all.length > 0;
741
+ if (fellBack) selected = all;
742
+ const ordered = [...selected].sort((a, b) => CI_ORDER.indexOf(a.name) - CI_ORDER.indexOf(b.name));
743
+ print(`holocron ci — ${input.scope === "all" ? "all CI checks" : "required checks"} (${ordered.length})`);
744
+ if (fellBack) print(" (no required tasks in the manifest — running every CI task)");
745
+ print("");
746
+ const jobs = [];
747
+ for (const entry of ordered) {
748
+ const context = WORKFLOW_CHECK_CONTEXTS[entry.name] ?? null;
749
+ print(`▶ ${context ?? entry.name}`);
750
+ const r = runTask({
751
+ print: (line) => print(` ${line}`),
752
+ logger: input.logger,
753
+ exec: input.exec,
754
+ readFile: input.readFile,
755
+ fileExists: input.fileExists,
756
+ listDir: input.listDir,
757
+ lookPath: input.lookPath,
758
+ task: entry.name,
759
+ cwd: input.cwd,
760
+ dryRun: input.dryRun ?? false,
761
+ required: entry.required === true,
762
+ ...input.filter ? { filter: input.filter } : {},
763
+ ...entry.name === "lint" ? { linters: input.linters } : {}
764
+ });
765
+ const status = r.status;
766
+ jobs.push({
767
+ task: entry.name,
768
+ checkContext: context,
769
+ status,
770
+ command: r.command,
771
+ message: r.message
772
+ });
773
+ }
774
+ const failed = jobs.filter((j) => j.status === "fail").length;
775
+ const passed = jobs.filter((j) => j.status === "ok" || j.status === "dry-run").length;
776
+ const skipped = jobs.filter((j) => j.status === "skip").length;
777
+ print("");
778
+ if (ordered.length === 0) print("holocron ci — nothing to run");
779
+ else if (failed > 0) print(`✗ ${failed} failed, ${passed} passed${skipped ? `, ${skipped} skipped` : ""}`);
780
+ else print(`✓ ${passed} passed${skipped ? `, ${skipped} skipped` : ""}${input.dryRun ? " (plan only)" : ""}`);
781
+ return {
782
+ status: failed > 0 ? "fail" : "ok",
783
+ jobs
784
+ };
703
785
  }
704
- function packageManager(cwd, readFile, fileExists) {
705
- try {
706
- const pkg = JSON.parse(readFile(join(cwd, "package.json")));
707
- if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
708
- } catch {}
709
- if (fileExists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
710
- if (fileExists(join(cwd, "bun.lockb"))) return "bun";
711
- if (fileExists(join(cwd, "yarn.lock"))) return "yarn";
712
- if (fileExists(join(cwd, "package-lock.json"))) return "npm";
713
- return "pnpm";
786
+ function taskName(item) {
787
+ return typeof item === "string" ? item : item.name;
714
788
  }
715
- function turboDefinesTask(cwd, task, readFile, fileExists) {
716
- if (!fileExists(join(cwd, "turbo.json"))) return false;
717
- try {
718
- const turbo = JSON.parse(readFile(join(cwd, "turbo.json")));
719
- return Boolean(turbo.tasks?.[task] ?? turbo.pipeline?.[task]);
720
- } catch {
721
- return false;
789
+ //#endregion
790
+ //#region src/required-checks.ts
791
+ /**
792
+ * `requiredChecks(config)` — the branch-protection required-status-check list,
793
+ * derived from the task manifest. Every `required: true` task contributes its
794
+ * {@link WORKFLOW_CHECK_CONTEXTS} entry; `config.extraRequiredChecks` adds
795
+ * contexts not backed by a task (codecov, DCO is prepended by the caller).
796
+ *
797
+ * `@theholocron/cli`'s `holocron setup` calls this instead of the old
798
+ * hand-maintained `repo.requiredChecks` array. Policy-free — it only knows the
799
+ * manifest.
800
+ */
801
+ /** Ordered (task contexts in {@link CI_ORDER}, then extras), de-duplicated. */
802
+ function requiredChecks(config) {
803
+ const entries = (config.tasks ?? []).map(normalizeTaskEntry);
804
+ const seen = /* @__PURE__ */ new Set();
805
+ const out = [];
806
+ for (const name of CI_ORDER) {
807
+ const context = entries.some((e) => e.name === name && e.required === true) ? WORKFLOW_CHECK_CONTEXTS[name] : void 0;
808
+ if (context && !seen.has(context)) {
809
+ seen.add(context);
810
+ out.push(context);
811
+ }
722
812
  }
723
- }
724
- function packageJsonScript(cwd, task, readFile, fileExists) {
725
- if (!fileExists(join(cwd, "package.json"))) return void 0;
726
- try {
727
- return JSON.parse(readFile(join(cwd, "package.json"))).scripts?.[task];
728
- } catch {
729
- return;
813
+ for (const context of config.extraRequiredChecks ?? []) if (!seen.has(context)) {
814
+ seen.add(context);
815
+ out.push(context);
730
816
  }
817
+ return out;
731
818
  }
732
819
  //#endregion
733
820
  //#region src/super-linter.ts
@@ -848,8 +935,16 @@ function createAstromech(options) {
848
935
  passthrough: opts.passthrough ?? [],
849
936
  dryRun: opts.dryRun ?? false,
850
937
  required: opts.required ?? false,
938
+ ...opts.filter ? { filter: opts.filter } : {},
851
939
  ...task === "lint" ? { linters: lintEntry()?.linters } : {}
852
940
  }),
941
+ ci: (opts = {}) => runCi({
942
+ ...deps,
943
+ cwd: options.cwd,
944
+ config: options.config ?? {},
945
+ linters: lintEntry()?.linters,
946
+ ...opts
947
+ }),
853
948
  thinCallers: () => {
854
949
  const orgCtx = options.orgContext ?? {};
855
950
  const out = /* @__PURE__ */ new Map();
@@ -900,4 +995,4 @@ function createAstromech(options) {
900
995
  };
901
996
  }
902
997
  //#endregion
903
- export { CI_ORDER, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, requiredChecks, resolveLinters, runTask, superLinterConfig };
998
+ export { CI_ORDER, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, requiredChecks, resolveLinters, runCi, runTask, superLinterConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "4.7.0",
3
+ "version": "4.9.0",
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,7 +37,7 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "@theholocron/datapad": "4.7.0"
40
+ "@theholocron/datapad": "4.9.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@theholocron/eslint-config": "^8.0.0",
@@ -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": "4.7.0"
56
+ "@theholocron/rollup-plugin-transform-template": "4.9.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"