@intentius/chant-lexicon-github 0.1.0 → 0.1.5

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.
@@ -1,41 +1,41 @@
1
1
  {
2
2
  "algorithm": "xxhash64",
3
3
  "artifacts": {
4
- "manifest.json": "83aca58a0237fd57",
5
- "meta.json": "317499a992c9c274",
6
- "types/index.d.ts": "93ce391baebf2afb",
7
- "rules/missing-recommended-inputs.ts": "42f2f3b0b6c7b52c",
8
- "rules/no-raw-expressions.ts": "6359f4d3135ed351",
9
- "rules/use-typed-actions.ts": "eeedcc6145b7a132",
4
+ "manifest.json": "62337ef86c01e6d1",
5
+ "meta.json": "2d1cccf3c19883a7",
6
+ "types/index.d.ts": "9d6f903cca5de1e9",
7
+ "rules/deprecated-action-version.ts": "9ec91b190557f25f",
10
8
  "rules/extract-inline-structs.ts": "646dce2eccf1fab4",
9
+ "rules/job-timeout.ts": "68f85c741c3d0ae8",
10
+ "rules/validate-concurrency.ts": "c12a1aa4ee8badb5",
11
11
  "rules/file-job-limit.ts": "7c46a302f6ba2744",
12
+ "rules/use-typed-actions.ts": "eeedcc6145b7a132",
13
+ "rules/suggest-cache.ts": "c45f7659afde2f15",
12
14
  "rules/detect-secrets.ts": "999e6c5b4e048764",
13
- "rules/deprecated-action-version.ts": "9ec91b190557f25f",
14
15
  "rules/no-hardcoded-secrets.ts": "adcdb23f0480a4b7",
15
- "rules/job-timeout.ts": "68f85c741c3d0ae8",
16
- "rules/use-condition-builders.ts": "7406215df1f79fb8",
17
- "rules/suggest-cache.ts": "c45f7659afde2f15",
18
- "rules/validate-concurrency.ts": "c12a1aa4ee8badb5",
19
16
  "rules/use-matrix-builder.ts": "6b1c0ebf43378805",
20
- "rules/gha020.ts": "36ef5e141524bab0",
17
+ "rules/missing-recommended-inputs.ts": "42f2f3b0b6c7b52c",
18
+ "rules/use-condition-builders.ts": "7406215df1f79fb8",
19
+ "rules/no-raw-expressions.ts": "6359f4d3135ed351",
21
20
  "rules/gha017.ts": "ff1c08fdedf83afa",
22
- "rules/gha027.ts": "6071aedb178c90a8",
21
+ "rules/gha018.ts": "46acbe27d4c0c817",
22
+ "rules/gha026.ts": "5ace32df6cb850af",
23
23
  "rules/gha019.ts": "d9184093f36ac167",
24
+ "rules/gha023.ts": "2d00140d63591c9",
25
+ "rules/gha027.ts": "6071aedb178c90a8",
26
+ "rules/yaml-helpers.ts": "df426df288c175c9",
24
27
  "rules/gha024.ts": "ed75a2900c8bf12d",
25
- "rules/gha009.ts": "df140c0cac573bc4",
26
- "rules/gha026.ts": "5ace32df6cb850af",
28
+ "rules/gha025.ts": "d196899f490521ba",
29
+ "rules/gha006.ts": "baca27402ba18d",
27
30
  "rules/gha028.ts": "9c1ba1eb9a93d8b6",
28
31
  "rules/gha022.ts": "41038ee697a497d1",
29
- "rules/gha018.ts": "46acbe27d4c0c817",
30
- "rules/yaml-helpers.ts": "df426df288c175c9",
31
32
  "rules/gha011.ts": "105e2d4faeaa9977",
32
- "rules/gha025.ts": "d196899f490521ba",
33
- "rules/gha006.ts": "baca27402ba18d",
34
- "rules/gha023.ts": "2d00140d63591c9",
33
+ "rules/gha020.ts": "36ef5e141524bab0",
34
+ "rules/gha009.ts": "df140c0cac573bc4",
35
35
  "rules/gha021.ts": "da7e2491926d1817",
36
36
  "skills/chant-github.md": "dc14037edaace2af",
37
37
  "skills/chant-github-patterns.md": "7678ef5c6b4b9bdf",
38
38
  "skills/chant-github-security.md": "f3fcfcd84475b73c"
39
39
  },
40
- "composite": "18c84a14d05392cc"
40
+ "composite": "92e041a88a3b5d8d"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/dist/meta.json CHANGED
@@ -17,7 +17,12 @@
17
17
  "Environment": {
18
18
  "resourceType": "GitHub::Actions::Environment",
19
19
  "kind": "property",
20
- "lexicon": "github"
20
+ "lexicon": "github",
21
+ "constraints": {
22
+ "deployment": {
23
+ "default": true
24
+ }
25
+ }
21
26
  },
22
27
  "Job": {
23
28
  "resourceType": "GitHub::Actions::Job",
@@ -40,6 +40,8 @@ export declare class Environment {
40
40
  constructor(props: {
41
41
  /** The name of the environment configured in the repo. */
42
42
  name: string;
43
+ /** Whether to create a deployment for this job. Setting to false lets the job use environment secrets and variables without creating a deployment record. Wait timers and required reviewers still apply. */
44
+ deployment?: boolean | string;
43
45
  /** A deployment URL */
44
46
  url?: string;
45
47
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -43,7 +43,7 @@
43
43
  "prepack": "bun run generate && bun run bundle && bun run validate"
44
44
  },
45
45
  "devDependencies": {
46
- "@intentius/chant": "0.1.0",
46
+ "@intentius/chant": "0.1.4",
47
47
  "typescript": "^5.9.3"
48
48
  },
49
49
  "peerDependencies": {
@@ -40,6 +40,8 @@ export declare class Environment {
40
40
  constructor(props: {
41
41
  /** The name of the environment configured in the repo. */
42
42
  name: string;
43
+ /** Whether to create a deployment for this job. Setting to false lets the job use environment secrets and variables without creating a deployment record. Wait timers and required reviewers still apply. */
44
+ deployment?: boolean | string;
43
45
  /** A deployment URL */
44
46
  url?: string;
45
47
  });
@@ -17,7 +17,12 @@
17
17
  "Environment": {
18
18
  "resourceType": "GitHub::Actions::Environment",
19
19
  "kind": "property",
20
- "lexicon": "github"
20
+ "lexicon": "github",
21
+ "constraints": {
22
+ "deployment": {
23
+ "default": true
24
+ }
25
+ }
21
26
  },
22
27
  "Job": {
23
28
  "resourceType": "GitHub::Actions::Job",
@@ -197,16 +197,122 @@ describe("githubSerializer.serialize", () => {
197
197
  });
198
198
  });
199
199
 
200
+ describe("Workflow.props.jobs", () => {
201
+ test("single-workflow: inline Job entity is serialized into the jobs section", () => {
202
+ const entities = new Map<string, Declarable>();
203
+ entities.set("workflow", new MockWorkflow({
204
+ name: "CI",
205
+ on: { push: { branches: ["main"] } },
206
+ jobs: {
207
+ build: new MockJob({ "runs-on": "ubuntu-latest" }),
208
+ },
209
+ }));
210
+
211
+ const output = githubSerializer.serialize(entities) as string;
212
+ expect(output).toContain("jobs:");
213
+ expect(output).toContain("build:");
214
+ expect(output).toContain("runs-on: ubuntu-latest");
215
+ });
216
+
217
+ test("single-workflow: inline Job with steps serializes step content", () => {
218
+ const entities = new Map<string, Declarable>();
219
+ entities.set("workflow", new MockWorkflow({
220
+ name: "CI",
221
+ on: { push: null },
222
+ jobs: {
223
+ test: new MockJob({
224
+ "runs-on": "ubuntu-latest",
225
+ steps: [
226
+ new MockStep({ name: "Run tests", run: "npm test" }),
227
+ ],
228
+ }),
229
+ },
230
+ }));
231
+
232
+ const output = githubSerializer.serialize(entities) as string;
233
+ expect(output).toContain("test:");
234
+ expect(output).toContain("run: npm test");
235
+ });
236
+
237
+ test("single-workflow: standalone Job export still works when Workflow.props.jobs is absent", () => {
238
+ const entities = new Map<string, Declarable>();
239
+ entities.set("workflow", new MockWorkflow({
240
+ name: "CI",
241
+ on: { push: null },
242
+ }));
243
+ entities.set("build", new MockJob({ "runs-on": "ubuntu-latest" }));
244
+
245
+ const output = githubSerializer.serialize(entities) as string;
246
+ expect(output).toContain("jobs:");
247
+ expect(output).toContain("build:");
248
+ expect(output).toContain("runs-on: ubuntu-latest");
249
+ });
250
+
251
+ test("multi-workflow: each workflow gets its own inline jobs", () => {
252
+ const entities = new Map<string, Declarable>();
253
+ entities.set("ci", new MockWorkflow({
254
+ name: "CI",
255
+ on: { push: { branches: ["main"] } },
256
+ jobs: { build: new MockJob({ "runs-on": "ubuntu-latest", name: "Build" }) },
257
+ }));
258
+ entities.set("deploy", new MockWorkflow({
259
+ name: "Deploy",
260
+ on: { workflowDispatch: null },
261
+ jobs: { release: new MockJob({ "runs-on": "ubuntu-latest", name: "Release" }) },
262
+ }));
263
+
264
+ const result = githubSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
265
+ expect(result.primary).toContain("name: CI");
266
+ expect(result.primary).toContain("build:");
267
+ expect(result.primary).not.toContain("release:");
268
+ expect(result.files["deploy.yml"]).toContain("name: Deploy");
269
+ expect(result.files["deploy.yml"]).toContain("release:");
270
+ expect(result.files["deploy.yml"]).not.toContain("build:");
271
+ });
272
+
273
+ test("multi-workflow: workflow with no props.jobs gets no jobs section", () => {
274
+ const entities = new Map<string, Declarable>();
275
+ entities.set("ci", new MockWorkflow({
276
+ name: "CI",
277
+ on: { push: null },
278
+ jobs: { build: new MockJob({ "runs-on": "ubuntu-latest" }) },
279
+ }));
280
+ entities.set("notify", new MockWorkflow({
281
+ name: "Notify",
282
+ on: { workflowDispatch: null },
283
+ // no jobs
284
+ }));
285
+
286
+ const result = githubSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
287
+ expect(result.primary).toContain("jobs:");
288
+ expect(result.files["notify.yml"]).not.toContain("jobs:");
289
+ });
290
+
291
+ test("multi-workflow: standalone Job exports fall back to first workflow (backwards compat for composites)", () => {
292
+ const entities = new Map<string, Declarable>();
293
+ entities.set("ci", new MockWorkflow({ name: "CI", on: { push: null } }));
294
+ entities.set("deploy", new MockWorkflow({ name: "Deploy", on: { workflowDispatch: null } }));
295
+ entities.set("build", new MockJob({ "runs-on": "ubuntu-latest" }));
296
+
297
+ const result = githubSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
298
+ // standalone job goes to first workflow only
299
+ expect(result.primary).toContain("build:");
300
+ expect(result.files["deploy.yml"]).not.toContain("jobs:");
301
+ });
302
+ });
303
+
200
304
  describe("multi-workflow output", () => {
201
- test("produces SerializerResult with files", () => {
305
+ test("produces SerializerResult with files, each containing their own jobs", () => {
202
306
  const entities = new Map<string, Declarable>();
203
307
  entities.set("ci", new MockWorkflow({
204
308
  name: "CI",
205
309
  on: { push: { branches: ["main"] } },
310
+ jobs: { build: new MockJob({ "runs-on": "ubuntu-latest" }) },
206
311
  }));
207
312
  entities.set("deploy", new MockWorkflow({
208
313
  name: "Deploy",
209
314
  on: { push: { branches: ["main"] } },
315
+ jobs: { ship: new MockJob({ "runs-on": "ubuntu-latest" }) },
210
316
  }));
211
317
 
212
318
  const output = githubSerializer.serialize(entities);
@@ -215,6 +321,8 @@ describe("multi-workflow output", () => {
215
321
  expect(result.files).toBeDefined();
216
322
  expect(Object.keys(result.files).length).toBe(2);
217
323
  expect(result.primary).toContain("name: CI");
324
+ expect(result.primary).toContain("build:");
325
+ expect(result.files["deploy.yml"]).toContain("ship:");
218
326
  });
219
327
  });
220
328
 
package/src/serializer.ts CHANGED
@@ -109,6 +109,78 @@ function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unkn
109
109
  return walkValue(preprocessed, entityNames, githubVisitor(entityNames));
110
110
  }
111
111
 
112
+ // ── Inline job serialization ──────────────────────────────────────
113
+
114
+ const JOB_ENTITY_TYPES = new Set([
115
+ "GitHub::Actions::Job",
116
+ "GitHub::Actions::ReusableWorkflowCallJob",
117
+ ]);
118
+
119
+ /**
120
+ * Serialize a value from Workflow.props.jobs into a YAML job object.
121
+ *
122
+ * When the value is a Job entity (resource Declarable), its props are inlined
123
+ * directly rather than emitted as a resource reference. This allows callers to
124
+ * pass `new Job({...})` directly inside `Workflow({ jobs: { name: new Job({...}) } })`.
125
+ *
126
+ * Plain objects are accepted too (JSON-style job definitions).
127
+ */
128
+ function serializeInlineJob(
129
+ jobValue: unknown,
130
+ entityNames: Map<Declarable, string>,
131
+ ): Record<string, unknown> | undefined {
132
+ if (!jobValue || typeof jobValue !== "object") return undefined;
133
+ const obj = jobValue as Record<string, unknown>;
134
+
135
+ if ("entityType" in obj && "props" in obj && JOB_ENTITY_TYPES.has(obj.entityType as string)) {
136
+ // Job entity: serialize its props inline (not as a resource reference)
137
+ const props = toYAMLValue(obj.props, entityNames);
138
+ return props && typeof props === "object"
139
+ ? convertKeys(props as Record<string, unknown>)
140
+ : undefined;
141
+ }
142
+
143
+ // Plain object job definition (JSON-style)
144
+ return convertKeys(convertValueKeys(obj) as Record<string, unknown>);
145
+ }
146
+
147
+ /**
148
+ * Build a jobs section from Workflow.props.jobs entries.
149
+ * Returns undefined when props.jobs is absent or empty.
150
+ */
151
+ function buildInlineJobsSection(
152
+ props: Record<string, unknown>,
153
+ entityNames: Map<Declarable, string>,
154
+ ): Record<string, unknown> | undefined {
155
+ if (!props.jobs || typeof props.jobs !== "object" || Array.isArray(props.jobs)) return undefined;
156
+ const jobsSection: Record<string, unknown> = {};
157
+ for (const [jName, jobValue] of Object.entries(props.jobs as Record<string, unknown>)) {
158
+ const serialized = serializeInlineJob(jobValue, entityNames);
159
+ if (serialized) jobsSection[toKebabCase(jName)] = serialized;
160
+ }
161
+ return Object.keys(jobsSection).length > 0 ? jobsSection : undefined;
162
+ }
163
+
164
+ /**
165
+ * Build a jobs section from standalone top-level Job entity exports.
166
+ * Returns undefined when there are no standalone jobs.
167
+ */
168
+ function buildStandaloneJobsSection(
169
+ jobs: Array<[string, Declarable]>,
170
+ entityNames: Map<Declarable, string>,
171
+ ): Record<string, unknown> | undefined {
172
+ if (jobs.length === 0) return undefined;
173
+ const jobsSection: Record<string, unknown> = {};
174
+ for (const [name, job] of jobs) {
175
+ const jProps = toYAMLValue(
176
+ (job as unknown as Record<string, unknown>).props,
177
+ entityNames,
178
+ ) as Record<string, unknown> | undefined;
179
+ if (jProps) jobsSection[toKebabCase(name)] = convertKeys(jProps);
180
+ }
181
+ return Object.keys(jobsSection).length > 0 ? jobsSection : undefined;
182
+ }
183
+
112
184
  // ── Key conversion for YAML output ────────────────────────────────
113
185
 
114
186
  /**
@@ -166,7 +238,7 @@ export const githubSerializer: Serializer = {
166
238
  for (const [name, entity] of entities) {
167
239
  if (isPropertyDeclarable(entity)) continue;
168
240
 
169
- const entityType = (entity as Record<string, unknown>).entityType as string;
241
+ const entityType = (entity as unknown as Record<string, unknown>).entityType as string;
170
242
  if (entityType === "GitHub::Actions::Workflow") {
171
243
  workflows.push([name, entity]);
172
244
  } else if (entityType === "GitHub::Actions::Job" || entityType === "GitHub::Actions::ReusableWorkflowCallJob") {
@@ -200,7 +272,7 @@ function serializeSingleWorkflow(
200
272
  // Workflow-level properties
201
273
  if (workflows.length > 0) {
202
274
  const [, wf] = workflows[0];
203
- const props = toYAMLValue((wf as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
275
+ const props = toYAMLValue((wf as unknown as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
204
276
  if (props) {
205
277
  if (props.name) doc.name = props.name;
206
278
  if (props["run-name"] || props.runName) doc["run-name"] = props["run-name"] ?? props.runName;
@@ -220,11 +292,11 @@ function serializeSingleWorkflow(
220
292
  if (triggers.length > 0) {
221
293
  const onSection = (doc.on as Record<string, unknown>) ?? {};
222
294
  for (const [, trigger] of triggers) {
223
- const entityType = (trigger as Record<string, unknown>).entityType as string;
295
+ const entityType = (trigger as unknown as Record<string, unknown>).entityType as string;
224
296
  const eventName = TRIGGER_TYPE_TO_EVENT[entityType];
225
297
  if (!eventName) continue;
226
298
 
227
- const props = toYAMLValue((trigger as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
299
+ const props = toYAMLValue((trigger as unknown as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
228
300
  if (props && Object.keys(props).length > 0) {
229
301
  onSection[eventName] = convertValueKeys(props);
230
302
  } else {
@@ -234,18 +306,14 @@ function serializeSingleWorkflow(
234
306
  doc.on = onSection;
235
307
  }
236
308
 
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;
309
+ // Jobs: prefer Workflow.props.jobs (raw) when present; fall back to standalone Job exports
310
+ let jobsSection: Record<string, unknown> | undefined;
311
+ if (workflows.length > 0) {
312
+ const rawProps = (workflows[0][1] as unknown as Record<string, unknown>).props as Record<string, unknown>;
313
+ jobsSection = buildInlineJobsSection(rawProps, entityNames);
248
314
  }
315
+ if (!jobsSection) jobsSection = buildStandaloneJobsSection(jobs, entityNames);
316
+ if (jobsSection) doc.jobs = jobsSection;
249
317
 
250
318
  return emitYAMLDocument(doc);
251
319
  }
@@ -257,16 +325,13 @@ function serializeMultiWorkflow(
257
325
  _entities: Map<string, Declarable>,
258
326
  entityNames: Map<Declarable, string>,
259
327
  ): 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
328
  const files: Record<string, string> = {};
264
329
  let primary = "";
265
330
 
266
331
  for (let i = 0; i < workflows.length; i++) {
267
332
  const [name, wf] = workflows[i];
268
333
  const doc: Record<string, unknown> = {};
269
- const props = toYAMLValue((wf as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
334
+ const props = toYAMLValue((wf as unknown as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
270
335
 
271
336
  if (props) {
272
337
  if (props.name) doc.name = props.name;
@@ -281,10 +346,10 @@ function serializeMultiWorkflow(
281
346
  if (i === 0 && triggers.length > 0) {
282
347
  const onSection = (doc.on as Record<string, unknown>) ?? {};
283
348
  for (const [, trigger] of triggers) {
284
- const entityType = (trigger as Record<string, unknown>).entityType as string;
349
+ const entityType = (trigger as unknown as Record<string, unknown>).entityType as string;
285
350
  const eventName = TRIGGER_TYPE_TO_EVENT[entityType];
286
351
  if (!eventName) continue;
287
- const tProps = toYAMLValue((trigger as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
352
+ const tProps = toYAMLValue((trigger as unknown as Record<string, unknown>).props, entityNames) as Record<string, unknown> | undefined;
288
353
  if (tProps && Object.keys(tProps).length > 0) {
289
354
  onSection[eventName] = convertValueKeys(tProps);
290
355
  } else {
@@ -294,17 +359,12 @@ function serializeMultiWorkflow(
294
359
  doc.on = onSection;
295
360
  }
296
361
 
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
- }
362
+ // Jobs: use Workflow.props.jobs when defined, otherwise fall back to standalone exports
363
+ // (standalone exports only assigned to first workflow, for backwards compat with composites)
364
+ const rawProps = (wf as unknown as Record<string, unknown>).props as Record<string, unknown>;
365
+ const inlineJobs = buildInlineJobsSection(rawProps, entityNames);
366
+ const jobsSection = inlineJobs ?? (i === 0 ? buildStandaloneJobsSection(jobs, entityNames) : undefined);
367
+ if (jobsSection) doc.jobs = jobsSection;
308
368
 
309
369
  const content = emitYAMLDocument(doc);
310
370
  const fileName = `${toKebabCase(name)}.yml`;