@theholocron/astromech 4.5.0 → 4.7.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
@@ -90,6 +90,7 @@ const astromech = createAstromech({ cwd, config, orgContext: { org, domain } });
90
90
 
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
+ astromech.requiredChecks(); // ["Lint / Conclusion", "codecov/patch", …] — branch-protection contexts
93
94
  ```
94
95
 
95
96
  `thinCallers()` returns the raw `.github/workflows/*.yml` content (no
@@ -101,6 +102,16 @@ per runnable task; it skips `local: false` entries and tasks with no local
101
102
  runner (`codeql`, `deploy`), and returns `{}` when `syncScripts: false` or
102
103
  there is no config.
103
104
 
105
+ ### Required checks
106
+
107
+ `requiredChecks()` derives the branch-protection required-status-check list
108
+ from the manifest: every `{ required: true }` task's check context (the
109
+ `… / Conclusion` aggregate job, from `WORKFLOW_CHECK_CONTEXTS`), ordered by
110
+ `CI_ORDER`, then `config.extraRequiredChecks` (codecov gates, the
111
+ bundle-build check, …), de-duplicated. `holocron setup` prepends `"DCO"` and
112
+ applies the list for `protection: "strict"` repos. Policy-free — manifest
113
+ only.
114
+
104
115
  `holocron run` itself does not read the config yet — that (and
105
116
  `holocron ci`) come in later phases (epic #581).
106
117
 
@@ -119,7 +130,7 @@ one place, `src/linters.ts`.
119
130
  | `actionlint` | `GITHUB_ACTIONS` | yes | `actionlint` (usually CI-only) |
120
131
  | `gitleaks` | `GITLEAKS` | yes | `gitleaks dir` (usually CI-only) |
121
132
  | `editorconfig` | `EDITORCONFIG` | yes | `editorconfig-checker` (usually CI-only) |
122
- | `commitlint` | `GIT_COMMITLINT` | yes | `commitlint --last` |
133
+ | `commitlint` | `GIT_COMMITLINT` | yes | — (commit-msg hook + CI) |
123
134
  | `git-merge-conflict-markers` | `GIT_MERGE_CONFLICT_MARKERS` | yes | — (CI only) |
124
135
  | `markdownlint` | `MARKDOWN` | on `.markdownlint*` | `markdownlint-cli2` |
125
136
 
@@ -1,4 +1,4 @@
1
- import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-DbpiBZCP.mjs";
1
+ import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-uPSn69gZ.mjs";
2
2
  //#region src/config/define.d.ts
3
3
  /**
4
4
  * Typed identity helper for `astromech.config.ts`:
package/dist/index.d.mts CHANGED
@@ -1,16 +1,23 @@
1
- import { r as TasksConfig } from "./schema-DbpiBZCP.mjs";
1
+ import { r as TasksConfig } from "./schema-uPSn69gZ.mjs";
2
2
  //#region src/run.d.ts
3
3
  /**
4
4
  * `holocron run <task> [-- <passthrough>]` — run a registry task locally.
5
5
  *
6
6
  * Resolution:
7
7
  *
8
+ * 0. task === "lint" → the linter aggregate (see below)
8
9
  * 1. turbo.json defines the task → `turbo run <task>`
9
10
  * 2. package.json has a `<task>` script → `<pm> run <task>`
10
11
  * (unless it's the `holocron run …` thin caller — that recurses)
11
12
  * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
12
13
  * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
13
14
  * 5. unknown task → "unknown task" (exit 1)
15
+ *
16
+ * `lint` runs the resolved linter set (`config.tasks` `linters`, else
17
+ * auto-detected): the eslint slot goes through the standard turbo / script /
18
+ * `eslint .` resolution (so turbo caching is kept); every other linter runs
19
+ * its `localBin` when found on PATH. Missing tools are flagged; the exit code
20
+ * is the worst of the lot.
14
21
  */
15
22
  /** Minimal structural logger — `@theholocron/logger`'s `Logger` satisfies it. */
