@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 +7 -0
- package/dist/index.d.mts +47 -4
- package/dist/index.mjs +609 -514
- package/package.json +3 -3
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.
|
|
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
|
|
399
|
-
*
|
|
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/
|
|
458
|
+
//#region src/thin-callers.ts
|
|
490
459
|
/**
|
|
491
|
-
*
|
|
460
|
+
* Workflow templates + thin-caller generation.
|
|
492
461
|
*
|
|
493
|
-
*
|
|
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
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
|
|
573
|
+
project,
|
|
574
|
+
...domain ? { domain } : {}
|
|
589
575
|
};
|
|
590
576
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
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
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
651
|
-
if (
|
|
652
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
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
|
|
667
|
-
|
|
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
|
-
*
|
|
686
|
-
*
|
|
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
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
705
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"@theholocron/rollup-plugin-transform-template": "4.9.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|