@intentius/chant-lexicon-github 0.118.0 → 0.120.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.
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;GAEG;AA8EH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+B9E"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;GAEG;AAmHH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+B9E"}
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/codegen/parse.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAChF,OAAO,EAEL,kBAAkB,IAAI,sBAAsB,EAG7C,MAAM,sCAAsC,CAAC;AAI9C,YAAY,EAAE,mBAAmB,EAAE,CAAC;AACpC,OAAO,EAAE,sBAAsB,IAAI,kBAAkB,EAAE,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAyFD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,EAAE,CAkB9E;AA0gBD;;GAEG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG1D"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/codegen/parse.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAChF,OAAO,EAEL,kBAAkB,IAAI,sBAAsB,EAG7C,MAAM,sCAAsC,CAAC;AAI9C,YAAY,EAAE,mBAAmB,EAAE,CAAC;AACpC,OAAO,EAAE,sBAAsB,IAAI,kBAAkB,EAAE,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAyFD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,EAAE,CAkB9E;AAkiBD;;GAEG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG1D"}
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "a0a217bf875f2999a78da91e06dd0fe33165e52b6077800a76654f75c9e8354d",
4
+ "manifest.json": "b90d05876520e11b56b19a89a89e02b3be6ba6410d8220685e5e2c70a8893868",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
- "types/index.d.ts": "bdd54a4145a39a9dd4f7bbc8c500191acb6bc9057a87da0040475afac84db397",
6
+ "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
8
8
  "rules/detect-secrets.ts": "5829832eb7b43993424971041ed386b196850183bffd34bddd6439f99061e4ae",
9
9
  "rules/extract-inline-structs.ts": "8f8147c200de8ffd0620dfdcca2e2cea76a520303ce431479ebecad6aa26aab5",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "db3b08fa2fb816149260d6ea9f821c842bbd6317bcb181114380fbb14fd7a734"
81
+ "composite": "9c78bc700120d8f4aa2ea98b522a590fa33de3de88907b30b1080d3cc46a9bbe"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.118.0",
3
+ "version": "0.120.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
@@ -12,6 +12,7 @@ resource_type: GitHub::Actions::Job
12
12
 
13
13
  - `runs-on` (`string | string[]`, required): The type of machine to run the job on. The machine can be either a GitHub-hosted runner, or a self-hosted runner.
14
14
  - `cache-mode` (`"read" | "write" | "write-only" | "none"`, optional)
15
+ - `comment` (`string`, optional): YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line
15
16
  - `concurrency` (`Concurrency | string`, optional): Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level. When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true.
16
17
  - `container` (`Container | string`, optional): A container to run any steps in a job that don't already specify a container. If you have steps that use both script and container actions, the container actions will run as sibling containers on the same network with the same volume mounts. If you do not set a container, all steps will run directly on the host specified by runs-on unless a step refers to an action configured to run in a container.
17
18
  - `continue-on-error` (`boolean`, optional): Prevents a workflow run from failing when a job fails. Set to true to allow a workflow run to pass when this job fails.
@@ -12,6 +12,7 @@ resource_type: GitHub::Actions::ReusableWorkflowCallJob
12
12
 
13
13
  - `uses` (`string`, required): The location and version of a reusable workflow file to run as a job, of the form './{path/to}/{localfile}.yml' or '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security.
14
14
  - `cache-mode` (`"read" | "write" | "write-only" | "none"`, optional)
15
+ - `comment` (`string`, optional): YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line
15
16
  - `concurrency` (`string | Record<string, any>`, optional): Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level. When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true.
16
17
  - `if` (`boolean | number | string`, optional): You can use the if conditional to prevent a job from running unless a condition is met. You can use any supported context and expression to create a conditional. Expressions in an if conditional do not require the ${{ }} syntax. For more information, see https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions.
17
18
  - `name` (`string`, optional): The name of the job displayed on GitHub.
@@ -13,6 +13,7 @@ resource_type: GitHub::Actions::Workflow
13
13
  - `jobs` (`Record<string, unknown>`, required): A workflow run is made up of one or more jobs. Jobs run in parallel by default. To run jobs sequentially, you can define dependencies on other jobs using the jobs.<job_id>.needs keyword. Each job runs in a fresh instance of the virtual environment specified by runs-on. You can run an unlimited number of jobs as long as you are within the workflow usage limits. For more information, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#usage-limits.
14
14
  - `on` (`Record<string, unknown>`, required): The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.
15
15
  - `cache-mode` (`"read" | "write" | "write-only" | "none"`, optional)
16
+ - `comment` (`string`, optional): YAML comment lines written at the top of the file, above `name:`. Not a workflow key; each line of the string becomes a `#` line
16
17
  - `concurrency` (`Concurrency | string`, optional): Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level. When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true.