16
23
  interface RunLogger {
@@ -35,6 +42,8 @@ interface RunDeps {
35
42
  readFile: (path: string) => string;
36
43
  fileExists: (path: string) => boolean;
37
44
  listDir: (path: string) => string[];
45
+ /** `node_modules/.bin/<bin>` or a PATH entry; `null` when not runnable. */
46
+ lookPath: (cwd: string, bin: string) => string | null;
38
47
  }
39
48
  interface RunTaskInput extends RunDeps {
40
49
  /** Registry task name, e.g. `"test"`. */
@@ -47,6 +56,8 @@ interface RunTaskInput extends RunDeps {
47
56
  dryRun?: boolean;
48
57
  /** Turn "no such task for this repo" (normally exit 0) into a failure. */
49
58
  required?: boolean;
59
+ /** The `lint` task's explicit linter list from `config.tasks`, if any. */
60
+ linters?: string[];
50
61
  }
51
62
  interface RunTaskReport {
52
63
  status: "ok" | "fail" | "skip" | "dry-run" | "unknown";
@@ -124,12 +135,13 @@ declare function lintThinCallerWith(opts: {
124
135
  declare const WORKFLOW_TEMPLATES: Record<string, string>;
125
136
  declare const KNOWN_WORKFLOWS: Set<string>;
126
137
  /**
127
- * GitHub check context name each CI workflow produces on a PR.
138
+ * The GitHub status-check context a `required` task contributes to branch
139
+ * protection. Format: `"{workflow name} / {job name}"`.
128
140
  *
129
- * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
130
- * job's own `name:` field does NOT appear in the external check name — only
131
- * the calling workflow's top-level `name:` and the inner reusable-workflow
132
- * job name matter. Only workflows that gate merges are listed here.
141
+ * These name the **aggregate `Conclusion` job** (fan-in, `if: always()`), not
142
+ * an individual inner job — `test` has several conditionally-run sub-jobs, so
143
+ * `"Test / Conclusion"` is the only stable gate. Only merge-gating workflows
144
+ * are listed. `astro.requiredChecks()` reads this for every `required` task.
133
145
  */
134
146
  declare const WORKFLOW_CHECK_CONTEXTS: Partial<Record<string, string>>;
135
147
  /**
@@ -242,6 +254,8 @@ interface AstromechOptions {
242
254
  readFile?: (path: string) => string;
243
255
  fileExists?: (path: string) => boolean;
244
256
  listDir?: (path: string) => string[];
257
+ /** Injectable binary lookup (tests). Default checks `node_modules/.bin` then `PATH`. */
258
+ lookPath?: (cwd: string, bin: string) => string | null;
245
259
  }
246
260
  interface RunOptions {
247
261
  /** Args after `--`, forwarded to the tool / turbo / script. */
@@ -277,6 +291,13 @@ interface Astromech {
277
291
  * `linters` list, else auto-detection from the repo's config files.
278
292
  */
279
293
  superLinterConfig(): SuperLinterConfig;
294
+ /**
295
+ * The branch-protection required-status-check contexts for this repo —
296
+ * every `required: true` task's check context plus `extraRequiredChecks`,
297
+ * ordered and de-duplicated. `holocron setup` prepends `"DCO"` and applies
298
+ * the list; this method is policy-free (manifest only).
299
+ */
300
+ requiredChecks(): string[];
280
301
  }
281
302
  declare function createAstromech(options: AstromechOptions): Astromech;
282
303
  //#endregion
@@ -394,5 +415,16 @@ interface TaskDef {
394
415
  declare const TASKS: Record<string, TaskDef>;
395
416
  /** Every task name the registry knows. */
396
417
  declare const KNOWN_TASKS: Set<string>;
418
+ /**
419
+ * The order `holocron ci` runs tasks in — cheapest / fastest signal first, so
420
+ * an agent or a `pre-push` hook fails early. Tasks not listed here run last, in
421
+ * manifest order. (The generated thin callers carry no `needs:` — cross-workflow
422
+ * ordering lives in `theholocron/.github` — so `holocron ci` declares its own.)
423
+ */
424
+ declare const CI_ORDER: string[];
425
+ //#endregion
426
+ //#region src/required-checks.d.ts
427
+ /** Ordered (task contexts in {@link CI_ORDER}, then extras), de-duplicated. */
428
+ declare function requiredChecks(config: TasksConfig): string[];
397
429
  //#endregion
398
- export { type Astromech, type AstromechOptions, 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, resolveLinters, runTask, superLinterConfig };
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 };
package/dist/index.mjs CHANGED
@@ -45,11 +45,446 @@ const TASKS = {
45
45
  ] } },
46
46
  sync: { local: { command: "sync" } },
47
47
  wiki: { local: { command: "sync-wiki" } },
48
+ audit: { local: null },
48
49
  codeql: { local: null },
49
50
  deploy: { local: null }
50
51
  };
51
52
  /** Every task name the registry knows. */
52
53
  const KNOWN_TASKS = new Set(Object.keys(TASKS));
54
+ /**
55
+ * The order `holocron ci` runs tasks in — cheapest / fastest signal first, so
56
+ * an agent or a `pre-push` hook fails early. Tasks not listed here run last, in
57
+ * manifest order. (The generated thin callers carry no `needs:` — cross-workflow
58
+ * ordering lives in `theholocron/.github` — so `holocron ci` declares its own.)
59
+ */
60
+ const CI_ORDER = [
61
+ "typecheck",
62
+ "lint",
63
+ "test",
64
+ "build",
65
+ "audit",
66
+ "codeql",
67
+ "deploy"
68
+ ];
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
+ //#region src/linters.ts
362
+ /**
363
+ * Known linters, in execution order. `always` entries are the current
364
+ * hard-coded super-linter baseline; `prettier` is always-on because the org
365
+ * applies it universally (super-linter only lints files that exist).
366
+ */
367
+ const LINTERS = {
368
+ eslint: {
369
+ validate: ["VALIDATE_JAVASCRIPT_ES", "VALIDATE_TYPESCRIPT_ES"],
370
+ localBin: "eslint",
371
+ localArgs: ["."],
372
+ detect: [
373
+ "eslint.config.ts",
374
+ "eslint.config.js",
375
+ "eslint.config.mjs",
376
+ "eslint.config.cjs",
377
+ ".eslintrc",
378
+ ".eslintrc.json",
379
+ ".eslintrc.yml",
380
+ ".eslintrc.yaml",
381
+ ".eslintrc.cjs"
382
+ ],
383
+ configInput: "eslint-config"
384
+ },
385
+ prettier: {
386
+ validate: [
387
+ "VALIDATE_JAVASCRIPT_PRETTIER",
388
+ "VALIDATE_JSX_PRETTIER",
389
+ "VALIDATE_TYPESCRIPT_PRETTIER",
390
+ "VALIDATE_TSX",
391
+ "VALIDATE_MARKDOWN_PRETTIER"
392
+ ],
393
+ fix: [
394
+ "FIX_JAVASCRIPT_PRETTIER",
395
+ "FIX_JSX_PRETTIER",
396
+ "FIX_TYPESCRIPT_PRETTIER",
397
+ "FIX_TSX",
398
+ "FIX_MARKDOWN_PRETTIER"
399
+ ],
400
+ always: true,
401
+ localBin: "prettier",
402
+ localArgs: ["--check", "."],
403
+ configInput: "prettier-config"
404
+ },
405
+ yamllint: {
406
+ validate: ["VALIDATE_YAML"],
407
+ always: true,
408
+ localBin: "yamllint",
409
+ localArgs: ["."],
410
+ installHint: "brew install yamllint",
411
+ configInput: "yaml-config"
412
+ },
413
+ actionlint: {
414
+ validate: ["VALIDATE_GITHUB_ACTIONS"],
415
+ always: true,
416
+ localBin: "actionlint",
417
+ localArgs: [],
418
+ installHint: "brew install actionlint"
419
+ },
420
+ gitleaks: {
421
+ validate: ["VALIDATE_GITLEAKS"],
422
+ always: true,
423
+ localBin: "gitleaks",
424
+ localArgs: ["dir", "--no-banner"],
425
+ installHint: "brew install gitleaks"
426
+ },
427
+ editorconfig: {
428
+ validate: ["VALIDATE_EDITORCONFIG"],
429
+ always: true,
430
+ localBin: "editorconfig-checker",
431
+ localArgs: [],
432
+ installHint: "brew install editorconfig-checker"
433
+ },
434
+ commitlint: {
435
+ validate: ["VALIDATE_GIT_COMMITLINT"],
436
+ always: true
437
+ },
438
+ "git-merge-conflict-markers": {
439
+ validate: ["VALIDATE_GIT_MERGE_CONFLICT_MARKERS"],
440
+ always: true
441
+ },
442
+ markdownlint: {
443
+ validate: ["VALIDATE_MARKDOWN"],
444
+ localBin: "markdownlint-cli2",
445
+ localArgs: ["**/*.md"],
446
+ detect: [
447
+ ".markdownlint.json",
448
+ ".markdownlint.jsonc",
449
+ ".markdownlint.yaml",
450
+ ".markdownlint.yml",
451
+ ".markdownlint-cli2.jsonc",
452
+ ".markdownlint-cli2.yaml",
453
+ ".markdownlint-cli2.mjs"
454
+ ]
455
+ }
456
+ };
457
+ /** Every linter name the registry knows. */
458
+ const LINTER_NAMES = new Set(Object.keys(LINTERS));
459
+ /**
460
+ * Resolve the linter set for a repo. An `explicit` list (from
461
+ * `config.tasks`) wins verbatim; otherwise every `always` linter plus every
462
+ * linter whose `detect` filenames are present at the repo root. Result is
463
+ * ordered by {@link LINTERS} declaration order.
464
+ *
465
+ * @throws when an `explicit` name is not in the registry — a typo is a
466
+ * config bug, not a linter to silently skip.
467
+ */
468
+ function resolveLinters(opts) {
469
+ const order = Object.keys(LINTERS);
470
+ if (opts.explicit && opts.explicit.length > 0) {
471
+ const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
472
+ if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
473
+ const wanted = new Set(opts.explicit);
474
+ return order.filter((n) => wanted.has(n)).map((name) => ({
475
+ name,
476
+ def: LINTERS[name]
477
+ }));
478
+ }
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
+ }));
487
+ }
53
488
  //#endregion
