@intentius/chant-lexicon-github 0.124.31 → 0.124.33

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.
Files changed (157) hide show
  1. package/dist/action-pins.js +53 -0
  2. package/dist/codegen/docs-cli.js +2 -0
  3. package/dist/codegen/docs.js +142 -0
  4. package/dist/codegen/generate-cli.js +26 -0
  5. package/dist/codegen/generate-lexicon.js +36 -0
  6. package/dist/codegen/generate-typescript.js +108 -0
  7. package/dist/codegen/generate.js +111 -0
  8. package/dist/codegen/naming.js +49 -0
  9. package/dist/codegen/package.js +40 -0
  10. package/dist/codegen/parse.js +595 -0
  11. package/dist/codegen/patches.js +36 -0
  12. package/dist/components/ci-jobs.d.ts +7 -0
  13. package/dist/components/ci-jobs.d.ts.map +1 -1
  14. package/dist/components/ci-jobs.js +98 -0
  15. package/dist/components/generate-op-pipeline.js +440 -0
  16. package/dist/components/generate-op-waves-pipeline.js +158 -0
  17. package/dist/components/generate-pipeline.d.ts.map +1 -1
  18. package/dist/components/generate-pipeline.js +481 -0
  19. package/dist/composites/cache.js +17 -0
  20. package/dist/composites/catalog.js +859 -0
  21. package/dist/composites/checkout.js +25 -0
  22. package/dist/composites/dependabot.js +43 -0
  23. package/dist/composites/deploy-environment.js +38 -0
  24. package/dist/composites/docker-build.js +70 -0
  25. package/dist/composites/download-artifact.js +18 -0
  26. package/dist/composites/go-ci.js +62 -0
  27. package/dist/composites/index.js +15 -0
  28. package/dist/composites/node-ci.js +44 -0
  29. package/dist/composites/node-pipeline.js +96 -0
  30. package/dist/composites/pr-plan-report.js +129 -0
  31. package/dist/composites/python-ci.js +60 -0
  32. package/dist/composites/setup-go.js +18 -0
  33. package/dist/composites/setup-node.js +21 -0
  34. package/dist/composites/setup-python.js +18 -0
  35. package/dist/composites/upload-artifact.js +19 -0
  36. package/dist/coverage.js +32 -0
  37. package/dist/expression.js +177 -0
  38. package/dist/generated/index.js +26 -0
  39. package/dist/generated/runtime.js +4 -0
  40. package/dist/graph-edges.js +174 -0
  41. package/dist/import/generator.js +108 -0
  42. package/dist/import/parser.js +69 -0
  43. package/dist/index.js +22 -0
  44. package/dist/integrity.json +2 -2
  45. package/dist/lint/audit-catalog.js +69 -0
  46. package/dist/lint/audit-lineage.js +168 -0
  47. package/dist/lint/post-synth/gha006.js +50 -0
  48. package/dist/lint/post-synth/gha009.js +35 -0
  49. package/dist/lint/post-synth/gha011.js +34 -0
  50. package/dist/lint/post-synth/gha013.js +40 -0
  51. package/dist/lint/post-synth/gha017.js +25 -0
  52. package/dist/lint/post-synth/gha018.js +33 -0
  53. package/dist/lint/post-synth/gha019.js +58 -0
  54. package/dist/lint/post-synth/gha021.js +42 -0
  55. package/dist/lint/post-synth/gha022.js +44 -0
  56. package/dist/lint/post-synth/gha023.js +37 -0
  57. package/dist/lint/post-synth/gha024.js +32 -0
  58. package/dist/lint/post-synth/gha025.js +33 -0
  59. package/dist/lint/post-synth/gha026.js +32 -0
  60. package/dist/lint/post-synth/gha027.js +46 -0
  61. package/dist/lint/post-synth/gha028.js +28 -0
  62. package/dist/lint/post-synth/gha029.js +56 -0
  63. package/dist/lint/post-synth/gha030.js +35 -0
  64. package/dist/lint/post-synth/gha031.js +75 -0
  65. package/dist/lint/post-synth/gha032.js +35 -0
  66. package/dist/lint/post-synth/gha033.js +39 -0
  67. package/dist/lint/post-synth/gha034.js +35 -0
  68. package/dist/lint/post-synth/gha035.js +50 -0
  69. package/dist/lint/post-synth/gha036.js +37 -0
  70. package/dist/lint/post-synth/gha037.js +41 -0
  71. package/dist/lint/post-synth/gha038.js +35 -0
  72. package/dist/lint/post-synth/gha039.js +34 -0
  73. package/dist/lint/post-synth/gha040.js +36 -0
  74. package/dist/lint/post-synth/gha041.js +32 -0
  75. package/dist/lint/post-synth/gha042.js +33 -0
  76. package/dist/lint/post-synth/gha043.js +35 -0
  77. package/dist/lint/post-synth/gha044.js +36 -0
  78. package/dist/lint/post-synth/gha045.js +33 -0
  79. package/dist/lint/post-synth/gha046.js +55 -0
  80. package/dist/lint/post-synth/gha047.js +44 -0
  81. package/dist/lint/post-synth/gha048.js +34 -0
  82. package/dist/lint/post-synth/gha049.js +36 -0
  83. package/dist/lint/post-synth/gha050.js +37 -0
  84. package/dist/lint/post-synth/gha051.js +38 -0
  85. package/dist/lint/post-synth/gha052.js +35 -0
  86. package/dist/lint/post-synth/gha053.js +34 -0
  87. package/dist/lint/post-synth/gha054.js +35 -0
  88. package/dist/lint/post-synth/gha055.js +44 -0
  89. package/dist/lint/post-synth/gha056.js +32 -0
  90. package/dist/lint/post-synth/gha057.js +35 -0
  91. package/dist/lint/post-synth/gha058.js +42 -0
  92. package/dist/lint/post-synth/gha059.js +119 -0
  93. package/dist/lint/post-synth/gha060.js +129 -0
  94. package/dist/lint/post-synth/gha061.js +78 -0
  95. package/dist/lint/post-synth/gha062.js +65 -0
  96. package/dist/lint/post-synth/gha063.js +56 -0
  97. package/dist/lint/post-synth/gha064.js +47 -0
  98. package/dist/lint/post-synth/gha065.js +125 -0
  99. package/dist/lint/post-synth/gha066.js +40 -0
  100. package/dist/lint/post-synth/gha067.js +75 -0
  101. package/dist/lint/post-synth/gha068.js +47 -0
  102. package/dist/lint/post-synth/gha069.js +99 -0
  103. package/dist/lint/post-synth/index.js +114 -0
  104. package/dist/lint/post-synth/yaml-helpers.js +465 -0
  105. package/dist/lint/rules/data/action-usage-policy.js +16 -0
  106. package/dist/lint/rules/data/advisory-feed.js +15 -0
  107. package/dist/lint/rules/data/deprecated-versions.js +14 -0
  108. package/dist/lint/rules/data/flagged-actions.js +18 -0
  109. package/dist/lint/rules/data/known-action-slugs.js +38 -0
  110. package/dist/lint/rules/data/known-actions.js +12 -0
  111. package/dist/lint/rules/data/preinstalled-tools.js +36 -0
  112. package/dist/lint/rules/data/recommended-inputs.js +9 -0
  113. package/dist/lint/rules/data/risky-features.js +22 -0
  114. package/dist/lint/rules/data/secret-patterns.js +30 -0
  115. package/dist/lint/rules/data/trusted-action-owners.js +12 -0
  116. package/dist/lint/rules/data/untrusted-contexts.js +44 -0
  117. package/dist/lint/rules/deprecated-action-version.js +44 -0
  118. package/dist/lint/rules/detect-secrets.js +45 -0
  119. package/dist/lint/rules/extract-inline-structs.js +55 -0
  120. package/dist/lint/rules/file-job-limit.js +43 -0
  121. package/dist/lint/rules/index.js +16 -0
  122. package/dist/lint/rules/job-timeout.js +53 -0
  123. package/dist/lint/rules/missing-recommended-inputs.js +51 -0
  124. package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
  125. package/dist/lint/rules/no-raw-expressions.js +44 -0
  126. package/dist/lint/rules/suggest-cache.js +60 -0
  127. package/dist/lint/rules/use-condition-builders.js +37 -0
  128. package/dist/lint/rules/use-matrix-builder.js +36 -0
  129. package/dist/lint/rules/use-typed-actions.js +41 -0
  130. package/dist/lint/rules/validate-concurrency.js +59 -0
  131. package/dist/lsp/completions.d.ts.map +1 -1
  132. package/dist/lsp/completions.js +18 -0
  133. package/dist/lsp/hover.d.ts.map +1 -1
  134. package/dist/lsp/hover.js +33 -0
  135. package/dist/manifest.json +1 -1
  136. package/dist/mcp/context-tools.js +152 -0
  137. package/dist/package-cli.js +20 -0
  138. package/dist/plugin.js +344 -0
  139. package/dist/serializer.js +572 -0
  140. package/dist/spec/fetch.js +46 -0
  141. package/dist/validate-cli.js +16 -0
  142. package/dist/validate.js +38 -0
  143. package/dist/variables.js +41 -0
  144. package/package.json +6 -6
  145. package/src/codegen/package.ts +2 -2
  146. package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
  147. package/src/components/__fixtures__/pr-loop-record.github.golden.yml +74 -0
  148. package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
  149. package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
  150. package/src/components/ci-jobs.ts +16 -2
  151. package/src/components/generate-op-pipeline.test.ts +11 -0
  152. package/src/components/generate-op-pipeline.ts +3 -3
  153. package/src/components/generate-pipeline.ts +74 -31
  154. package/src/components/generate-pr-pipeline.test.ts +99 -29
  155. package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
  156. package/src/lsp/completions.ts +2 -1
  157. package/src/lsp/hover.ts +2 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The GitHub Actions job pieces the three generators share (#3765): the Op
3
+ * pipeline (`./generate-op-pipeline.ts`), the Op waves pipeline
4
+ * (`./generate-op-waves-pipeline.ts`) and the component pipeline
5
+ * (`./generate-pipeline.ts`). The forgejo lexicon builds on the same
6
+ * documents and applies its dialect. The forge-neutral rules (image default,
7
+ * branch defaults, tokens, permissions, setup and environment checks) are
8
+ * core's, in `@intentius/chant/ci-jobs`.
9
+ */
10
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
11
+ import { BUILD_REQUEST_TAGS, GITHUB_JOB_TOKEN, pushBranches } from "@intentius/chant/ci-jobs";
12
+ import { actionRef } from "../action-pins.js";
13
+ /** The artifact actions every generated job uses, at the major the generators have always pinned. */
14
+ export const UPLOAD_ARTIFACT = "actions/upload-artifact@v4";
15
+ export const DOWNLOAD_ARTIFACT = "actions/download-artifact@v4";
16
+ /** The checkout step; `fullHistory` fetches every commit, for a job that reads the base commit or measures a change with git. */
17
+ export function checkoutStep(fullHistory = false) {
18
+ return fullHistory ? { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } } : { uses: actionRef("actions/checkout") };
19
+ }
20
+ /** One `run:` step per line, so each line is its own step in the log. */
21
+ export function runSteps(lines) {
22
+ return lines.map((line) => ({ run: line }));
23
+ }
24
+ /** A step that downloads `artifact` into `path`. */
25
+ export function downloadArtifactStep(name, artifact, path) {
26
+ return { name, uses: DOWNLOAD_ARTIFACT, with: { name: artifact, path } };
27
+ }
28
+ /**
29
+ * A step that keeps a record as an artifact whatever the job's outcome, so a
30
+ * job that stops at a gate (exit 3) still hands its record on, and a re-run
31
+ * replaces it. Nothing to keep is not an error.
32
+ */
33
+ export function keepRecordStep(name, artifact, path) {
34
+ return {
35
+ name,
36
+ if: "always()",
37
+ uses: UPLOAD_ARTIFACT,
38
+ with: { name: artifact, path, "if-no-files-found": "ignore", "include-hidden-files": true, overwrite: true },
39
+ };
40
+ }
41
+ /** A `concurrency:` mapping: one run at a time per group. */
42
+ export function concurrency(group, cancelInProgress = false) {
43
+ return { group, "cancel-in-progress": cancelInProgress };
44
+ }
45
+ /** The job token, as a step's `GITHUB_TOKEN`; `gh` reads `GH_TOKEN` too, for a step that writes. */
46
+ export function jobTokenEnv(gh = false) {
47
+ return gh ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN, GH_TOKEN: GITHUB_JOB_TOKEN } : { GITHUB_TOKEN: GITHUB_JOB_TOKEN };
48
+ }
49
+ /**
50
+ * A trigger's `on:` mapping (#2084): `cron` runs on its schedule and by hand,
51
+ * `pull_request` on pull requests into its branches (every branch when it
52
+ * names none; no `workflow_dispatch`, since a PR event needs no manual
53
+ * escape hatch), `push` on pushes to its branches (core's default when it
54
+ * names none).
55
+ */
56
+ export function onTrigger(trigger) {
57
+ switch (trigger.kind) {
58
+ case "cron":
59
+ return { schedule: [{ cron: trigger.schedule }], workflow_dispatch: {} };
60
+ case "pull_request":
61
+ return { pull_request: trigger.branches && trigger.branches.length > 0 ? { branches: trigger.branches } : {} };
62
+ case "push":
63
+ // #4169: a newest build runs at each build request's tag too, and by hand.
64
+ return trigger.build === "newest"
65
+ ? { push: { branches: pushBranches(trigger.branches), tags: [BUILD_REQUEST_TAGS] }, workflow_dispatch: {} }
66
+ : { push: { branches: pushBranches(trigger.branches) } };
67
+ }
68
+ }
69
+ /**
70
+ * A push Op's concurrency group (#4169): one per commit under `build:
71
+ * "every"`, so no push cancels or replaces another's run; one per ref under
72
+ * `newest`, so the newest push waits and each build request's tag runs on its
73
+ * own; and the Op's own name otherwise, one run at a time.
74
+ */
75
+ export function opConcurrencyGroup(jobName, trigger) {
76
+ if (trigger.kind !== "push" || !trigger.build)
77
+ return jobName;
78
+ return trigger.build === "every" ? `${jobName}-\${{ github.sha }}` : `${jobName}-\${{ github.ref }}`;
79
+ }
80
+ /** Emit a workflow document as YAML, its sections in GitHub's customary order. */
81
+ export function emitWorkflowYAML(doc) {
82
+ const sections = [];
83
+ if (doc.header && doc.header.length > 0)
84
+ sections.push(doc.header.join("\n"));
85
+ if (doc.name)
86
+ sections.push("name: " + emitYAML(doc.name, 0));
87
+ sections.push(emitYAMLEntry("on", doc.on));
88
+ if (doc.env && Object.keys(doc.env).length > 0)
89
+ sections.push(emitYAMLEntry("env", doc.env));
90
+ if (doc.defaults)
91
+ sections.push(emitYAMLEntry("defaults", doc.defaults));
92
+ if (doc.concurrency)
93
+ sections.push(emitYAMLEntry("concurrency", doc.concurrency));
94
+ if (doc.permissions && Object.keys(doc.permissions).length > 0)
95
+ sections.push(emitYAMLEntry("permissions", doc.permissions));
96
+ sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
97
+ return sections.join("\n\n") + "\n";
98
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Generate mode — scheduled Op → GitHub Actions workflow YAML (#927).
3
+ *
4
+ * The Op counterpart to `./generate-pipeline.ts` (#891): that module
5
+ * synthesizes a `workflow_dispatch`-triggered pipeline from a deploy-time
6
+ * component graph, this one synthesizes a cron-triggered workflow per
7
+ * stateless Op. An Op's cadence is an `OpSchedule` on the Op itself
8
+ * (`packages/core/src/op/types.ts`), runtime-neutral data each reader
9
+ * interprets; this module is the reader that turns it into a cron a GitHub
10
+ * runner fires (`WorkflowAuditOp`, `PipelineAuditOp`, `ReconcileOp`, … all
11
+ * accept an optional `schedule` precisely for this).
12
+ *
13
+ * GitHub Actions' `on.schedule` is workflow-scoped, not job-scoped, so unlike
14
+ * the component generator (one combined pipeline for the whole graph) this
15
+ * emits one workflow file per `ScheduledOpSpec`. Each workflow:
16
+ * - triggers on its `ScheduledOpSpec`'s trigger (#2084): `cron` (the Op's
17
+ * schedule, plus `workflow_dispatch` so manual runs stay available for
18
+ * testing/dry-runs), `pull_request` (optionally filtered to `branches`,
19
+ * no `workflow_dispatch` — a PR event needs no manual escape hatch), or
20
+ * `push` (filtered to `branches`, defaulting to the repository's default
21
+ * branch);
22
+ * - declares only the `permissions:` its `findingMode` (and, for a
23
+ * `pull_request` trigger, whether that mode posts a comment) needs —
24
+ * `report` stays read-only, `issue`/`comment`/`pull-request` add the write
25
+ * scope the Op's own activity uses (`gh issue create` / a comment on the
26
+ * triggering PR / `gh pr create`, see `@intentius/chant/op`'s
27
+ * `reconcilePr` activity) — never a blanket `write-all`. `comment` is the
28
+ * one mode that constrains the trigger rather than only the scope: it
29
+ * needs a pull request to post onto, so this generator refuses it by name
30
+ * on any other trigger (#2231);
31
+ * - runs exactly one invocation, `chant run <name>` by default — never
32
+ * inlined audit/reconcile logic. The finding-mode itself is already baked
33
+ * into the Op's own activity args at build time by the composite that
34
+ * created it; this workflow only supplies the token the mode needs to act;
35
+ * - on a `push` trigger only, runs that invocation with `--gated-exit 0`
36
+ * and adds a follow-up job that says where the approval is pending
37
+ * (#2243). See {@link GATED_EXIT_FLAG} and {@link gateNoticeJob}.
38
+ *
39
+ * Two per-Op options widen that shape without loosening it (#2242). A spec's
40
+ * `setup` list emits steps between the checkout and the `beforeScript` lines,
41
+ * `uses:` steps included, which is the only way a generated job can reach an
42
+ * action like `aws-actions/configure-aws-credentials`; {@link
43
+ * assertSetupSteps} refuses an unpinned or default-branch ref at build time.
44
+ * A spec's `permissions` map is merged over {@link permissionsFor}, adding
45
+ * scopes the finding-mode never grants (`id-token: write` is the whole
46
+ * reason) and never touching one it does; {@link mergePermissions} refuses a
47
+ * blanket grant, an overlap with the mode's own set, an unknown scope name,
48
+ * and pull-request write on a trigger that has no pull request.
49
+ *
50
+ * A third widens it the other way (#2257): a spec's `environment` emits
51
+ * `environment:` on the Op's own job, which is how a GitHub environment's
52
+ * protection rules — required reviewers above all — come to hold a generated
53
+ * apply. That is a second gate beside chant's own, not a replacement for it:
54
+ * the reviewer stops the job before any step runs, chant's gate ledger
55
+ * (#2119) stops the apply inside a run that already started, and the two
56
+ * compose in either combination. It costs {@link permissionsFor} nothing —
57
+ * environment protection is repository configuration, not a token scope — and
58
+ * {@link assertEnvironment} refuses only what would emit as configured and
59
+ * bind nothing.
60
+ *
61
+ * A fourth is per-Op credentials (#2290): a spec's `variables` emits the Op's
62
+ * own job-level `env:`, beside (and layered over, on a key collision)
63
+ * `options.variables`'s workflow-level `env:`. `options.variables` keeps
64
+ * meaning what it always has — set once, landing on every generated file — so
65
+ * a caller who declares nothing per-Op sees byte-identical output; a spec's
66
+ * own `variables` is the way one Op's job can hold a credential no other Op's
67
+ * job receives, which a workflow-level declaration can never express because
68
+ * every generated file here has exactly the one Op job (plus, on a `push`
69
+ * trigger, the gate-notice job beside it — job-level `env:` does not reach
70
+ * that job either, same as `environment:` above does not).
71
+ */
72
+ import { readFileSync } from "node:fs";
73
+ import { assertEnvironment, assertSetupSteps, CI_IMAGE, ciJobName, environmentDoc, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, mergePermissions, nonEmpty, permissionsFor, setupStepDoc, } from "@intentius/chant/ci-jobs";
74
+ import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
+ import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, opConcurrencyGroup, runSteps } from "./ci-jobs.js";
76
+ // The setup, environment and permission checks are shared with the Op waves
77
+ // generator and the forgejo lexicon (#3765); they stay importable from here.
78
+ export { assertEnvironment, assertSetupSteps, mergePermissions };
79
+ // ── The gated apply (#2243) ─────────────────────────────────────────────────
80
+ /**
81
+ * `chant run` returns 3 when a run stops at an unapproved gate. GitHub Actions
82
+ * has no neutral conclusion for a `run:` step, so a push-to-main apply that
83
+ * gates paints the branch red on every merge until someone approves. This maps
84
+ * that one outcome to success, in chant rather than in a shell wrapper
85
+ * (#2243); a failed run still returns 1 and is still red.
86
+ *
87
+ * `push` only. A cron watch and a `pull_request` plan are never gated in a way
88
+ * that should be hidden: nobody is waiting on a merge for either, and a gated
89
+ * one there is a signal, not noise.
90
+ */
91
+ const GATED_EXIT_FLAG = ["--gated-exit", "0"];
92
+ /** The id of the `chant run` step on a `push` job, so the job can publish its outputs. */
93
+ const RUN_STEP_ID = "chant-run";
94
+ /**
95
+ * Turn the run's `--json` record into step outputs, so the notice job below
96
+ * has a condition to test and a gate to name. Runs in node, which is already
97
+ * on any machine `chant` runs on — unlike `jq`, which the Op's own container
98
+ * image need not carry.
99
+ *
100
+ * Nothing is written for a run that completed, so `gated` is either the string
101
+ * `true` or absent, and the notice job's `if:` is a plain equality.
102
+ */
103
+ const GATE_OUTPUT_SCRIPT = 'const fs=require("fs");' +
104
+ 'const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));' +
105
+ 'if(r.status!=="gated"||!process.env.GITHUB_OUTPUT)process.exit(0);' +
106
+ "fs.appendFileSync(process.env.GITHUB_OUTPUT," +
107
+ '`gated=true\\nop=${r.op}\\ngate=${(r.gate&&r.gate.name)||""}\\napprove=${r.approve||""}\\n`)';
108
+ /**
109
+ * The `push` job's run step: the invocation with {@link GATED_EXIT_FLAG} and
110
+ * `--json`, tee'd so the record is both in the log and on disk, then read for
111
+ * the job's outputs.
112
+ *
113
+ * The exit-code capture below is not decoration. Piping straight into `tee`
114
+ * — `chant run ... | tee "$json"` — reports `tee`'s own zero as the step's
115
+ * result no matter what `chant run` exited with, and turns a broken apply
116
+ * green, which is the exact thing this step must not do. bash's fix for that
117
+ * is `set -o pipefail`, but `pipefail` is a bash-ism, and this generator's
118
+ * own Op job always sets `container:` (see {@link buildGithubOpPipelineDocs}'s
119
+ * `container: image`) — GitHub's (and Forgejo's) default shell for a
120
+ * `container:` job is plain `sh`, which rejects `set -o pipefail` outright
121
+ * (`Illegal option -o pipefail`) and fails the step before `chant` ever runs
122
+ * (#2299). #2307 first fixed that by pairing the `pipefail` line with an
123
+ * unconditional `shell: "bash"` on the step — which then broke every
124
+ * consumer whose `options.image` has no bash on it at all, e.g. an
125
+ * `alpine:*` or distroless image (#2321): busybox `ash` ran the old
126
+ * plain-pipe script fine, so pinning `shell: bash` regressed exactly the
127
+ * images that never needed pipefail's workaround in the first place.
128
+ *
129
+ * So this step needs no shell declaration and runs under whatever `sh` the
130
+ * consumer's image provides. It gets the same failure-cannot-hide guarantee
131
+ * `pipefail` gave, without the bash-ism, by capturing the invocation's own
132
+ * exit code into a file from *inside* the pipeline's first stage — where
133
+ * `tee` can never see it — and checking that file once the pipe finishes:
134
+ * `{ invocation; echo "$?" >status; } | tee "$json"` followed by `exit
135
+ * "$(cat "$status")"` when that is non-zero. `set +e` up front makes this
136
+ * work under any default shell a container job might get, `-e` included:
137
+ * GitHub's documented `sh` template is `sh -e {0}`, and without `set +e`
138
+ * `-e` would abort the brace group at the first failing command — before
139
+ * `echo "$?"` ever runs — which was verified by running both this script and
140
+ * that failure mode under bash and dash directly, `-e` on. Everything past
141
+ * that point (`set +e`, `{ }`, `$(...)`, `[ ]`) is POSIX `sh`, so it needs no
142
+ * `shell:` field to run correctly under `ash`, `dash`, or bash alike, and
143
+ * #2299 does not reopen: nothing here is a bash-only construct.
144
+ *
145
+ * The `node -e` line and the job's own `outputs:` mapping exist for exactly
146
+ * one reader: the gate-notice job beside it, which is `needs:`-only readable
147
+ * through `steps.<id>.outputs`/`needs.<job>.outputs`. Forgejo dropped both
148
+ * while it had no notice job (#2294) and carries them again since #3773. The
149
+ * tee'd invocation is what puts the run's own JSON record in the log.
150
+ */
151
+ function gatedRunStep(id, op, invocation, env) {
152
+ const lines = [
153
+ "set +e",
154
+ 'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
155
+ 'status="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.status"',
156
+ `{ ${invocation}; echo "$?" >"$status"; } | tee "$json"`,
157
+ 'code=$(cat "$status")',
158
+ '[ "$code" -eq 0 ] || exit "$code"',
159
+ ];
160
+ lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
161
+ return { id, run: lines.join("\n"), env };
162
+ }
163
+ /**
164
+ * The chant release the notice job installs: this lexicon's own version.
165
+ * The github lexicon and `@intentius/chant` release in lockstep, so the pr-forge
166
+ * a generated workflow runs is the one this generator was built against, and
167
+ * it changes only when the workflow is regenerated, the same as a pinned
168
+ * action ref.
169
+ */
170
+ function chantVersion() {
171
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
172
+ if (typeof pkg.version !== "string" || pkg.version === "") {
173
+ throw new Error("the github lexicon's package.json has no version to pin the gate-notice job's chant to");
174
+ }
175
+ return pkg.version;
176
+ }
177
+ /**
178
+ * What the notice job runs under node (#3773): the sticky-comment recipe
179
+ * `reconcilePr`'s `comment` mode uses, through the same
180
+ * `@intentius/chant/pr-forge` clients, so it posts on GitHub, GitHub
181
+ * Enterprise Server and Forgejo alike. `forgeTargetFromEnv` reads the forge,
182
+ * the REST base, the repository, the commit and the token off the job's
183
+ * environment (`FORGE_TOKEN_ORDER`: `CHANT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` on
184
+ * GitHub; `CHANT_FORGEJO_TOKEN` first on Forgejo), the order `reconcilePr` reads too (#3807),
185
+ * and `pullRequestFor` is the commit-to-pull-request lookup each forge spells
186
+ * its own way (`commits/{sha}/pulls` on GitHub, `commits/{sha}/pull` on
187
+ * Forgejo). A commit with no pull request, a direct push, gets a tracking
188
+ * issue keyed on the same marker, so a second gated push edits it rather
189
+ * than opening another.
190
+ *
191
+ * Plain JavaScript in double-quoted strings, so the markdown backticks need
192
+ * no escaping and nothing in it reads as a `${{ }}` expression.
193
+ */
194
+ const NOTICE_SCRIPT = [
195
+ 'import { forgeClient, forgeTargetFromEnv } from "@intentius/chant/pr-forge";',
196
+ "const { CHANT_OP: op, CHANT_GATE: gate, CHANT_APPROVE: approve } = process.env;",
197
+ "const target = forgeTargetFromEnv(process.env);",
198
+ "const forge = forgeClient(target);",
199
+ 'const marker = "<!-- chant-gate:" + op + " -->";',
200
+ 'const fence = "```";',
201
+ "const body = [",
202
+ " marker,",
203
+ ' "",',
204
+ ' "The `" + op + "` apply for " + target.sha + " stopped at gate `" + gate + "` and is waiting for an approval. Nothing was applied.",',
205
+ ' "",',
206
+ " fence,",
207
+ ' approve + " --approver <you>",',
208
+ " fence,",
209
+ ' "",',
210
+ ' "The pending fact is on `_gates/" + op + ".jsonl` on the `chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.",',
211
+ '].join("\\n") + "\\n";',
212
+ "const pr = target.sha ? await forge.pullRequestFor(target.sha) : null;",
213
+ "const url = pr",
214
+ " ? await forge.upsertNote(pr, marker, body)",
215
+ ' : await forge.upsertIssue(marker, op + " is waiting on gate " + gate, body);',
216
+ 'console.log(url ?? (pr ? "commented on #" + pr : "kept the tracking issue"));',
217
+ ].join("\n");
218
+ /**
219
+ * The notice step's shell (#3773). It installs the pinned chant into the
220
+ * runner's temp directory and runs {@link NOTICE_SCRIPT} with tsx, which
221
+ * chant depends on and which is how `chant` itself runs its TypeScript.
222
+ * `--omit=peer` keeps chant's optional vitest peer out: about 17 packages,
223
+ * a few seconds. The `package.json` written first stops npm from walking up
224
+ * to some other project root. No checkout and no `beforeScript`: the job
225
+ * reads nothing out of the repository.
226
+ */
227
+ function gateNoticeScript(version) {
228
+ return [
229
+ 'dir="${RUNNER_TEMP:-/tmp}/chant-gate-notice"',
230
+ 'mkdir -p "$dir"',
231
+ 'cd "$dir"',
232
+ "echo '{\"private\":true}' > package.json",
233
+ `npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
234
+ "cat > notice.mjs <<'NOTICE'",
235
+ NOTICE_SCRIPT,
236
+ "NOTICE",
237
+ "node --import tsx notice.mjs",
238
+ ].join("\n");
239
+ }
240
+ /**
241
+ * The follow-up job: `needs:` the apply, runs only when the apply reported
242
+ * gated, and puts the pending state somewhere other than the Actions log.
243
+ *
244
+ * It runs in the Op's own image, which carries node and npm by construction
245
+ * (the apply's `node -e` output step needs them too), so it runs the same on
246
+ * GitHub's hosted runners and on a Forgejo runner. It checks nothing out.
247
+ *
248
+ * Its `permissions:` are its own, replacing the workflow-level set for this
249
+ * job alone: `contents: read` for the commit-to-pull-request lookup,
250
+ * `pull-requests: write` for the sticky comment, `issues: write` for the
251
+ * tracking issue when the push has no pull request. Nothing wider: it opens
252
+ * no branch and merges nothing. The Forgejo dialect drops them, as it drops
253
+ * every scope but `id-token`.
254
+ */
255
+ function gateNoticeJob(applyJobName, image) {
256
+ const output = (name) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
257
+ return {
258
+ needs: applyJobName,
259
+ if: `needs.${applyJobName}.outputs.gated == 'true'`,
260
+ "runs-on": GITHUB_RUNNER,
261
+ container: image,
262
+ permissions: { contents: "read", issues: "write", "pull-requests": "write" },
263
+ steps: [
264
+ {
265
+ name: "Report the pending gate",
266
+ env: {
267
+ GITHUB_TOKEN: GITHUB_JOB_TOKEN,
268
+ CHANT_OP: output("op"),
269
+ CHANT_GATE: output("gate"),
270
+ CHANT_APPROVE: output("approve"),
271
+ },
272
+ run: gateNoticeScript(chantVersion()),
273
+ },
274
+ ],
275
+ };
276
+ }
277
+ /**
278
+ * Refuse `findingMode: "comment"` on a trigger that has no pull request
279
+ * (#2231). The mode's activity reads the triggering PR out of the event
280
+ * payload at run time, so a cron- or push-triggered job carrying it would
281
+ * generate fine and then fail on every run. Refusing here names the Op, the
282
+ * mode and the trigger at build time instead.
283
+ */
284
+ function assertTriggerSupportsMode(name, mode, trigger) {
285
+ if (mode !== "comment" || trigger.kind === "pull_request")
286
+ return;
287
+ throw new Error(`Scheduled Op "${name}" has findingMode "comment", which posts its finding on the pull request that ` +
288
+ `triggered the run, but its trigger is "${trigger.kind}". A ${trigger.kind} run has no pull request ` +
289
+ `to comment on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue".`);
290
+ }
291
+ /**
292
+ * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
293
+ * steps, least-privilege `permissions:` for its finding-mode plus whatever
294
+ * the spec adds, one job that runs `chant run <name>`. Every
295
+ * `ScheduledOpSpec` is independent — unlike the component generator there is
296
+ * no shared graph to resolve — so the only thing this refuses is a spec that
297
+ * contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
298
+ * "comment"` on a trigger that has no pull request ({@link
299
+ * assertTriggerSupportsMode}), an unpinned `setup` action ({@link
300
+ * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
301
+ * mergePermissions}).
302
+ */
303
+ export function buildGithubOpPipelineDocs(ops, options = {}) {
304
+ const image = options.image ?? CI_IMAGE;
305
+ const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
306
+ const beforeScript = options.beforeScript ?? [];
307
+ const extraScript = options.extraScript ?? [];
308
+ const files = [];
309
+ const jobs = [];
310
+ for (const spec of ops) {
311
+ const findingMode = spec.findingMode ?? "report";
312
+ const trigger = resolveOpTrigger(spec);
313
+ assertTriggerSupportsMode(spec.name, findingMode, trigger);
314
+ const jobName = ciJobName(spec.name);
315
+ jobs.push({ jobName, op: spec.name, trigger, findingMode });
316
+ const runParts = fillCommand(runCommand, spec.name);
317
+ // A live-resolution read (rate limits) always benefits from a token;
318
+ // creating an issue/PR additionally needs `gh` CLI's own token variable.
319
+ const stepEnv = jobTokenEnv(findingMode !== "report");
320
+ const setup = spec.setup ?? [];
321
+ assertSetupSteps(spec.name, setup);
322
+ if (spec.environment)
323
+ assertEnvironment(spec.name, spec.environment);
324
+ // A `push` job is the one that has to survive a gate (#2243): the apply
325
+ // runs with `--gated-exit 0` so a pending approval is a green run, and
326
+ // publishes what it stopped on as job outputs for the notice job below.
327
+ // Every other trigger keeps the plain one-line invocation it always had.
328
+ const gated = trigger.kind === "push";
329
+ const invocation = gated
330
+ ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
331
+ : runParts.join(" ");
332
+ const steps = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
333
+ steps.push(gated
334
+ ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
335
+ : { run: invocation, env: stepEnv });
336
+ steps.push(...runSteps(extraScript));
337
+ const doc = {
338
+ name: spec.name,
339
+ on: onTrigger(trigger),
340
+ ...nonEmpty("env", options.variables),
341
+ // One run at a time per Op — a slow audit must not overlap its own next
342
+ // scheduled trigger. A push Op's `build` (#4169) widens the group.
343
+ concurrency: concurrency(opConcurrencyGroup(jobName, trigger)),
344
+ permissions: mergePermissions(spec.name, permissionsFor(findingMode, trigger), spec.permissions ?? {}, trigger),
345
+ jobsDoc: {
346
+ [jobName]: {
347
+ "runs-on": GITHUB_RUNNER,
348
+ container: image,
349
+ // Per-Op credentials (#2290): job-level `env:`, one level more
350
+ // specific than `options.variables`'s workflow-level `env:` above,
351
+ // and never on the notice job beside it — same reason `environment:`
352
+ // isn't either, it's this Op's own job alone.
353
+ ...nonEmpty("env", spec.variables),
354
+ // On this Op's own job and never on the notice job beside it: the
355
+ // notice exists to say a chant gate is pending, and putting it
356
+ // behind the same reviewer would hold the message back until
357
+ // somebody had already acted.
358
+ ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
359
+ ...(gated
360
+ ? {
361
+ outputs: Object.fromEntries(["gated", "op", "gate", "approve"].map((name) => [
362
+ name,
363
+ `\${{ steps.${RUN_STEP_ID}.outputs.${name} }}`,
364
+ ])),
365
+ }
366
+ : {}),
367
+ steps,
368
+ },
369
+ },
370
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
371
+ };
372
+ files.push({ name: `${spec.name}.yml`, doc });
373
+ }
374
+ if (options.member) {
375
+ const member = options.member;
376
+ return { files: files.map((f) => scopeOpFileToMember(f, member)), jobs };
377
+ }
378
+ return { files, jobs };
379
+ }
380
+ /**
381
+ * Scope one Op's workflow to a workspace member (#2542, #2524 D19). The file
382
+ * and the workflow take the member's name (`<member>-<op>.yml`, `<member>/<op>`)
383
+ * and so does the concurrency group, which GitHub keys across the whole
384
+ * repository. A `push` or `pull_request` trigger is filtered to the member's
385
+ * paths and this file; a cron trigger has no paths to filter. The Op's own
386
+ * job starts its `run:` steps in the member's directory. The gate-notice job
387
+ * checks nothing out and keeps the default.
388
+ */
389
+ function scopeOpFileToMember(file, member) {
390
+ const name = `${member.name}-${file.name}`;
391
+ const paths = memberPathFilter(member, member.fileDir ? `${member.fileDir}/${name}` : undefined);
392
+ const on = { ...file.doc.on };
393
+ for (const event of ["push", "pull_request"]) {
394
+ if (event in on)
395
+ on[event] = { ...on[event], paths };
396
+ }
397
+ const rooted = member.dir === "." || member.dir === "";
398
+ const jobsDoc = rooted
399
+ ? file.doc.jobsDoc
400
+ : Object.fromEntries(Object.entries(file.doc.jobsDoc).map(([job, props]) => {
401
+ const { steps, ...rest } = props;
402
+ return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
403
+ }));
404
+ const group = file.doc.concurrency.group;
405
+ return {
406
+ name,
407
+ doc: {
408
+ ...file.doc,
409
+ ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
410
+ on,
411
+ concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
412
+ jobsDoc,
413
+ },
414
+ };
415
+ }
416
+ /**
417
+ * Emit a `GithubOpPipelineDoc`'s `on`/`env`/`concurrency`/`permissions`/`jobs`
418
+ * mappings as workflow YAML. Shared with the forgejo dialect (#969), which
419
+ * transforms the doc first (and drops `permissions`, which it ignores).
420
+ */
421
+ export function emitOpPipelineYAML(doc) {
422
+ // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
423
+ // is carried separately on the doc so a dialect transforms it on its own.
424
+ // A dialect's `header` note (#2257) is absent on github.
425
+ const { gatedNoticeDoc, ...workflow } = doc;
426
+ return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}) } });
427
+ }
428
+ /**
429
+ * Synthesize one `.github/workflows/*.yml` per scheduled Op. Thin wrapper
430
+ * over {@link buildGithubOpPipelineDocs} + {@link emitOpPipelineYAML}. Wired
431
+ * into core's Op generate mode via the github lexicon plugin's
432
+ * `generateOpPipeline` (../plugin.ts).
433
+ */
434
+ export function generateGithubOpPipeline(ops, options = {}) {
435
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
436
+ return {
437
+ files: files.map((f) => ({ name: f.name, yaml: emitOpPipelineYAML(f.doc) })),
438
+ jobs,
439
+ };
440
+ }