17
18
  - `defaults` (`Defaults`, optional): A map of default settings that will apply to all jobs in the workflow.
18
19
  - `env` (`Record<string, string>`, optional): A map of environment variables that are available to all jobs and steps in the workflow.
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,6BAA6B,CAAC;AAuNhF;;GAEG;AACH,eAAO,MAAM,gBAAgB,EAAE,UAwD9B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,6BAA6B,CAAC;AAsRhF;;GAEG;AACH,eAAO,MAAM,gBAAgB,EAAE,UAwD9B,CAAC"}
@@ -23,8 +23,8 @@ export declare class Container {
23
23
  env?: Record<string, string>;
24
24
  /** Docker options */
25
25
  options?: string;
26
- /** Exposed ports */
27
- ports?: number[];
26
+ /** Exposed ports: a container port (published on a random host port) or "host:container" */
27
+ ports?: (number | string)[];
28
28
  /** Volume mounts */
29
29
  volumes?: string[];
30
30
  });
@@ -52,6 +52,8 @@ export declare class Job {
52
52
  /** The type of machine to run the job on. The machine can be either a GitHub-hosted runner, or a self-hosted runner. */
53
53
  "runs-on": string | string[];
54
54
  "cache-mode"?: "read" | "write" | "write-only" | "none";
55
+ /** YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line */
56
+ comment?: string;
55
57
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
56
58
  You can also specify concurrency at the workflow level.
57
59
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
@@ -159,6 +161,8 @@ export declare class ReusableWorkflowCallJob {
159
161
  /** The location and version of a reusable workflow file to run as a job, of the form './{path/to}/{localfile}.yml' or '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security. */
160
162
  uses: string;
161
163
  "cache-mode"?: "read" | "write" | "write-only" | "none";
164
+ /** YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line */
165
+ comment?: string;
162
166
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
163
167
  You can also specify concurrency at the workflow level.
164
168
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
@@ -193,13 +197,16 @@ export declare class Service {
193
197
  credentials?: Record<string, string>;
194
198
  env?: Record<string, string>;
195
199
  options?: string;
196
- ports?: number[];
200
+ /** Exposed ports: a container port (published on a random host port) or "host:container", which a step on the runner reaches at localhost:<host> */
201
+ ports?: (number | string)[];
197
202
  volumes?: string[];
198
203
  });
199
204
  }
200
205
 