54
489
  //#region src/run.ts
55
490
  /**
@@ -57,18 +492,25 @@ const KNOWN_TASKS = new Set(Object.keys(TASKS));
57
492
  *
58
493
  * Resolution:
59
494
  *
495
+ * 0. task === "lint" → the linter aggregate (see below)
60
496
  * 1. turbo.json defines the task → `turbo run <task>`
61
497
  * 2. package.json has a `<task>` script → `<pm> run <task>`
62
498
  * (unless it's the `holocron run …` thin caller — that recurses)
63
499
  * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
64
500
  * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
65
501
  * 5. unknown task → "unknown task" (exit 1)
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.
66
508
  */
67
- function runTask(input) {
68
- const { print, logger, exec, readFile, fileExists, listDir, task, cwd } = input;
69
- const passthrough = input.passthrough ?? [];
509
+ /** The per-command runner — `runTask` and `runLintAggregate` share it. */
510
+ function makeRunOne(input) {
511
+ const { print, logger, exec, task, cwd } = input;
70
512
  const dryRun = input.dryRun ?? false;
71
- const run = (cmd, args) => {
513
+ return (cmd, args) => {
72
514
  const command = [cmd, ...args].join(" ");
73
515
  if (dryRun) {
74
516
  print(`would run: ${command}`);
@@ -97,6 +539,12 @@ function runTask(input) {
97
539
  ...status === "fail" ? { message: `\`${command}\` exited ${exitCode}` } : {}
98
540
  };
99
541
  };
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);
100
548
  if (turboDefinesTask(cwd, task, readFile, fileExists)) {
101
549
  const args = [
102
550
  "run",
@@ -150,6 +598,85 @@ function runTask(input) {
150
598
  message: `unknown task "${task}"`
151
599
  };
152
600
  }
601
+ /**
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.
605
+ */
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", ["."]);
649
+ }
650
+ for (const { name, def } of resolved) {
651
+ if (name === "eslint") continue;
652
+ runLinter(name, def.localBin, def.localArgs ?? [], def.installHint);
653
+ }
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
+ };
665
+ }
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
+ };
679
+ }
153
680
  const mkRunner = (tool, args = []) => ({
154
681
  tool,
155
682
  args
@@ -198,139 +725,9 @@ function packageJsonScript(cwd, task, readFile, fileExists) {
198
725
  if (!fileExists(join(cwd, "package.json"))) return void 0;
199
726
  try {
200
727
  return JSON.parse(readFile(join(cwd, "package.json"))).scripts?.[task];
201
- } catch {
202
- return;
203
- }
204
- }
205
- //#endregion
206
- //#region src/linters.ts
207
- /**
208
- * Known linters, in execution order. `always` entries are the current
209
- * hard-coded super-linter baseline; `prettier` is always-on because the org
210
- * applies it universally (super-linter only lints files that exist).
211
- */
212
- const LINTERS = {
213
- eslint: {
214
- validate: ["VALIDATE_JAVASCRIPT_ES", "VALIDATE_TYPESCRIPT_ES"],
215
- localBin: "eslint",
216
- localArgs: ["."],
217
- detect: [
218
- "eslint.config.ts",
219
- "eslint.config.js",
220
- "eslint.config.mjs",
221
- "eslint.config.cjs",
222
- ".eslintrc",
223
- ".eslintrc.json",
224
- ".eslintrc.yml",
225
- ".eslintrc.yaml",
226
- ".eslintrc.cjs"
227
- ],
228
- configInput: "eslint-config"
229
- },
230
- prettier: {
231
- validate: [
232
- "VALIDATE_JAVASCRIPT_PRETTIER",
233
- "VALIDATE_JSX_PRETTIER",
234
- "VALIDATE_TYPESCRIPT_PRETTIER",
235
- "VALIDATE_TSX",
236
- "VALIDATE_MARKDOWN_PRETTIER"
237
- ],
238
- fix: [
239
- "FIX_JAVASCRIPT_PRETTIER",
240
- "FIX_JSX_PRETTIER",
241
- "FIX_TYPESCRIPT_PRETTIER",
242
- "FIX_TSX",
243
- "FIX_MARKDOWN_PRETTIER"
244
- ],
245
- always: true,
246
- localBin: "prettier",
247
- localArgs: ["--check", "."],
248
- configInput: "prettier-config"
249
- },
250
- yamllint: {
251
- validate: ["VALIDATE_YAML"],
252
- always: true,
253
- localBin: "yamllint",
254
- localArgs: ["."],
255
- installHint: "brew install yamllint",
256
- configInput: "yaml-config"
257
- },
258
- actionlint: {
259
- validate: ["VALIDATE_GITHUB_ACTIONS"],
260
- always: true,
261
- localBin: "actionlint",
262
- localArgs: [],
263
- installHint: "brew install actionlint"
264
- },
265
- gitleaks: {
266
- validate: ["VALIDATE_GITLEAKS"],
267
- always: true,
268
- localBin: "gitleaks",
269
- localArgs: ["dir", "--no-banner"],
270
- installHint: "brew install gitleaks"
271
- },
272
- editorconfig: {
273
- validate: ["VALIDATE_EDITORCONFIG"],
274
- always: true,
275
- localBin: "editorconfig-checker",
276
- localArgs: [],
277
- installHint: "brew install editorconfig-checker"
278
- },
279
- commitlint: {
280
- validate: ["VALIDATE_GIT_COMMITLINT"],
281
- always: true,
282
- localBin: "commitlint",
283
- localArgs: ["--last"]
284
- },
285
- "git-merge-conflict-markers": {
286
- validate: ["VALIDATE_GIT_MERGE_CONFLICT_MARKERS"],
287
- always: true
288
- },
289
- markdownlint: {
290
- validate: ["VALIDATE_MARKDOWN"],
291
- localBin: "markdownlint-cli2",
292
- localArgs: ["**/*.md"],
293
- detect: [
294
- ".markdownlint.json",
295
- ".markdownlint.jsonc",
296
- ".markdownlint.yaml",
297
- ".markdownlint.yml",
298
- ".markdownlint-cli2.jsonc",
299
- ".markdownlint-cli2.yaml",
300
- ".markdownlint-cli2.mjs"
301
- ]
302
- }
303
- };
304
- /** Every linter name the registry knows. */
305
- const LINTER_NAMES = new Set(Object.keys(LINTERS));
306
- /**
307
- * Resolve the linter set for a repo. An `explicit` list (from
308
- * `config.tasks`) wins verbatim; otherwise every `always` linter plus every
309
- * linter whose `detect` filenames are present at the repo root. Result is
310
- * ordered by {@link LINTERS} declaration order.
311
- *
312
- * @throws when an `explicit` name is not in the registry — a typo is a
313
- * config bug, not a linter to silently skip.
314
- */
315
- function resolveLinters(opts) {
316
- const order = Object.keys(LINTERS);
317
- if (opts.explicit && opts.explicit.length > 0) {
318
- const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
319
- if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
320
- const wanted = new Set(opts.explicit);
321
- return order.filter((n) => wanted.has(n)).map((name) => ({
322
- name,
323
- def: LINTERS[name]
324
- }));
325
- }
326
- const present = new Set(opts.rootFiles);
327
- return order.filter((name) => {
328
- const def = LINTERS[name];
329
- return def.always === true || def.detect.some((f) => present.has(f));
330
- }).map((name) => ({
331
- name,
332
- def: LINTERS[name]
333
- }));
728
+ } catch {
729
+ return;
730
+ }
334
731
  }
335
732
  //#endregion
336
733
  //#region src/super-linter.ts
@@ -401,265 +798,6 @@ function lintThinCallerWith(opts) {
401
798
  };
402
799
  }
