@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.
- package/dist/action-pins.js +53 -0
- package/dist/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +142 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +36 -0
- package/dist/codegen/generate-typescript.js +108 -0
- package/dist/codegen/generate.js +111 -0
- package/dist/codegen/naming.js +49 -0
- package/dist/codegen/package.js +40 -0
- package/dist/codegen/parse.js +595 -0
- package/dist/codegen/patches.js +36 -0
- package/dist/components/ci-jobs.d.ts +7 -0
- package/dist/components/ci-jobs.d.ts.map +1 -1
- package/dist/components/ci-jobs.js +98 -0
- package/dist/components/generate-op-pipeline.js +440 -0
- package/dist/components/generate-op-waves-pipeline.js +158 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +481 -0
- package/dist/composites/cache.js +17 -0
- package/dist/composites/catalog.js +859 -0
- package/dist/composites/checkout.js +25 -0
- package/dist/composites/dependabot.js +43 -0
- package/dist/composites/deploy-environment.js +38 -0
- package/dist/composites/docker-build.js +70 -0
- package/dist/composites/download-artifact.js +18 -0
- package/dist/composites/go-ci.js +62 -0
- package/dist/composites/index.js +15 -0
- package/dist/composites/node-ci.js +44 -0
- package/dist/composites/node-pipeline.js +96 -0
- package/dist/composites/pr-plan-report.js +129 -0
- package/dist/composites/python-ci.js +60 -0
- package/dist/composites/setup-go.js +18 -0
- package/dist/composites/setup-node.js +21 -0
- package/dist/composites/setup-python.js +18 -0
- package/dist/composites/upload-artifact.js +19 -0
- package/dist/coverage.js +32 -0
- package/dist/expression.js +177 -0
- package/dist/generated/index.js +26 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/graph-edges.js +174 -0
- package/dist/import/generator.js +108 -0
- package/dist/import/parser.js +69 -0
- package/dist/index.js +22 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +69 -0
- package/dist/lint/audit-lineage.js +168 -0
- package/dist/lint/post-synth/gha006.js +50 -0
- package/dist/lint/post-synth/gha009.js +35 -0
- package/dist/lint/post-synth/gha011.js +34 -0
- package/dist/lint/post-synth/gha013.js +40 -0
- package/dist/lint/post-synth/gha017.js +25 -0
- package/dist/lint/post-synth/gha018.js +33 -0
- package/dist/lint/post-synth/gha019.js +58 -0
- package/dist/lint/post-synth/gha021.js +42 -0
- package/dist/lint/post-synth/gha022.js +44 -0
- package/dist/lint/post-synth/gha023.js +37 -0
- package/dist/lint/post-synth/gha024.js +32 -0
- package/dist/lint/post-synth/gha025.js +33 -0
- package/dist/lint/post-synth/gha026.js +32 -0
- package/dist/lint/post-synth/gha027.js +46 -0
- package/dist/lint/post-synth/gha028.js +28 -0
- package/dist/lint/post-synth/gha029.js +56 -0
- package/dist/lint/post-synth/gha030.js +35 -0
- package/dist/lint/post-synth/gha031.js +75 -0
- package/dist/lint/post-synth/gha032.js +35 -0
- package/dist/lint/post-synth/gha033.js +39 -0
- package/dist/lint/post-synth/gha034.js +35 -0
- package/dist/lint/post-synth/gha035.js +50 -0
- package/dist/lint/post-synth/gha036.js +37 -0
- package/dist/lint/post-synth/gha037.js +41 -0
- package/dist/lint/post-synth/gha038.js +35 -0
- package/dist/lint/post-synth/gha039.js +34 -0
- package/dist/lint/post-synth/gha040.js +36 -0
- package/dist/lint/post-synth/gha041.js +32 -0
- package/dist/lint/post-synth/gha042.js +33 -0
- package/dist/lint/post-synth/gha043.js +35 -0
- package/dist/lint/post-synth/gha044.js +36 -0
- package/dist/lint/post-synth/gha045.js +33 -0
- package/dist/lint/post-synth/gha046.js +55 -0
- package/dist/lint/post-synth/gha047.js +44 -0
- package/dist/lint/post-synth/gha048.js +34 -0
- package/dist/lint/post-synth/gha049.js +36 -0
- package/dist/lint/post-synth/gha050.js +37 -0
- package/dist/lint/post-synth/gha051.js +38 -0
- package/dist/lint/post-synth/gha052.js +35 -0
- package/dist/lint/post-synth/gha053.js +34 -0
- package/dist/lint/post-synth/gha054.js +35 -0
- package/dist/lint/post-synth/gha055.js +44 -0
- package/dist/lint/post-synth/gha056.js +32 -0
- package/dist/lint/post-synth/gha057.js +35 -0
- package/dist/lint/post-synth/gha058.js +42 -0
- package/dist/lint/post-synth/gha059.js +119 -0
- package/dist/lint/post-synth/gha060.js +129 -0
- package/dist/lint/post-synth/gha061.js +78 -0
- package/dist/lint/post-synth/gha062.js +65 -0
- package/dist/lint/post-synth/gha063.js +56 -0
- package/dist/lint/post-synth/gha064.js +47 -0
- package/dist/lint/post-synth/gha065.js +125 -0
- package/dist/lint/post-synth/gha066.js +40 -0
- package/dist/lint/post-synth/gha067.js +75 -0
- package/dist/lint/post-synth/gha068.js +47 -0
- package/dist/lint/post-synth/gha069.js +99 -0
- package/dist/lint/post-synth/index.js +114 -0
- package/dist/lint/post-synth/yaml-helpers.js +465 -0
- package/dist/lint/rules/data/action-usage-policy.js +16 -0
- package/dist/lint/rules/data/advisory-feed.js +15 -0
- package/dist/lint/rules/data/deprecated-versions.js +14 -0
- package/dist/lint/rules/data/flagged-actions.js +18 -0
- package/dist/lint/rules/data/known-action-slugs.js +38 -0
- package/dist/lint/rules/data/known-actions.js +12 -0
- package/dist/lint/rules/data/preinstalled-tools.js +36 -0
- package/dist/lint/rules/data/recommended-inputs.js +9 -0
- package/dist/lint/rules/data/risky-features.js +22 -0
- package/dist/lint/rules/data/secret-patterns.js +30 -0
- package/dist/lint/rules/data/trusted-action-owners.js +12 -0
- package/dist/lint/rules/data/untrusted-contexts.js +44 -0
- package/dist/lint/rules/deprecated-action-version.js +44 -0
- package/dist/lint/rules/detect-secrets.js +45 -0
- package/dist/lint/rules/extract-inline-structs.js +55 -0
- package/dist/lint/rules/file-job-limit.js +43 -0
- package/dist/lint/rules/index.js +16 -0
- package/dist/lint/rules/job-timeout.js +53 -0
- package/dist/lint/rules/missing-recommended-inputs.js +51 -0
- package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
- package/dist/lint/rules/no-raw-expressions.js +44 -0
- package/dist/lint/rules/suggest-cache.js +60 -0
- package/dist/lint/rules/use-condition-builders.js +37 -0
- package/dist/lint/rules/use-matrix-builder.js +36 -0
- package/dist/lint/rules/use-typed-actions.js +41 -0
- package/dist/lint/rules/validate-concurrency.js +59 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +152 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +344 -0
- package/dist/serializer.js +572 -0
- package/dist/spec/fetch.js +46 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +38 -0
- package/dist/variables.js +41 -0
- package/package.json +6 -6
- package/src/codegen/package.ts +2 -2
- package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
- package/src/components/__fixtures__/pr-loop-record.github.golden.yml +74 -0
- package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
- package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
- package/src/components/ci-jobs.ts +16 -2
- package/src/components/generate-op-pipeline.test.ts +11 -0
- package/src/components/generate-op-pipeline.ts +3 -3
- package/src/components/generate-pipeline.ts +74 -31
- package/src/components/generate-pr-pipeline.test.ts +99 -29
- package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
- package/src/lsp/completions.ts +2 -1
- 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
|
+
}
|