201
206
  export declare class Step {
202
207
  constructor(props: {
208
+ /** YAML comment lines written above the step. Not a step key; each line of the string becomes a `#` line */
209
+ comment?: string;
203
210
  /** Continue on error */
204
211
  "continue-on-error"?: boolean;
205
212
  /** Environment variables */
@@ -218,8 +225,8 @@ export declare class Step {
218
225
  "timeout-minutes"?: number;
219
226
  /** Action reference */
220
227
  uses?: string;
221
- /** Action inputs */
222
- with?: Record<string, string>;
228
+ /** Action inputs. The runner reads each as a string; a number or boolean renders as written (`fetch-depth: 0`) */
229
+ with?: Record<string, string | number | boolean>;
223
230
  /** Working directory */
224
231
  "working-directory"?: string;
225
232
  });
@@ -244,6 +251,8 @@ You can run an unlimited number of jobs as long as you are within the workflow u
244
251
  /** The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows. */
245
252
  on: Record<string, unknown>;
246
253
  "cache-mode"?: "read" | "write" | "write-only" | "none";
254
+ /** YAML comment lines written at the top of the file, above `name:`. Not a workflow key; each line of the string becomes a `#` line */
255
+ comment?: string;
247
256
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
248
257
  You can also specify concurrency at the workflow level.
249
258
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.118.0",
3
+ "version": "0.120.0",
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",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.118.0",
64
+ "@intentius/chant": "^0.120.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -57,6 +57,43 @@ The generated file includes:
57
57
  | \`timeoutMinutes: 15\` | \`timeout-minutes: 15\` | camelCase → kebab-case for job properties |
58
58
  | \`new Step({ uses: "actions/checkout@v7" })\` | \`- uses: actions/checkout@v7\` | Steps serialize as sequence entries |
59
59
 
60
+ ## Comments
61
+
62
+ \`Workflow\`, \`Job\` and \`Step\` take a \`comment\`. It is not a workflow key: the serializer writes it as \`#\` lines, one per line of the string, so the reasons a hand-written workflow keeps as YAML comments stay in the file a reader opens on GitHub. A workflow's comment is the file's header, above \`name:\`; a job's goes above its key and a step's above its \`- \`. The Forgejo lexicon renders the same declarations, so its files carry them too.
63
+
64
+ \`\`\`typescript
65
+ export const publish = new Workflow({
66
+ comment: "Publishes on a version tag.\\nKeep the file name: npm's trusted publisher names it.",
67
+ name: "Publish",
68
+ on: { push: { tags: ["v*"] } },
69
+ jobs: {
70
+ publish: new Job({
71
+ "runs-on": "ubuntu-latest",
72
+ steps: [new Step({ comment: "The registry times out now and then; retry once.", run: "npm publish || npm publish" })],
73
+ }),
74
+ },
75
+ });
76
+ \`\`\`
77
+
78
+ \`\`\`yaml
79
+ # Publishes on a version tag.
80
+ # Keep the file name: npm's trusted publisher names it.
81
+
82
+ name: Publish
83
+
84
+ on:
85
+ push:
86
+ tags:
87
+ - v*
88
+
89
+ jobs:
90
+ publish:
91
+ runs-on: ubuntu-latest
92
+ steps:
93
+ # The registry times out now and then; retry once.
94
+ - run: npm publish || npm publish
95
+ \`\`\`
96
+
60
97
  ## Validating locally
61
98
 
62
99
  The output is standard GitHub Actions YAML. Validate locally with \`act\` or push to GitHub:
@@ -175,7 +175,7 @@ function extractEntity(
175
175
  resource: {
176
176
  typeName: entity.typeName,
177
177
  description: entity.description,
178
- properties: buildFallbackProperties(entity.typeName),
178
+ properties: addCommentProperty(entity.typeName, buildFallbackProperties(entity.typeName)),
179
179
  attributes: [],
180
180
  deprecatedProperties: [],
181
181
  },
@@ -197,6 +197,7 @@ function extractEntity(
197
197
  }
198
198
  }
199
199
  }
200
+ addCommentProperty(entity.typeName, properties);
200
201
 
201
202
  return {
202
203
  resource: {
@@ -490,7 +491,7 @@ const PROPERTY_OVERRIDES: Record<string, Record<string, string>> = {
490
491
  },
491
492
  "GitHub::Actions::Step": {
492
493
  env: "Record<string, string>",
493
- with: "Record<string, string>",
494
+ with: "Record<string, string | number | boolean>",
494
495
  "if": "string",
495
496
  "timeout-minutes": "number",
496
497
  "continue-on-error": "boolean",
@@ -500,6 +501,29 @@ const PROPERTY_OVERRIDES: Record<string, Record<string, string>> = {
500
501
  },
501
502
  };
502
503
 
504
+ // ── Comments ──────────────────────────────────────────────────────
505
+
506
+ /**
507
+ * Entities that carry a `comment`: not a workflow key, but `# ...` lines the
508
+ * serializer writes above the entry (above `name:` for a workflow, the file's
509
+ * header), so the reasons a hand-written workflow keeps as YAML comments
510
+ * reach the rendered file (#3667).
511
+ */
512
+ const COMMENTED_ENTITIES: Record<string, string> = {
513
+ "GitHub::Actions::Workflow": "YAML comment lines written at the top of the file, above `name:`. Not a workflow key; each line of the string becomes a `#` line",
514
+ "GitHub::Actions::Job": "YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line",
515
+ "GitHub::Actions::ReusableWorkflowCallJob": "YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line",
516
+ "GitHub::Actions::Step": "YAML comment lines written above the step. Not a step key; each line of the string becomes a `#` line",
517
+ };
518
+
519
+ function addCommentProperty(typeName: string, properties: ParsedProperty[]): ParsedProperty[] {
520
+ const description = COMMENTED_ENTITIES[typeName];
521
+ if (description && !properties.some((p) => p.name === "comment")) {
522
+ properties.push({ name: "comment", tsType: "string", required: false, description, constraints: {} });
523
+ }
524
+ return properties;
525
+ }
526
+
503
527
  // ── Fallback properties ───────────────────────────────────────────
504
528
 
505
529
  /**
@@ -548,7 +572,7 @@ function buildFallbackProperties(typeName: string): ParsedProperty[] {
548
572
  { name: "name", tsType: "string", required: false, description: "Step display name" },
549
573
  { name: "uses", tsType: "string", required: false, description: "Action reference" },
550
574
  { name: "run", tsType: "string", required: false, description: "Shell command" },
551
- { name: "with", tsType: "Record<string, string>", required: false, description: "Action inputs" },
575
+ { name: "with", tsType: "Record<string, string | number | boolean>", required: false, description: "Action inputs. The runner reads each as a string; a number or boolean renders as written (`fetch-depth: 0`)" },
552
576
  { name: "env", tsType: "Record<string, string>", required: false, description: "Environment variables" },
553
577
  { name: "if", tsType: "string", required: false, description: "Conditional expression" },
554
578
  { name: "id", tsType: "string", required: false, description: "Step ID for output references" },
@@ -583,7 +607,7 @@ function buildFallbackProperties(typeName: string): ParsedProperty[] {
583
607
  { name: "image", tsType: "string", required: true, description: "Docker image" },
584
608
  { name: "credentials", tsType: "Record<string, string>", required: false, description: "Registry credentials" },
585
609
  { name: "env", tsType: "Record<string, string>", required: false, description: "Environment variables" },
586
- { name: "ports", tsType: "number[]", required: false, description: "Exposed ports" },
610
+ { name: "ports", tsType: "(number | string)[]", required: false, description: "Exposed ports: a container port (published on a random host port) or \"host:container\"" },
587
611
  { name: "volumes", tsType: "string[]", required: false, description: "Volume mounts" },
588
612
  { name: "options", tsType: "string", required: false, description: "Docker options" },
589
613
  ],
@@ -591,7 +615,7 @@ function buildFallbackProperties(typeName: string): ParsedProperty[] {
591
615
  { name: "image", tsType: "string", required: true, description: "Docker image" },
592
616
  { name: "credentials", tsType: "Record<string, string>", required: false },
593
617
  { name: "env", tsType: "Record<string, string>", required: false },
594
- { name: "ports", tsType: "number[]", required: false },
618
+ { name: "ports", tsType: "(number | string)[]", required: false, description: "Exposed ports: a container port (published on a random host port) or \"host:container\", which a step on the runner reaches at localhost:<host>" },
595
619
  { name: "volumes", tsType: "string[]", required: false },
596
620
  { name: "options", tsType: "string", required: false },
597
621
  ],
@@ -23,8 +23,8 @@ export declare class Container {
23
23
  env?: Record<string, string>;
24
24
  /** Docker options */
25
25
  options?: string;
26
- /** Exposed ports */
27
- ports?: number[];
26
+ /** Exposed ports: a container port (published on a random host port) or "host:container" */
27
+ ports?: (number | string)[];
28
28
  /** Volume mounts */
29
29
  volumes?: string[];
30
30
  });
@@ -52,6 +52,8 @@ export declare class Job {
52
52
  /** The type of machine to run the job on. The machine can be either a GitHub-hosted runner, or a self-hosted runner. */
53
53
  "runs-on": string | string[];
54
54
  "cache-mode"?: "read" | "write" | "write-only" | "none";
55
+ /** YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line */
56
+ comment?: string;
55
57
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
56
58
  You can also specify concurrency at the workflow level.
57
59
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
@@ -159,6 +161,8 @@ export declare class ReusableWorkflowCallJob {
159
161
  /** The location and version of a reusable workflow file to run as a job, of the form './{path/to}/{localfile}.yml' or '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security. */
160
162
  uses: string;
161
163
  "cache-mode"?: "read" | "write" | "write-only" | "none";
164
+ /** YAML comment lines written above the job's key. Not a job key; each line of the string becomes a `#` line */
165
+ comment?: string;
162
166
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
163
167
  You can also specify concurrency at the workflow level.
164
168
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
@@ -193,13 +197,16 @@ export declare class Service {
193
197
  credentials?: Record<string, string>;
194
198
  env?: Record<string, string>;
195
199
  options?: string;
196
- ports?: number[];
200
+ /** Exposed ports: a container port (published on a random host port) or "host:container", which a step on the runner reaches at localhost:<host> */
201
+ ports?: (number | string)[];
197
202
  volumes?: string[];
198
203
  });
199
204
  }
200
205
 
201
206
  export declare class Step {
202
207
  constructor(props: {
208
+ /** YAML comment lines written above the step. Not a step key; each line of the string becomes a `#` line */
209
+ comment?: string;
203
210
  /** Continue on error */
204
211
  "continue-on-error"?: boolean;
205
212
  /** Environment variables */
@@ -218,8 +225,8 @@ export declare class Step {
218
225
  "timeout-minutes"?: number;
219
226
  /** Action reference */
220
227
  uses?: string;
221
- /** Action inputs */
222
- with?: Record<string, string>;
228
+ /** Action inputs. The runner reads each as a string; a number or boolean renders as written (`fetch-depth: 0`) */
229
+ with?: Record<string, string | number | boolean>;
223
230
  /** Working directory */
224
231
  "working-directory"?: string;
225
232
  });
@@ -244,6 +251,8 @@ You can run an unlimited number of jobs as long as you are within the workflow u
244
251
  /** The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows. */
245
252
  on: Record<string, unknown>;
246
253
  "cache-mode"?: "read" | "write" | "write-only" | "none";
254
+ /** YAML comment lines written at the top of the file, above `name:`. Not a workflow key; each line of the string becomes a `#` line */
255
+ comment?: string;
247
256
  /** Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context.
248
257
  You can also specify concurrency at the workflow level.
249
258
  When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. By default any previously pending job or workflow in the concurrency group will be canceled; this behavior can be changed with `queue`. To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true. */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The generated declarations (`src/generated/index.d.ts`) hold a declaration
3
+ * to what GitHub Actions accepts (#3670). Each snippet is compiled against
4
+ * them in memory, so a type narrower than the workflow syntax fails here.
5
+ */
6
+ import { describe, test, expect } from "vitest";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import ts from "typescript";
10
+
11
+ const DTS = readFileSync(join(import.meta.dirname, "generated", "index.d.ts"), "utf-8");
12
+
13
+ /** The diagnostics from compiling `body` against the generated declarations. */
14
+ function diagnostics(body: string): string[] {
15
+ const files: Record<string, string> = {
16
+ "/gh.d.ts": DTS,
17
+ "/main.ts": `import { Container, Service, Step } from "./gh";\n${body}\n`,
18
+ };
19
+ const options: ts.CompilerOptions = { strict: true, noEmit: true, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, types: [], lib: ["lib.es2022.d.ts"] };
20
+ const host = ts.createCompilerHost(options);
21
+ const read = host.getSourceFile.bind(host);
22
+ host.getSourceFile = (name, lang) => (name in files ? ts.createSourceFile(name, files[name], lang) : read(name, lang));
23
+ host.fileExists = (name) => name in files || ts.sys.fileExists(name);
24
+ host.readFile = (name) => files[name] ?? ts.sys.readFile(name);
25
+ const program = ts.createProgram(["/main.ts"], options, host);
26
+ return ts.getPreEmitDiagnostics(program, program.getSourceFile("/main.ts")).map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
27
+ }
28
+
29
+ describe("generated types (#3670)", () => {
30
+ test("Service and Container ports take host:container strings as well as numbers", () => {
31
+ expect(diagnostics(`
32
+ new Service({ image: "clickhouse/clickhouse-server:24", ports: ["8123:8123", 9000] });
33
+ new Container({ image: "node:22", ports: ["3000:3000"] });
34
+ `)).toEqual([]);
35
+ });
36
+
37
+ test("Step with takes numbers and booleans as well as strings", () => {
38
+ expect(diagnostics(`new Step({ uses: "actions/checkout@v4", with: { "fetch-depth": 0, lfs: true, ref: "main" } });`)).toEqual([]);
39
+ });
40
+
41
+ test("the declarations still reject what the syntax does not take", () => {
42
+ expect(diagnostics(`new Service({ image: "x", ports: [{ port: 1 }] });`)).not.toEqual([]);
43
+ expect(diagnostics(`new Step({ with: { inputs: ["a"] } });`)).not.toEqual([]);
44
+ });
45
+ });
@@ -2,6 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { githubSerializer } from "./serializer";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
4
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
5
+ import { parseYAML } from "@intentius/chant/yaml";
5
6
 
6
7
  // ── Mock entities ──────────────────────────────────────────────────
7
8
 
@@ -407,3 +408,82 @@ describe("YAML key ordering", () => {
407
408
  expect(envIdx).toBeLessThan(jobsIdx);
408
409
  });
409
410
  });
411
+
412
+ describe("ports and step inputs render as written (#3670)", () => {
413
+ test("a host:container port stays a mapping and a numeric input stays a number", () => {
414
+ const entities = new Map<string, Declarable>();
415
+ entities.set("workflow", new MockWorkflow({
416
+ name: "CI",
417
+ on: { push: null },
418
+ jobs: {
419
+ test: new MockJob({
420
+ "runs-on": "ubuntu-latest",
421
+ services: { clickhouse: { image: "clickhouse/clickhouse-server:24", ports: ["8123:8123", 9000] } },
422
+ steps: [new MockStep({ uses: "actions/checkout@v4", with: { "fetch-depth": 0, lfs: true } })],
423
+ }),
424
+ },
425
+ }));
426
+
427
+ const output = githubSerializer.serialize(entities) as string;
428
+ expect(output).toContain(" ports:\n - '8123:8123'\n - 9000");
429
+ expect(output).toContain(" with:\n fetch-depth: 0\n lfs: true\n");
430
+ });
431
+ });
432
+
433
+ describe("comments (#3667)", () => {
434
+ const declare = (comments: boolean): Map<string, Declarable> => {
435
+ const c = <T>(v: T): T | undefined => (comments ? v : undefined);
436
+ const entities = new Map<string, Declarable>();
437
+ entities.set("workflow", new MockWorkflow({
438
+ comment: c("What this workflow is for.\n\nKeep the file name: npm's trusted publisher names it."),
439
+ name: "Publish",
440
+ on: { push: { tags: ["v*"] } },
441
+ jobs: {
442
+ build: new MockJob({ "runs-on": "ubuntu-latest", steps: [new MockStep({ run: "npm ci" })] }),
443
+ publish: new MockJob({
444
+ comment: c("Runs after build; holds the id-token."),
445
+ "runs-on": "ubuntu-latest",
446
+ needs: ["build"],
447
+ steps: [
448
+ new MockStep({ uses: "actions/checkout@v4" }),
449
+ new MockStep({ comment: c("The registry is flaky; retry once."), name: "Publish", run: "npm publish || npm publish", env: { NODE_AUTH_TOKEN: "x" } }),
450
+ ],
451
+ }),
452
+ },
453
+ }));
454
+ return entities;
455
+ };
456
+
457
+ test("a workflow, job and step comment render as # lines above their entries", () => {
458
+ const output = githubSerializer.serialize(declare(true)) as string;
459
+ expect(output.startsWith("# What this workflow is for.\n#\n# Keep the file name: npm's trusted publisher names it.\n\nname: Publish\n")).toBe(true);
460
+ expect(output).toContain(" # Runs after build; holds the id-token.\n publish:\n runs-on: ubuntu-latest\n");
461
+ expect(output).toContain(" - uses: actions/checkout@v4\n # The registry is flaky; retry once.\n - name: Publish\n run: npm publish || npm publish\n");
462
+ expect(output).not.toContain("comment:");
463
+ });
464
+
465
+ test("the comments are the only difference, and the YAML parses to the same document", () => {
466
+ const withComments = githubSerializer.serialize(declare(true)) as string;
467
+ const without = githubSerializer.serialize(declare(false)) as string;
468
+ expect(withComments.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n").replace(/^\n/, "")).toBe(without);
469
+ expect(parseYAML(withComments)).toEqual(parseYAML(without));
470
+ });
471
+
472
+ test("standalone Job and Step entities carry their comments too", () => {
473
+ const entities = new Map<string, Declarable>();
474
+ entities.set("ci", new MockWorkflow({ comment: "Header", name: "CI", on: { push: null } }));
475
+ entities.set("test", new MockJob({ comment: "Unit tests", "runs-on": "ubuntu-latest", steps: [new MockStep({ comment: "Why", run: "npm test" })] }));
476
+ const output = githubSerializer.serialize(entities) as string;
477
+ expect(output).toContain("# Header\n\nname: CI");
478
+ expect(output).toContain("jobs:\n # Unit tests\n test:\n runs-on: ubuntu-latest\n steps:\n # Why\n - run: npm test\n");
479
+ });
480
+
481
+ test("each file of a multi-workflow build gets its own header", () => {
482
+ const entities = new Map<string, Declarable>();
483
+ entities.set("ci", new MockWorkflow({ comment: "CI header", name: "CI", on: { push: null }, jobs: { a: new MockJob({ "runs-on": "x" }) } }));
484
+ entities.set("release", new MockWorkflow({ comment: "Release header", name: "Release", on: { push: null }, jobs: { b: new MockJob({ "runs-on": "x" }) } }));
485
+ const result = githubSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
486
+ expect(result.primary.startsWith("# CI header\n\nname: CI")).toBe(true);
487
+ expect(result.files["release.yml"].startsWith("# Release header\n\nname: Release")).toBe(true);
488
+ });
489
+ });
package/src/serializer.ts CHANGED
@@ -12,7 +12,7 @@ import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
12
12
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
13
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
14
14
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
15
- import { emitYAMLEntry } from "@intentius/chant/yaml";
15
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
16
16
 
17
17
  // ── Key conversion ────────────────────────────────────────────────
18
18
 
@@ -51,6 +51,62 @@ function isTriggerType(entityType: string): boolean {
51
51
  return entityType in TRIGGER_TYPE_TO_EVENT;
52
52
  }
53
53
 
54
+ // ── Comments ──────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Entity types whose `comment` prop is YAML comment lines, not a key (#3667):
58
+ * the workflow's above `name:`, a job's above its key, a step's above its
59
+ * `- `. The prop never reaches the emitted mapping.
60
+ */
61
+ const COMMENTED_TYPES = new Set([
62
+ "GitHub::Actions::Workflow",
63
+ "GitHub::Actions::Job",
64
+ "GitHub::Actions::ReusableWorkflowCallJob",
65
+ "GitHub::Actions::Step",
66
+ ]);
67
+
68
+ /**
69
+ * The comment for each serialized job or step mapping, keyed by the object
70
+ * the emitter will see. Attached after key conversion, which builds new
71
+ * objects, and read by `emitJobs`.
72
+ */
73
+ const COMMENTS = new WeakMap<object, string>();
74
+
75
+ /** The `comment` of a workflow, job or step entity, if it has one. */
76
+ function commentOf(value: unknown): string | undefined {
77
+ if (!value || typeof value !== "object" || !("entityType" in value) || !("props" in value)) return undefined;
78
+ if (!COMMENTED_TYPES.has((value as { entityType: string }).entityType)) return undefined;
79
+ const comment = ((value as { props: unknown }).props as Record<string, unknown> | undefined)?.comment;
80
+ return typeof comment === "string" && comment !== "" ? comment : undefined;
81
+ }
82
+
83
+ /** `props` without its `comment`, for an entity whose comment is not a key. */
84
+ function withoutComment(props: unknown): unknown {
85
+ if (!props || typeof props !== "object" || !("comment" in props)) return props;
86
+ const { comment: _comment, ...rest } = props as Record<string, unknown>;
87
+ return rest;
88
+ }
89
+
90
+ /** A comment as `#` lines at `indent` (two spaces each), one per line of the string. */
91
+ function commentLines(comment: string, indent: number): string {
92
+ const prefix = " ".repeat(indent);
93
+ return comment.split("\n").map((l) => `${prefix}#${l.trimEnd() ? ` ${l.trimEnd()}` : ""}`).join("\n");
94
+ }
95
+
96
+ /** Record a serialized job's comment and its steps' comments, from the job entity it came from. */
97
+ function attachComments(source: unknown, job: Record<string, unknown> | undefined): void {
98
+ if (!job) return;
99
+ const comment = commentOf(source);
100
+ if (comment) COMMENTS.set(job, comment);
101
+ const rawSteps = (source as { props?: { steps?: unknown } } | undefined)?.props?.steps;
102
+ if (!Array.isArray(rawSteps) || !Array.isArray(job.steps)) return;
103
+ rawSteps.forEach((raw, i) => {
104
+ const stepComment = commentOf(raw);
105
+ const step = (job.steps as unknown[])[i];
106
+ if (stepComment && step && typeof step === "object") COMMENTS.set(step, stepComment);
107
+ });
108
+ }
109
+
54
110
  // ── Visitor ───────────────────────────────────────────────────────
55
111
 
56
112
  function githubVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
@@ -62,8 +118,10 @@ function githubVisitor(entityNames: Map<Declarable, string>): SerializerVisitor
62
118
  return undefined;
63
119
  }
64
120
  const props = entity.props as Record<string, unknown>;
121
+ const commented = COMMENTED_TYPES.has(entity.entityType);
65
122
  const result: Record<string, unknown> = {};
66
123
  for (const [key, value] of Object.entries(props)) {
124
+ if (commented && key === "comment") continue;
67
125
  if (value !== undefined) {
68
126
  result[key] = walk(value);
69
127
  }
@@ -134,10 +192,12 @@ function serializeInlineJob(
134
192
 
135
193
  if ("entityType" in obj && "props" in obj && JOB_ENTITY_TYPES.has(obj.entityType as string)) {
136
194
  // Job entity: serialize its props inline (not as a resource reference)
137
- const props = toYAMLValue(obj.props, entityNames);
138
- return props && typeof props === "object"
195
+ const props = toYAMLValue(withoutComment(obj.props), entityNames);
196
+ const job = props && typeof props === "object"
139
197
  ? convertKeys(props as Record<string, unknown>)
140
198
  : undefined;
199
+ attachComments(obj, job);
200
+ return job;
141
201
  }
142
202
 
143
203
  // Plain object job definition (JSON-style)
@@ -173,10 +233,13 @@ function buildStandaloneJobsSection(
173
233
  const jobsSection: Record<string, unknown> = {};
174
234
  for (const [name, job] of jobs) {
175
235
  const jProps = toYAMLValue(
176
- isResourceDeclarable(job) ? job.props : undefined,
236
+ withoutComment(isResourceDeclarable(job) ? job.props : undefined),
177
237
  entityNames,
178
238
  ) as Record<string, unknown> | undefined;
179
- if (jProps) jobsSection[toKebabCase(name)] = convertKeys(jProps);
239
+ if (!jProps) continue;
240
+ const converted = convertKeys(jProps);
241
+ attachComments(job, converted);
242
+ jobsSection[toKebabCase(name)] = converted;
180
243
  }
181
244
  return Object.keys(jobsSection).length > 0 ? jobsSection : undefined;
182
245
  }
@@ -358,7 +421,7 @@ function serializeSingleWorkflow(
358
421
  if (!jobsSection) jobsSection = buildStandaloneJobsSection(jobs, entityNames);
359
422
  if (jobsSection) doc.jobs = jobsSection;
360
423
 
361
- return emitYAMLDocument(doc);
424
+ return emitYAMLDocument(doc, workflows.length > 0 ? commentOf(workflows[0][1]) : undefined);
362
425
  }
363
426
 
364
427
  function serializeMultiWorkflow(
@@ -409,7 +472,7 @@ function serializeMultiWorkflow(
409
472
  const jobsSection = inlineJobs ?? (i === 0 ? buildStandaloneJobsSection(jobs, entityNames) : undefined);
410
473
  if (jobsSection) doc.jobs = jobsSection;
411
474
 
412
- const content = emitYAMLDocument(doc);
475
+ const content = emitYAMLDocument(doc, commentOf(wf));
413
476
  if (i === 0) {
414
477
  primary = content;
415
478
  // primary is written to the -o path by the CLI; don't also add it to files[]
@@ -443,8 +506,9 @@ function convertTriggerProps(on: unknown): unknown {
443
506
  /**
444
507
  * Emit a complete YAML document from a structured object.
445
508
  */
446
- function emitYAMLDocument(doc: Record<string, unknown>): string {
509
+ function emitYAMLDocument(doc: Record<string, unknown>, header?: string): string {
447
510
  const sections: string[] = [];
511
+ if (header) sections.push(commentLines(header, 0));
448
512
 
449
513
  // Emit in canonical order: name, run-name, on, permissions, env, concurrency, defaults, jobs
450
514
  const order = ["name", "run-name", "on", "permissions", "env", "concurrency", "defaults", "jobs"];
@@ -456,6 +520,8 @@ function emitYAMLDocument(doc: Record<string, unknown>): string {
456
520
  const value = doc[key];
457
521
  if (value === null) {
458
522
  sections.push(`${key}:`);
523
+ } else if (key === "jobs" && hasComments(value)) {
524
+ sections.push(emitJobs(value as Record<string, unknown>));
459
525
  } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
460
526
  sections.push(`${key}: ${yamlScalar(value)}`);
461
527
  } else {
@@ -477,6 +543,56 @@ function emitYAMLDocument(doc: Record<string, unknown>): string {
477
543
  return unquotePinComments(sections.join("\n\n") + "\n");
478
544
  }
479
545
 
546
+ /** Whether any job in a jobs section, or any of its steps, carries a comment. */
547
+ function hasComments(jobs: unknown): boolean {
548
+ if (!jobs || typeof jobs !== "object") return false;
549
+ return Object.values(jobs as Record<string, unknown>).some((job) =>
550
+ !!job && typeof job === "object" && (COMMENTS.has(job) || stepsOf(job).some((s) => !!s && typeof s === "object" && COMMENTS.has(s))));
551
+ }
552
+
553
+ const stepsOf = (job: object): unknown[] => {
554
+ const steps = (job as { steps?: unknown }).steps;
555
+ return Array.isArray(steps) ? steps : [];
556
+ };
557
+
558
+ /**
559
+ * The jobs section with each comment above its job or step. Everything else
560
+ * is what `emitYAMLEntry("jobs", jobs)` writes: the job at indent 1, its keys
561
+ * at 2, its step items at 3.
562
+ */
563
+ function emitJobs(jobs: Record<string, unknown>): string {
564
+ const lines = ["jobs:"];
565
+ for (const [name, job] of Object.entries(jobs)) {
566
+ if (job === undefined) continue;
567
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
568
+ lines.push(emitYAMLEntry(name, job, 1));
569
+ continue;
570
+ }
571
+ const comment = COMMENTS.get(job);
572
+ if (comment) lines.push(commentLines(comment, 1));
573
+ const steps = stepsOf(job);
574
+ if (!steps.some((s) => !!s && typeof s === "object" && COMMENTS.has(s))) {
575
+ lines.push(emitYAMLEntry(name, job, 1));
576
+ continue;
577
+ }
578
+ lines.push(` ${name}:`);
579
+ for (const [key, value] of Object.entries(job as Record<string, unknown>)) {
580
+ if (value === undefined) continue;
581
+ if (key !== "steps") {
582
+ lines.push(emitYAMLEntry(key, value, 2));
583
+ continue;
584
+ }
585
+ lines.push(" steps:");
586
+ for (const step of steps) {
587
+ const stepComment = step && typeof step === "object" ? COMMENTS.get(step) : undefined;
588
+ if (stepComment) lines.push(commentLines(stepComment, 3));
589
+ lines.push(emitYAML([step], 3).slice(1));
590
+ }
591
+ }
592
+ }
593
+ return lines.join("\n");
594
+ }
595
+
480
596
  /**
481
597
  * A SHA-pinned `uses:` carries its version as a trailing comment
482
598
  * (`actions/checkout@<sha> # v7.0.1`). The YAML emitter quotes any string with