403
800
  //#endregion
404
- //#region src/thin-callers.ts
405
- /**
406
- * Workflow templates + thin-caller generation.
407
- *
408
- * `WORKFLOW_TEMPLATES` holds each reusable `.github/workflows/<name>.yml`
409
- * (synced to `theholocron/.github`); `generateThinCallerContent` wraps one
410
- * into the thin caller `holocron setup` / `holocron sync` write locally.
411
- */
412
- const WORKFLOW_TEMPLATES = {
413
- 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",
414
- 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",
415
- 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",
416
- 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",
417
- 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",
418
- 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",
419
- 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",
420
- 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",
421
- 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",
422
- 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",
423
- 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",
424
- 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",
425
- 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",
426
- 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",
427
- 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"
428
- };
429
- const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
430
- /**
431
- * GitHub check context name each CI workflow produces on a PR.
432
- *
433
- * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
434
- * job's own `name:` field does NOT appear in the external check name — only
435
- * the calling workflow's top-level `name:` and the inner reusable-workflow
436
- * job name matter. Only workflows that gate merges are listed here.
437
- */
438
- const WORKFLOW_CHECK_CONTEXTS = {
439
- lint: "Lint / Lint entire codebase",
440
- test: "Test / Run tests and collect coverage",
441
- typecheck: "Typecheck / tsc --noEmit"
442
- };
443
- /**
444
- * Generate the thin caller content for a workflow, optionally injecting or
445
- * merging `with:` overrides into the jobs block.
446
- *
447
- * Two strategies are used depending on the template:
448
- * - Templates that already have a `with:` block (e.g. lint):
449
- * the override entries are merged in, replacing existing keys and appending
450
- * new ones.
451
- * - Templates that end with ` secrets: inherit`: a new `with:` block is
452
- * injected immediately before `secrets: inherit`.
453
- * If neither pattern matches the template, a warning is emitted and the
454
- * base template is returned unchanged.
455
- */
456
- function generateThinCallerContent(name, withOverrides, additionalPaths, logger, comments) {
457
- const base = WORKFLOW_TEMPLATES[name];
458
- if (!base) return "";
459
- const yamlScalar = (v) => {
460
- if (v === true) return "true";
461
- if (v === false) return "false";
462
- const s = String(v);
463
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
464
- };
465
- const fmt = (k, v) => {
466
- const line = ` ${k}: ${yamlScalar(v)}`;
467
- return comments?.[k] ? ` # ${comments[k]}\n${line}` : line;
468
- };
469
- let result = base;
470
- if (additionalPaths && additionalPaths.length > 0) {
471
- const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
472
- if (pathsBlockRe.test(result)) result = result.replace(pathsBlockRe, (_, header, existing) => {
473
- const existingPaths = new Set([...existing.matchAll(/- (.+)/g)].map((m) => m[1]));
474
- const newEntries = additionalPaths.filter((p) => !existingPaths.has(p)).map((p) => ` - ${p}\n`).join("");
475
- return header + existing + newEntries;
476
- });
477
- else {
478
- const pathsBlock = ` paths:\n${additionalPaths.map((p) => ` - ${p}\n`).join("")}`;
479
- result = result.replace(/( {4}branches: \[main\]\n)/, `$1${pathsBlock}`);
480
- }
481
- }
482
- if (!withOverrides || Object.keys(withOverrides).length === 0) return result;
483
- const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
484
- const existingMatch = result.match(withBlockRe);
485
- if (existingMatch) {
486
- const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).filter((line) => !line.trim().startsWith("#")).map((line) => {
487
- const m = line.match(/^ {6}([^:]+):\s*(.*)/);
488
- return m ? [m[1].trim(), m[2].trim()] : null;
489
- }).filter((e) => e !== null));
490
- for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, yamlScalar(v));
491
- const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
492
- return result.replace(withBlockRe, ` with:\n${merged}\n`);
493
- }
494
- const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
495
- const injected = result.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
496
- if (injected === result) logger?.warn({ template: name }, "generateThinCallerContent: could not inject `with:` overrides");
497
- return injected;
498
- }
499
- /**
500
- * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
501
- *
502
- * Accepts three forms:
503
- * - `preview: true` — derive both project and domain from org context
504
- * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
505
- * - `preview: { project: "...", domain: "..." }` — fully explicit
506
- *
507
- * Returns null when `preview:` is absent, false, or can't be resolved.
508
- */
509
- function extractPreviewConfig(raw, ctx = {}) {
510
- const preview = raw["preview"];
511
- if (!preview) return null;
512
- if (preview === true) {
513
- const project = ctx.org ? `${ctx.org}-preview` : null;
514
- const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
515
- if (!project) return null;
516
- return {
517
- project,
518
- ...domain ? { domain } : {}
519
- };
520
- }
521
- if (typeof preview !== "object") return null;
522
- const p = preview;
523
- const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
524
- if (!project) return null;
525
- const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
526
- return {
527
- project,
528
- ...domain ? { domain } : {}
529
- };
530
- }
531
- /**
532
- * Generate the full thin-caller YAML for a `deploy.yml` that handles both
533
- * production (push to main → GitHub Pages) and preview (pull_request →
534
- * Cloudflare Pages) in a single file.
535
- *
536
- * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
537
- * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
538
- * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
539
- * all repos with a `deploy` workflow get previews without per-repo config.
540
- */
541
- function generateCombinedDeployContent(deployWith, paths, preview) {
542
- const yamlScalar = (v) => {
543
- if (v === true) return "true";
544
- if (v === false) return "false";
545
- const s = String(v);
546
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
547
- };
548
- const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
549
- const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
550
- const previewWith = {
551
- ...deployWith,
552
- "cloudflare-project": preview.project
553
- };
554
- const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
555
- const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
556
- return [
557
- `name: Deploy`,
558
- ``,
559
- `on: # yamllint disable-line rule:truthy`,
560
- ` push:`,
561
- ` branches: [main]`,
562
- ...pathsBlock ? [`${pathsBlock}`] : [],
563
- ` pull_request:`,
564
- ` branches: [main]`,
565
- ` types: [opened, synchronize, reopened, closed]`,
566
- ...pathsBlock ? [`${pathsBlock}`] : [],
567
- ` workflow_dispatch:`,
568
- ``,
569
- `concurrency:`,
570
- ` group: $\{{ github.event_name == 'pull_request' && format('preview-{0}', github.event.pull_request.number) || 'pages' }}`,
571
- ` cancel-in-progress: $\{{ github.event_name == 'pull_request' && github.event.action != 'closed' }}`,
572
- ``,
573
- `permissions:`,
574
- ` contents: read`,
575
- ` deployments: write`,
576
- ` pages: write`,
577
- ` id-token: write`,
578
- ` pull-requests: write`,
579
- ``,
580
- `jobs:`,
581
- ` deploy:`,
582
- ` name: Deploy`,
583
- ` if: \${{ github.event_name != 'pull_request' }}`,
584
- ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
585
- ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
586
- ` secrets: inherit`,
587
- ``,
588
- ` preview:`,
589
- ` name: Preview`,
590
- ` if: \${{ github.event_name == 'pull_request' }}`,
591
- ` uses: theholocron/.github/.github/workflows/preview.yml@main`,
592
- previewWithBlock.trimEnd(),
593
- ` secrets: inherit`,
594
- ``
595
- ].join("\n");
596
- }
597
- /**
598
- * Expand structured with-values to flat GitHub Actions inputs before
599
- * generating the thin caller. Handles:
600
- * - deploy shorthand: docs/storybook → type + storybook-projects
601
- * - preview: stripped (handled separately via extractPreviewConfig)
602
- * - run-chromatic object → run-chromatic: true + chromatic-projects
603
- * - plain arrays → JSON-stringified for YAML scalar quoting
604
- *
605
- * Used by both `holocron setup` and `sync-workflow-templates`.
606
- */
607
- function normalizeWorkflowWith(raw) {
608
- const result = { ...raw };
609
- delete result["preview"];
610
- const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
611
- const storybookProjects = raw["storybook"];
612
- if (hasDocs) {
613
- result["type"] = "docs";
614
- delete result["docs"];
615
- }
616
- if (Array.isArray(storybookProjects)) {
617
- if (!hasDocs) result["type"] = "storybook";
618
- result["storybook-projects"] = JSON.stringify(storybookProjects.map(({ name, path = "." }) => ({
619
- name,
620
- workingDir: path
621
- })));
622
- delete result["storybook"];
623
- }
624
- const runChromatic = raw["run-chromatic"];
625
- if (runChromatic !== null && typeof runChromatic === "object" && "projects" in runChromatic) {
626
- result["run-chromatic"] = true;
627
- const projects = runChromatic.projects.map((p) => ({
628
- ...p,
629
- ...Array.isArray(p.untraced) ? { untraced: p.untraced.join("\n") } : {}
630
- }));
631
- result["chromatic-projects"] = JSON.stringify(projects);
632
- }
633
- for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) result[k] = JSON.stringify(v);
634
- return result;
635
- }
636
- /**
637
- * Derive on.push.paths entries from the deploy with: shorthand.
638
- * Used by both `holocron setup` and `sync-workflow-templates`.
639
- */
640
- function deriveDeployPaths(raw) {
641
- const paths = [];
642
- const docs = raw["docs"];
643
- if (docs === true) {
644
- paths.push("docs/**");
645
- paths.push("astro.config.ts");
646
- paths.push("pnpm-workspace.yaml");
647
- paths.push("pnpm-lock.yaml");
648
- } else if (docs !== null && typeof docs === "object" && "path" in docs) {
649
- const p = docs.path;
650
- if (p && p !== ".") paths.push(`${p}/**`);
651
- }
652
- const storybookProjects = raw["storybook"];
653
- if (Array.isArray(storybookProjects)) for (const s of storybookProjects) {
654
- const p = s.path || ".";
655
- if (p === ".") {
656
- paths.push("src/**");
657
- paths.push(".storybook/**");
658
- } else paths.push(`${p}/**`);
659
- }
660
- return paths;
661
- }
662
- //#endregion
663
801
  //#region src/astromech.ts
