@intentius/chant-lexicon-github 0.0.18
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/dist/integrity.json +31 -0
- package/dist/manifest.json +15 -0
- package/dist/meta.json +135 -0
- package/dist/rules/deprecated-action-version.ts +49 -0
- package/dist/rules/detect-secrets.ts +53 -0
- package/dist/rules/extract-inline-structs.ts +62 -0
- package/dist/rules/file-job-limit.ts +49 -0
- package/dist/rules/gha006.ts +58 -0
- package/dist/rules/gha009.ts +42 -0
- package/dist/rules/gha011.ts +40 -0
- package/dist/rules/gha017.ts +32 -0
- package/dist/rules/gha018.ts +40 -0
- package/dist/rules/gha019.ts +72 -0
- package/dist/rules/job-timeout.ts +59 -0
- package/dist/rules/missing-recommended-inputs.ts +61 -0
- package/dist/rules/no-hardcoded-secrets.ts +46 -0
- package/dist/rules/no-raw-expressions.ts +51 -0
- package/dist/rules/suggest-cache.ts +71 -0
- package/dist/rules/use-condition-builders.ts +45 -0
- package/dist/rules/use-matrix-builder.ts +44 -0
- package/dist/rules/use-typed-actions.ts +47 -0
- package/dist/rules/validate-concurrency.ts +66 -0
- package/dist/rules/yaml-helpers.ts +129 -0
- package/dist/skills/chant-github.md +29 -0
- package/dist/skills/github-actions-patterns.md +93 -0
- package/dist/types/index.d.ts +358 -0
- package/package.json +33 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1138 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +149 -0
- package/src/codegen/generate.ts +141 -0
- package/src/codegen/naming.ts +57 -0
- package/src/codegen/package.ts +65 -0
- package/src/codegen/parse.ts +700 -0
- package/src/codegen/patches.ts +46 -0
- package/src/composites/cache.ts +25 -0
- package/src/composites/checkout.ts +31 -0
- package/src/composites/composites.test.ts +675 -0
- package/src/composites/deploy-environment.ts +77 -0
- package/src/composites/docker-build.ts +120 -0
- package/src/composites/download-artifact.ts +24 -0
- package/src/composites/go-ci.ts +91 -0
- package/src/composites/index.ts +26 -0
- package/src/composites/node-ci.ts +71 -0
- package/src/composites/node-pipeline.ts +151 -0
- package/src/composites/python-ci.ts +92 -0
- package/src/composites/setup-go.ts +24 -0
- package/src/composites/setup-node.ts +26 -0
- package/src/composites/setup-python.ts +24 -0
- package/src/composites/upload-artifact.ts +27 -0
- package/src/coverage.ts +49 -0
- package/src/expression.test.ts +147 -0
- package/src/expression.ts +214 -0
- package/src/generated/index.d.ts +358 -0
- package/src/generated/index.ts +29 -0
- package/src/generated/lexicon-github.json +135 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +110 -0
- package/src/import/generator.ts +119 -0
- package/src/import/parser.test.ts +98 -0
- package/src/import/parser.ts +73 -0
- package/src/index.ts +53 -0
- package/src/lint/post-synth/gha006.ts +58 -0
- package/src/lint/post-synth/gha009.ts +42 -0
- package/src/lint/post-synth/gha011.ts +40 -0
- package/src/lint/post-synth/gha017.ts +32 -0
- package/src/lint/post-synth/gha018.ts +40 -0
- package/src/lint/post-synth/gha019.ts +72 -0
- package/src/lint/post-synth/post-synth.test.ts +318 -0
- package/src/lint/post-synth/yaml-helpers.ts +129 -0
- package/src/lint/rules/data/deprecated-versions.ts +13 -0
- package/src/lint/rules/data/known-actions.ts +13 -0
- package/src/lint/rules/data/recommended-inputs.ts +10 -0
- package/src/lint/rules/data/secret-patterns.ts +31 -0
- package/src/lint/rules/deprecated-action-version.ts +49 -0
- package/src/lint/rules/detect-secrets.ts +53 -0
- package/src/lint/rules/extract-inline-structs.ts +62 -0
- package/src/lint/rules/file-job-limit.ts +49 -0
- package/src/lint/rules/index.ts +17 -0
- package/src/lint/rules/job-timeout.ts +59 -0
- package/src/lint/rules/missing-recommended-inputs.ts +61 -0
- package/src/lint/rules/no-hardcoded-secrets.ts +46 -0
- package/src/lint/rules/no-raw-expressions.ts +51 -0
- package/src/lint/rules/rules.test.ts +365 -0
- package/src/lint/rules/suggest-cache.ts +71 -0
- package/src/lint/rules/use-condition-builders.ts +45 -0
- package/src/lint/rules/use-matrix-builder.ts +44 -0
- package/src/lint/rules/use-typed-actions.ts +47 -0
- package/src/lint/rules/validate-concurrency.ts +66 -0
- package/src/lsp/completions.test.ts +9 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +9 -0
- package/src/lsp/hover.ts +38 -0
- package/src/package-cli.ts +42 -0
- package/src/plugin.test.ts +128 -0
- package/src/plugin.ts +408 -0
- package/src/serializer.test.ts +270 -0
- package/src/serializer.ts +383 -0
- package/src/skills/github-actions-patterns.md +93 -0
- package/src/spec/fetch.ts +55 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +12 -0
- package/src/validate.ts +32 -0
- package/src/variables.ts +44 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions YAML serializer.
|
|
3
|
+
*
|
|
4
|
+
* Converts Chant declarables to .github/workflows/*.yml YAML output.
|
|
5
|
+
* Uses kebab-case keys for job properties and snake_case for trigger
|
|
6
|
+
* event names.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
12
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
13
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
14
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
15
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
16
|
+
|
|
17
|
+
// ── Key conversion ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert camelCase property names to kebab-case for YAML output.
|
|
21
|
+
* Examples: timeoutMinutes → timeout-minutes, runsOn → runs-on
|
|
22
|
+
*/
|
|
23
|
+
function toKebabCase(name: string): string {
|
|
24
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert camelCase trigger names to snake_case event names.
|
|
29
|
+
* Examples: pullRequest → pull_request, workflowDispatch → workflow_dispatch
|
|
30
|
+
*/
|
|
31
|
+
function toSnakeCase(name: string): string {
|
|
32
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map entity type names to YAML trigger event names.
|
|
37
|
+
*/
|
|
38
|
+
const TRIGGER_TYPE_TO_EVENT: Record<string, string> = {
|
|
39
|
+
"GitHub::Actions::PushTrigger": "push",
|
|
40
|
+
"GitHub::Actions::PullRequestTrigger": "pull_request",
|
|
41
|
+
"GitHub::Actions::PullRequestTargetTrigger": "pull_request_target",
|
|
42
|
+
"GitHub::Actions::ScheduleTrigger": "schedule",
|
|
43
|
+
"GitHub::Actions::WorkflowDispatchTrigger": "workflow_dispatch",
|
|
44
|
+
"GitHub::Actions::WorkflowCallTrigger": "workflow_call",
|
|
45
|
+
"GitHub::Actions::WorkflowRunTrigger": "workflow_run",
|
|
46
|
+
"GitHub::Actions::RepositoryDispatchTrigger": "repository_dispatch",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Check if an entity type is a trigger. */
|
|
50
|
+
function isTriggerType(entityType: string): boolean {
|
|
51
|
+
return entityType in TRIGGER_TYPE_TO_EVENT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Visitor ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function githubVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
57
|
+
return {
|
|
58
|
+
attrRef: (name, _attr) => name,
|
|
59
|
+
resourceRef: (name) => toKebabCase(name),
|
|
60
|
+
propertyDeclarable: (entity, walk) => {
|
|
61
|
+
if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const props = entity.props as Record<string, unknown>;
|
|
65
|
+
const result: Record<string, unknown> = {};
|
|
66
|
+
for (const [key, value] of Object.entries(props)) {
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
result[key] = walk(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Intrinsic preprocessing ───────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function preprocessIntrinsics(value: unknown): unknown {
|
|
79
|
+
if (value === null || value === undefined) return value;
|
|
80
|
+
|
|
81
|
+
if (typeof value === "object" && INTRINSIC_MARKER in value) {
|
|
82
|
+
if ("toYAML" in value && typeof value.toYAML === "function") {
|
|
83
|
+
return (value as { toYAML(): unknown }).toYAML();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Leave Declarables untouched
|
|
88
|
+
if (typeof value === "object" && value !== null && "entityType" in value) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
return value.map(preprocessIntrinsics);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof value === "object") {
|
|
97
|
+
const result: Record<string, unknown> = {};
|
|
98
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
99
|
+
result[k] = preprocessIntrinsics(v);
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
|
|
108
|
+
const preprocessed = preprocessIntrinsics(value);
|
|
109
|
+
return walkValue(preprocessed, entityNames, githubVisitor(entityNames));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Key conversion for YAML output ────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Convert a props object keys from camelCase to kebab-case for job/step properties.
|
|
116
|
+
*/
|
|
117
|
+
function convertKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
|
118
|
+
const result: Record<string, unknown> = {};
|
|
119
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
120
|
+
if (value === undefined || value === null) continue;
|
|
121
|
+
// Zero and false are valid values, but undefined/null should be omitted
|
|
122
|
+
const yamlKey = toKebabCase(key);
|
|
123
|
+
result[yamlKey] = convertValueKeys(value);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function convertValueKeys(value: unknown): unknown {
|
|
129
|
+
if (value === null || value === undefined) return value;
|
|
130
|
+
if (typeof value !== "object") return value;
|
|
131
|
+
if (Array.isArray(value)) return value.map(convertValueKeys);
|
|
132
|
+
|
|
133
|
+
const obj = value as Record<string, unknown>;
|
|
134
|
+
const result: Record<string, unknown> = {};
|
|
135
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
136
|
+
if (v === undefined || v === null) continue;
|
|
137
|
+
result[toKebabCase(key)] = convertValueKeys(v);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Serializer ────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* GitHub Actions YAML serializer implementation.
|
|
146
|
+
*/
|
|
147
|
+
export const githubSerializer: Serializer = {
|
|
148
|
+
name: "github",
|
|
149
|
+
rulePrefix: "GHA",
|
|
150
|
+
|
|
151
|
+
serialize(
|
|
152
|
+
entities: Map<string, Declarable>,
|
|
153
|
+
_outputs?: LexiconOutput[],
|
|
154
|
+
): string | SerializerResult {
|
|
155
|
+
const entityNames = new Map<Declarable, string>();
|
|
156
|
+
for (const [name, entity] of entities) {
|
|
157
|
+
entityNames.set(entity, name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Categorize entities
|
|
161
|
+
const workflows: Array<[string, Declarable]> = [];
|
|
162
|
+
const jobs: Array<[string, Declarable]> = [];
|
|
163
|
+
const triggers: Array<[string, Declarable]> = [];
|
|
164
|
+
const others: Array<[string, Declarable]> = [];
|
|
165
|
+
|
|
166
|
+
for (const [name, entity] of entities) {
|
|
167
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
168
|
+
|
|
169
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
170
|
+
if (entityType === "GitHub::Actions::Workflow") {
|
|
171
|
+
workflows.push([name, entity]);
|
|
172
|
+
} else if (entityType === "GitHub::Actions::Job" || entityType === "GitHub::Actions::ReusableWorkflowCallJob") {
|
|
173
|
+
jobs.push([name, entity]);
|
|
174
|
+
} else if (isTriggerType(entityType)) {
|
|
175
|
+
triggers.push([name, entity]);
|
|
176
|
+
} else {
|
|
177
|
+
others.push([name, entity]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// If multiple workflows, produce multiple files
|
|
182
|
+
if (workflows.length > 1) {
|
|
183
|
+
return serializeMultiWorkflow(workflows, jobs, triggers, entities, entityNames);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Single workflow (or implicit workflow from jobs)
|
|
187
|
+
return serializeSingleWorkflow(workflows, jobs, triggers, entities, entityNames);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function serializeSingleWorkflow(
|
|
192
|
+
workflows: Array<[string, Declarable]>,
|
|
193
|
+
jobs: Array<[string, Declarable]>,
|
|
194
|
+
triggers: Array<[string, Declarable]>,
|
|
195
|
+
_entities: Map<string, Declarable>,
|
|
196
|
+
entityNames: Map<Declarable, string>,
|
|
197
|
+
): string {
|
|
198
|
+
const doc: Record<string, unknown> = {};
|
|
199
|
+
|
|
200
|
+
// Workflow-level properties
|
|
201
|
+
if (workflows.length > 0) {
|
|
202
|
+
const [, wf] = workflows[0];
|
|
203
|
+
const props = toYAMLValue((wf as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
204
|
+
if (props) {
|
|
205
|
+
if (props.name) doc.name = props.name;
|
|
206
|
+
if (props["run-name"] || props.runName) doc["run-name"] = props["run-name"] ?? props.runName;
|
|
207
|
+
if (props.permissions) doc.permissions = convertValueKeys(props.permissions);
|
|
208
|
+
if (props.env) doc.env = props.env;
|
|
209
|
+
if (props.concurrency) doc.concurrency = convertValueKeys(props.concurrency);
|
|
210
|
+
if (props.defaults) doc.defaults = convertValueKeys(props.defaults);
|
|
211
|
+
|
|
212
|
+
// Handle 'on' from workflow props
|
|
213
|
+
if (props.on) {
|
|
214
|
+
doc.on = convertTriggerProps(props.on);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// If triggers exist as separate entities, merge into 'on'
|
|
220
|
+
if (triggers.length > 0) {
|
|
221
|
+
const onSection = (doc.on as Record<string, unknown>) ?? {};
|
|
222
|
+
for (const [, trigger] of triggers) {
|
|
223
|
+
const entityType = (trigger as Record<string, unknown>).entityType as string;
|
|
224
|
+
const eventName = TRIGGER_TYPE_TO_EVENT[entityType];
|
|
225
|
+
if (!eventName) continue;
|
|
226
|
+
|
|
227
|
+
const props = toYAMLValue((trigger as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
228
|
+
if (props && Object.keys(props).length > 0) {
|
|
229
|
+
onSection[eventName] = convertValueKeys(props);
|
|
230
|
+
} else {
|
|
231
|
+
onSection[eventName] = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
doc.on = onSection;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Jobs
|
|
238
|
+
if (jobs.length > 0) {
|
|
239
|
+
const jobsSection: Record<string, unknown> = {};
|
|
240
|
+
for (const [name, job] of jobs) {
|
|
241
|
+
const props = toYAMLValue((job as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
242
|
+
if (props) {
|
|
243
|
+
const yamlName = toKebabCase(name);
|
|
244
|
+
jobsSection[yamlName] = convertKeys(props);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
doc.jobs = jobsSection;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return emitYAMLDocument(doc);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function serializeMultiWorkflow(
|
|
254
|
+
workflows: Array<[string, Declarable]>,
|
|
255
|
+
jobs: Array<[string, Declarable]>,
|
|
256
|
+
triggers: Array<[string, Declarable]>,
|
|
257
|
+
_entities: Map<string, Declarable>,
|
|
258
|
+
entityNames: Map<Declarable, string>,
|
|
259
|
+
): SerializerResult {
|
|
260
|
+
// For multi-workflow, each workflow gets its own file.
|
|
261
|
+
// Jobs and triggers need to be associated with workflows somehow.
|
|
262
|
+
// For now, first workflow gets all unscoped entities.
|
|
263
|
+
const files: Record<string, string> = {};
|
|
264
|
+
let primary = "";
|
|
265
|
+
|
|
266
|
+
for (let i = 0; i < workflows.length; i++) {
|
|
267
|
+
const [name, wf] = workflows[i];
|
|
268
|
+
const doc: Record<string, unknown> = {};
|
|
269
|
+
const props = toYAMLValue((wf as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
270
|
+
|
|
271
|
+
if (props) {
|
|
272
|
+
if (props.name) doc.name = props.name;
|
|
273
|
+
if (props.on) doc.on = convertTriggerProps(props.on);
|
|
274
|
+
if (props.permissions) doc.permissions = convertValueKeys(props.permissions);
|
|
275
|
+
if (props.env) doc.env = props.env;
|
|
276
|
+
if (props.concurrency) doc.concurrency = convertValueKeys(props.concurrency);
|
|
277
|
+
if (props.defaults) doc.defaults = convertValueKeys(props.defaults);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Attach all triggers to first workflow for now
|
|
281
|
+
if (i === 0 && triggers.length > 0) {
|
|
282
|
+
const onSection = (doc.on as Record<string, unknown>) ?? {};
|
|
283
|
+
for (const [, trigger] of triggers) {
|
|
284
|
+
const entityType = (trigger as Record<string, unknown>).entityType as string;
|
|
285
|
+
const eventName = TRIGGER_TYPE_TO_EVENT[entityType];
|
|
286
|
+
if (!eventName) continue;
|
|
287
|
+
const tProps = toYAMLValue((trigger as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
288
|
+
if (tProps && Object.keys(tProps).length > 0) {
|
|
289
|
+
onSection[eventName] = convertValueKeys(tProps);
|
|
290
|
+
} else {
|
|
291
|
+
onSection[eventName] = null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
doc.on = onSection;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Attach all jobs to first workflow for now
|
|
298
|
+
if (i === 0 && jobs.length > 0) {
|
|
299
|
+
const jobsSection: Record<string, unknown> = {};
|
|
300
|
+
for (const [jName, job] of jobs) {
|
|
301
|
+
const jProps = toYAMLValue((job as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
|
|
302
|
+
if (jProps) {
|
|
303
|
+
jobsSection[toKebabCase(jName)] = convertKeys(jProps);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
doc.jobs = jobsSection;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const content = emitYAMLDocument(doc);
|
|
310
|
+
const fileName = `${toKebabCase(name)}.yml`;
|
|
311
|
+
files[fileName] = content;
|
|
312
|
+
|
|
313
|
+
if (i === 0) primary = content;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { primary, files };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Convert trigger props to YAML-compatible form.
|
|
321
|
+
*/
|
|
322
|
+
function convertTriggerProps(on: unknown): unknown {
|
|
323
|
+
if (typeof on !== "object" || on === null) return on;
|
|
324
|
+
if (Array.isArray(on)) return on;
|
|
325
|
+
|
|
326
|
+
const result: Record<string, unknown> = {};
|
|
327
|
+
for (const [key, value] of Object.entries(on as Record<string, unknown>)) {
|
|
328
|
+
const eventName = toSnakeCase(key);
|
|
329
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
330
|
+
result[eventName] = convertValueKeys(value as Record<string, unknown>);
|
|
331
|
+
} else {
|
|
332
|
+
result[eventName] = value;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Emit a complete YAML document from a structured object.
|
|
340
|
+
*/
|
|
341
|
+
function emitYAMLDocument(doc: Record<string, unknown>): string {
|
|
342
|
+
const sections: string[] = [];
|
|
343
|
+
|
|
344
|
+
// Emit in canonical order: name, run-name, on, permissions, env, concurrency, defaults, jobs
|
|
345
|
+
const order = ["name", "run-name", "on", "permissions", "env", "concurrency", "defaults", "jobs"];
|
|
346
|
+
const emitted = new Set<string>();
|
|
347
|
+
|
|
348
|
+
for (const key of order) {
|
|
349
|
+
if (key in doc && doc[key] !== undefined) {
|
|
350
|
+
emitted.add(key);
|
|
351
|
+
const value = doc[key];
|
|
352
|
+
if (value === null) {
|
|
353
|
+
sections.push(`${key}:`);
|
|
354
|
+
} else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
355
|
+
sections.push(`${key}: ${yamlScalar(value)}`);
|
|
356
|
+
} else {
|
|
357
|
+
sections.push(`${key}:` + emitYAML(value, 1));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Remaining keys
|
|
363
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
364
|
+
if (emitted.has(key) || value === undefined) continue;
|
|
365
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
366
|
+
sections.push(`${key}: ${yamlScalar(value)}`);
|
|
367
|
+
} else {
|
|
368
|
+
sections.push(`${key}:` + emitYAML(value, 1));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return sections.join("\n\n") + "\n";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function yamlScalar(value: string | number | boolean): string {
|
|
376
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
377
|
+
if (typeof value === "number") return String(value);
|
|
378
|
+
// Quote strings that might be ambiguous
|
|
379
|
+
if (/^[\d]|[:#\[\]{}|>&*!%@`]|true|false|null|yes|no/i.test(value)) {
|
|
380
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
381
|
+
}
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: github-actions-patterns
|
|
3
|
+
description: GitHub Actions workflow patterns — triggers, jobs, matrix, caching, artifacts, permissions, reusable workflows
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# GitHub Actions Patterns
|
|
7
|
+
|
|
8
|
+
## Workflow Structure
|
|
9
|
+
|
|
10
|
+
A GitHub Actions workflow is a YAML file in `.github/workflows/`. Key sections:
|
|
11
|
+
|
|
12
|
+
- `name:` — display name for the workflow
|
|
13
|
+
- `on:` — trigger events (push, pull_request, schedule, etc.)
|
|
14
|
+
- `permissions:` — GITHUB_TOKEN permissions (least-privilege)
|
|
15
|
+
- `jobs:` — named jobs that run on runners
|
|
16
|
+
|
|
17
|
+
## Trigger Patterns
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// Push to main
|
|
21
|
+
new PushTrigger({ branches: ["main"] })
|
|
22
|
+
|
|
23
|
+
// Pull requests
|
|
24
|
+
new PullRequestTrigger({ branches: ["main"], types: ["opened", "synchronize"] })
|
|
25
|
+
|
|
26
|
+
// Schedule (cron)
|
|
27
|
+
new ScheduleTrigger({ cron: "0 0 * * *" })
|
|
28
|
+
|
|
29
|
+
// Manual dispatch with inputs
|
|
30
|
+
new WorkflowDispatchTrigger({ inputs: { environment: { type: "choice", options: ["staging", "production"] } } })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Matrix Strategy
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
new Strategy({
|
|
37
|
+
matrix: {
|
|
38
|
+
os: ["ubuntu-latest", "windows-latest"],
|
|
39
|
+
"node-version": ["18", "20", "22"],
|
|
40
|
+
},
|
|
41
|
+
"fail-fast": false,
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Caching
|
|
46
|
+
|
|
47
|
+
Use the `cache` option on setup actions, or explicit Cache action:
|
|
48
|
+
```typescript
|
|
49
|
+
SetupNode({ nodeVersion: "22", cache: "npm" })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Permissions (Least Privilege)
|
|
53
|
+
|
|
54
|
+
Always set explicit permissions:
|
|
55
|
+
```typescript
|
|
56
|
+
new Permissions({
|
|
57
|
+
contents: "read",
|
|
58
|
+
"pull-requests": "write",
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Reusable Workflows
|
|
63
|
+
|
|
64
|
+
Call reusable workflows with `ReusableWorkflowCallJob`:
|
|
65
|
+
```typescript
|
|
66
|
+
new ReusableWorkflowCallJob({
|
|
67
|
+
uses: "./.github/workflows/deploy.yml",
|
|
68
|
+
with: { environment: "production" },
|
|
69
|
+
secrets: "inherit",
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Artifacts
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
UploadArtifact({ name: "build", path: "dist/", retentionDays: 7 })
|
|
77
|
+
DownloadArtifact({ name: "build", path: "dist/" })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Environment Protection
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
new Environment({ name: "production", url: "https://example.com" })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Concurrency
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
new Concurrency({
|
|
90
|
+
group: "${{ github.workflow }}-${{ github.ref }}",
|
|
91
|
+
"cancel-in-progress": true,
|
|
92
|
+
})
|
|
93
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow schema fetching — downloads the JSON Schema
|
|
3
|
+
* and caches it locally.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { fetchWithCache, clearCacheFile } from "@intentius/chant/codegen/fetch";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schema URL from SchemaStore.
|
|
12
|
+
*/
|
|
13
|
+
const SCHEMA_URL = "https://json.schemastore.org/github-workflow.json";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the cache file path for the workflow schema.
|
|
17
|
+
*/
|
|
18
|
+
export function getCachePath(): string {
|
|
19
|
+
return join(homedir(), ".chant", "github-workflow-schema.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch the GitHub Actions Workflow JSON Schema.
|
|
24
|
+
* Uses local file caching with 24-hour TTL.
|
|
25
|
+
*/
|
|
26
|
+
export async function fetchWorkflowSchema(force?: boolean): Promise<Buffer> {
|
|
27
|
+
return fetchWithCache(
|
|
28
|
+
{
|
|
29
|
+
url: SCHEMA_URL,
|
|
30
|
+
cacheFile: getCachePath(),
|
|
31
|
+
},
|
|
32
|
+
force,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetch the workflow schema as a Map<typeName, Buffer>
|
|
38
|
+
* compatible with the generatePipeline fetchSchemas callback.
|
|
39
|
+
*
|
|
40
|
+
* Single document keyed by "GitHub::Actions::Workflow" — the parse
|
|
41
|
+
* step will split it into multiple entities.
|
|
42
|
+
*/
|
|
43
|
+
export async function fetchSchemas(force?: boolean): Promise<Map<string, Buffer>> {
|
|
44
|
+
const data = await fetchWorkflowSchema(force);
|
|
45
|
+
const schemas = new Map<string, Buffer>();
|
|
46
|
+
schemas.set("GitHub::Actions::Workflow", data);
|
|
47
|
+
return schemas;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Clear the cached schema file.
|
|
52
|
+
*/
|
|
53
|
+
export function clearCache(): void {
|
|
54
|
+
clearCacheFile(getCachePath());
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Thin entry point for `bun run validate` in lexicon-github.
|
|
4
|
+
*/
|
|
5
|
+
import { validate } from "./validate";
|
|
6
|
+
|
|
7
|
+
const result = await validate();
|
|
8
|
+
|
|
9
|
+
for (const check of result.checks) {
|
|
10
|
+
const status = check.ok ? "OK" : "FAIL";
|
|
11
|
+
const msg = check.error ? ` — ${check.error}` : "";
|
|
12
|
+
console.error(` [${status}] ${check.name}${msg}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!result.success) {
|
|
16
|
+
console.error("Validation failed");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
console.error("All validation checks passed.");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { validate } from "./validate";
|
|
3
|
+
|
|
4
|
+
describe("validate", () => {
|
|
5
|
+
test("exports validate function", () => {
|
|
6
|
+
expect(typeof validate).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Note: Full validation requires generated artifacts to exist.
|
|
10
|
+
// This test verifies the validate module loads correctly.
|
|
11
|
+
// Run `bun run generate` first for full validation tests.
|
|
12
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate generated lexicon-github artifacts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { validateLexiconArtifacts, type ValidateResult } from "@intentius/chant/codegen/validate";
|
|
8
|
+
|
|
9
|
+
export type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
|
|
10
|
+
|
|
11
|
+
const REQUIRED_NAMES = [
|
|
12
|
+
"Workflow", "Job", "ReusableWorkflowCallJob",
|
|
13
|
+
"Step", "Strategy", "Permissions", "Concurrency",
|
|
14
|
+
"Container", "Service", "Environment", "Defaults",
|
|
15
|
+
"PushTrigger", "PullRequestTrigger", "PullRequestTargetTrigger",
|
|
16
|
+
"ScheduleTrigger", "WorkflowDispatchTrigger", "WorkflowCallTrigger",
|
|
17
|
+
"WorkflowRunTrigger", "RepositoryDispatchTrigger",
|
|
18
|
+
"WorkflowInput", "WorkflowOutput", "WorkflowSecret",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate the generated lexicon-github artifacts.
|
|
23
|
+
*/
|
|
24
|
+
export async function validate(opts?: { basePath?: string }): Promise<ValidateResult> {
|
|
25
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
|
|
27
|
+
return validateLexiconArtifacts({
|
|
28
|
+
lexiconJsonFilename: "lexicon-github.json",
|
|
29
|
+
requiredNames: REQUIRED_NAMES,
|
|
30
|
+
basePath,
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/variables.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions predefined context variable references.
|
|
3
|
+
*
|
|
4
|
+
* These provide type-safe access to GitHub and Runner context values
|
|
5
|
+
* that expand to `${{ context.property }}` expressions in YAML.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Expression } from "./expression";
|
|
9
|
+
|
|
10
|
+
export const GitHub = {
|
|
11
|
+
Ref: new Expression("github.ref"),
|
|
12
|
+
RefName: new Expression("github.ref_name"),
|
|
13
|
+
RefType: new Expression("github.ref_type"),
|
|
14
|
+
Sha: new Expression("github.sha"),
|
|
15
|
+
Actor: new Expression("github.actor"),
|
|
16
|
+
TriggeringActor: new Expression("github.triggering_actor"),
|
|
17
|
+
Repository: new Expression("github.repository"),
|
|
18
|
+
RepositoryOwner: new Expression("github.repository_owner"),
|
|
19
|
+
EventName: new Expression("github.event_name"),
|
|
20
|
+
Event: new Expression("github.event"),
|
|
21
|
+
RunId: new Expression("github.run_id"),
|
|
22
|
+
RunNumber: new Expression("github.run_number"),
|
|
23
|
+
RunAttempt: new Expression("github.run_attempt"),
|
|
24
|
+
Workflow: new Expression("github.workflow"),
|
|
25
|
+
WorkflowRef: new Expression("github.workflow_ref"),
|
|
26
|
+
Workspace: new Expression("github.workspace"),
|
|
27
|
+
Token: new Expression("github.token"),
|
|
28
|
+
Job: new Expression("github.job"),
|
|
29
|
+
HeadRef: new Expression("github.head_ref"),
|
|
30
|
+
BaseRef: new Expression("github.base_ref"),
|
|
31
|
+
ServerUrl: new Expression("github.server_url"),
|
|
32
|
+
ApiUrl: new Expression("github.api_url"),
|
|
33
|
+
GraphqlUrl: new Expression("github.graphql_url"),
|
|
34
|
+
Action: new Expression("github.action"),
|
|
35
|
+
ActionPath: new Expression("github.action_path"),
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export const Runner = {
|
|
39
|
+
Os: new Expression("runner.os"),
|
|
40
|
+
Arch: new Expression("runner.arch"),
|
|
41
|
+
Name: new Expression("runner.name"),
|
|
42
|
+
Temp: new Expression("runner.temp"),
|
|
43
|
+
ToolCache: new Expression("runner.tool_cache"),
|
|
44
|
+
} as const;
|