@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 +12 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/index.d.mts +39 -7
- package/dist/index.mjs +548 -400
- package/dist/{schema-DbpiBZCP.d.mts → schema-uPSn69gZ.d.mts} +6 -4
- package/package.json +3 -3
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 |
|
|
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
|
|
package/dist/config/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-
|
|
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-
|
|
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
|
|
138
|
+
* The GitHub status-check context a `required` task contributes to branch
|
|
139
|
+
* protection. Format: `"{workflow name} / {job name}"`.
|
|
128
140
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
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 —
|
|
55
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"@theholocron/rollup-plugin-transform-template": "4.7.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|