664
802
  /**
665
803
  * `createAstromech(options)` — the self-contained task runner.
@@ -676,6 +814,13 @@ const realExec = (cmd, args, opts) => {
676
814
  stdio: "inherit"
677
815
  }).status ?? -1 };
678
816
  };
817
+ /** `node_modules/.bin/<bin>`, else the first `PATH` entry that has it, else `null`. */
818
+ const realLookPath = (cwd, bin) => {
819
+ const local = join(cwd, "node_modules", ".bin", bin);
820
+ if (existsSync(local)) return local;
821
+ for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir && existsSync(join(dir, bin))) return join(dir, bin);
822
+ return null;
823
+ };
679
824
  function createAstromech(options) {
680
825
  const deps = {
681
826
  print: options.print ?? ((line) => console.log(line)),
@@ -683,7 +828,8 @@ function createAstromech(options) {
683
828
  exec: options.exec ?? realExec,
684
829
  readFile: options.readFile ?? ((path) => readFileSync(path, "utf8")),
685
830
  fileExists: options.fileExists ?? ((path) => existsSync(path)),
686
- listDir: options.listDir ?? ((path) => readdirSync(path))
831
+ listDir: options.listDir ?? ((path) => readdirSync(path)),
832
+ lookPath: options.lookPath ?? realLookPath
687
833
  };
688
834
  const items = () => (options.config?.tasks ?? []).map((i) => normalizeTaskEntry(i));
689
835
  const rootFiles = () => {
@@ -701,7 +847,8 @@ function createAstromech(options) {
701
847
  cwd: options.cwd,
702
848
  passthrough: opts.passthrough ?? [],
703
849
  dryRun: opts.dryRun ?? false,
704
- required: opts.required ?? false
850
+ required: opts.required ?? false,
851
+ ...task === "lint" ? { linters: lintEntry()?.linters } : {}
705
852
  }),
706
853
  thinCallers: () => {
707
854
  const orgCtx = options.orgContext ?? {};
@@ -748,8 +895,9 @@ function createAstromech(options) {
748
895
  superLinterConfig: () => superLinterConfig({
749
896
  explicit: lintEntry()?.linters,
750
897
  rootFiles: rootFiles()
751
- })
898
+ }),
899
+ requiredChecks: () => requiredChecks(options.config ?? {})
752
900
  };
753
901
  }
754
902
  //#endregion
755
- export { KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, resolveLinters, runTask, superLinterConfig };
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 };
@@ -22,8 +22,9 @@ interface TaskEntry {
22
22
  */
23
23
  local?: boolean;
24
24
  /**
25
- * The task's CI check context is a required status check (branch
26
- * protection) and part of `holocron ci`'s default run.
25
+ * The task's CI check context (from `WORKFLOW_CHECK_CONTEXTS`) is a
26
+ * required status check in branch protection (`astro.requiredChecks()`)
27
+ * and part of `holocron ci`'s default run.
27
28
  */
28
29
  required?: boolean;
29
30
  /** Per-repo overrides on the same channel the reusable workflow reads. */
@@ -51,8 +52,9 @@ interface TasksConfig {
51
52
  */
52
53
  holocronScript?: string;
53
54
  /**
54
- * Required status-check contexts not backed by a task — DCO, semantic
55
- * PR title, …
55
+ * Required status-check contexts not backed by a task — codecov gates,
56
+ * a bundle-build check, … Appended to `astro.requiredChecks()` after the
57
+ * `required`-task contexts. (`DCO` is prepended by `holocron setup` itself.)
56
58
  */
57
59
  extraRequiredChecks?: string[];
58
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "4.5.0",
3
+ "version": "4.7.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.5.0"
40
+ "@theholocron/datapad": "4.7.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.5.0"
56
+ "@theholocron/rollup-plugin-transform-template": "4.7.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"