@intentius/chant-lexicon-github 0.59.0 → 0.61.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.
- package/dist/components/generate-op-pipeline.d.ts +37 -3
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/generate-op-pipeline.test.ts +127 -0
- package/src/components/generate-op-pipeline.ts +84 -2
|
@@ -46,8 +46,19 @@
|
|
|
46
46
|
* reason) and never touching one it does; {@link mergePermissions} refuses a
|
|
47
47
|
* blanket grant, an overlap with the mode's own set, an unknown scope name,
|
|
48
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.
|
|
49
60
|
*/
|
|
50
|
-
import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
61
|
+
import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
51
62
|
export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
52
63
|
/**
|
|
53
64
|
* The structured pipeline document behind one generated file, before YAML
|
|
@@ -57,6 +68,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
|
57
68
|
* `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
|
|
58
69
|
*/
|
|
59
70
|
export interface GithubOpPipelineDoc {
|
|
71
|
+
/**
|
|
72
|
+
* Comment lines emitted above the document, `#` prefix included, when a
|
|
73
|
+
* dialect has something to say about what it could not carry across
|
|
74
|
+
* (#2257). Empty on github, which drops nothing; the forgejo dialect uses
|
|
75
|
+
* it to name the `environment:` its runner has no concept of, so the fact
|
|
76
|
+
* that a reviewer gate did not survive is readable in the generated file
|
|
77
|
+
* rather than only in a build warning.
|
|
78
|
+
*/
|
|
79
|
+
header?: string[];
|
|
60
80
|
/**
|
|
61
81
|
* The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
|
|
62
82
|
* (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
|
|
@@ -80,8 +100,10 @@ export interface GithubOpPipelineDoc {
|
|
|
80
100
|
* Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
|
|
81
101
|
* simply not copying it: the job shells to `gh` against the GitHub API and
|
|
82
102
|
* needs `gh` on the runner, which is the same reason the `comment` finding
|
|
83
|
-
* mode is refused on forgejo
|
|
84
|
-
*
|
|
103
|
+
* mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
|
|
104
|
+
* into `jobs:` for the forges that can. GitLab reaches the same outcome
|
|
105
|
+
* without this job at all: its push job writes the pending block to an
|
|
106
|
+
* artifact instead (#2256).
|
|
85
107
|
*/
|
|
86
108
|
gatedNoticeDoc?: Record<string, unknown>;
|
|
87
109
|
}
|
|
@@ -102,6 +124,18 @@ export interface GithubOpPipelineFile {
|
|
|
102
124
|
* cannot know a local path resolves there.
|
|
103
125
|
*/
|
|
104
126
|
export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
|
|
127
|
+
/**
|
|
128
|
+
* Validate a spec's `environment` (#2257). GitHub creates an environment it
|
|
129
|
+
* has never seen on first use rather than failing the run, and an environment
|
|
130
|
+
* created that way carries no protection rules at all — so a job can name one
|
|
131
|
+
* and read as gated while being gated by nothing. Neither this generator nor
|
|
132
|
+
* GitHub can tell those apart at build time (the environment and its
|
|
133
|
+
* reviewers are repository configuration, not workflow content), which is why
|
|
134
|
+
* what is refused here is only the shape that could never bind: a name that
|
|
135
|
+
* is blank, and a `url` that is neither absolute nor an expression the forge
|
|
136
|
+
* resolves. The rest is the README's job to say out loud.
|
|
137
|
+
*/
|
|
138
|
+
export declare function assertEnvironment(name: string, environment: OpEnvironment): void;
|
|
105
139
|
/**
|
|
106
140
|
* Merge a spec's additive `permissions` over the finding-mode's own set
|
|
107
141
|
* (#2242), refusing by name anything that is not strictly additive:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAsQD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "81759e8725dbe9f79f6747195efe17ce7e0504182857c99a317784b02c7ef629",
|
|
5
5
|
"meta.json": "dc3977afc2b6ddc4904de906d1b1e448b786b2aced9d82f8eade2ff915203344",
|
|
6
6
|
"types/index.d.ts": "f207946d7ab52f712d0c09995366440a6bba8b39358057b0da38ff9c0acca429",
|
|
7
7
|
"rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
78
78
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
79
79
|
},
|
|
80
|
-
"composite": "
|
|
80
|
+
"composite": "b47a71d4987ee8534216ed299add82e196f2ba21026e79bf6490473bb8df525c"
|
|
81
81
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.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.
|
|
64
|
+
"@intentius/chant": "^0.61.0",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -27,6 +27,7 @@ interface ParsedStep {
|
|
|
27
27
|
interface ParsedJob {
|
|
28
28
|
"runs-on"?: string;
|
|
29
29
|
container?: string;
|
|
30
|
+
environment?: Record<string, string>;
|
|
30
31
|
needs?: string;
|
|
31
32
|
if?: string;
|
|
32
33
|
permissions?: Record<string, string>;
|
|
@@ -515,3 +516,129 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
515
516
|
expect(step?.run).toContain("set -o pipefail");
|
|
516
517
|
});
|
|
517
518
|
});
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* chant #2257 — the second gate. A GitHub environment carries its own
|
|
522
|
+
* protection rules (required reviewers above all), and until this existed no
|
|
523
|
+
* generated job named one, so a `production` environment declared in a
|
|
524
|
+
* repository bound nothing chant generated. What is asserted here is the key
|
|
525
|
+
* on the right job, the key's absence everywhere else, and that adding it
|
|
526
|
+
* changes nothing else in the document.
|
|
527
|
+
*/
|
|
528
|
+
describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2257)", () => {
|
|
529
|
+
const APPLY: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
|
|
530
|
+
const GATED: ScheduledOpSpec = { ...APPLY, environment: { name: "production" } };
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The exact document a spec with no `environment` emitted before the option
|
|
534
|
+
* existed, produced by the generator at the commit this change branched
|
|
535
|
+
* from. The claim the option makes is that it is additive; this is what
|
|
536
|
+
* makes that claim falsifiable rather than a sentence in a PR body.
|
|
537
|
+
*/
|
|
538
|
+
const AUDIT_YAML_BEFORE_2257 =
|
|
539
|
+
[
|
|
540
|
+
"on:",
|
|
541
|
+
" schedule:",
|
|
542
|
+
" - cron: '0 6 * * *'",
|
|
543
|
+
" workflow_dispatch: {}",
|
|
544
|
+
"",
|
|
545
|
+
"concurrency:",
|
|
546
|
+
" group: actions-audit",
|
|
547
|
+
" cancel-in-progress: false",
|
|
548
|
+
"",
|
|
549
|
+
"permissions:",
|
|
550
|
+
" contents: read",
|
|
551
|
+
" issues: write",
|
|
552
|
+
"",
|
|
553
|
+
"jobs:",
|
|
554
|
+
" actions-audit:",
|
|
555
|
+
" runs-on: ubuntu-latest",
|
|
556
|
+
" container: node:22-slim",
|
|
557
|
+
" steps:",
|
|
558
|
+
" - uses: actions/checkout@v4",
|
|
559
|
+
" - run: chant run actions-audit",
|
|
560
|
+
" env:",
|
|
561
|
+
" GITHUB_TOKEN: '${{ github.token }}'",
|
|
562
|
+
" GH_TOKEN: '${{ github.token }}'",
|
|
563
|
+
].join("\n") + "\n";
|
|
564
|
+
|
|
565
|
+
test("a spec with no environment emits the bytes it emitted before the option existed", () => {
|
|
566
|
+
const yaml = generateGithubOpPipeline([
|
|
567
|
+
{ name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
|
|
568
|
+
]).files[0].yaml;
|
|
569
|
+
expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("emits environment: on the Op's job, as a mapping rather than the string shorthand", () => {
|
|
573
|
+
const job = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs!["app-apply"];
|
|
574
|
+
expect(job.environment).toEqual({ name: "production" });
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("carries the url when the spec sets one", () => {
|
|
578
|
+
const spec: ScheduledOpSpec = {
|
|
579
|
+
...APPLY,
|
|
580
|
+
environment: { name: "production", url: "https://app.example.com" },
|
|
581
|
+
};
|
|
582
|
+
const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-apply"];
|
|
583
|
+
expect(job.environment).toEqual({
|
|
584
|
+
name: "production",
|
|
585
|
+
url: "https://app.example.com",
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("adding the environment changes exactly the environment block and nothing else", () => {
|
|
590
|
+
const before = generateGithubOpPipeline([APPLY]).files[0].yaml;
|
|
591
|
+
const after = generateGithubOpPipeline([GATED]).files[0].yaml;
|
|
592
|
+
expect(after).toContain(" environment:\n name: production\n");
|
|
593
|
+
expect(after.replace(" environment:\n name: production\n", "")).toBe(before);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("the gate-notice job is not held behind the same reviewer", () => {
|
|
597
|
+
// It exists to say a chant gate is pending. Behind the environment it
|
|
598
|
+
// would only be readable after somebody had already released the job it
|
|
599
|
+
// is reporting on, which is after the message stops being useful.
|
|
600
|
+
const notice = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs![
|
|
601
|
+
"app-apply-gate-notice"
|
|
602
|
+
];
|
|
603
|
+
expect(notice.environment).toBeUndefined();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("costs no token scope: environment protection is repository configuration", () => {
|
|
607
|
+
// `permissionsFor` gains nothing from the option — the reviewer lives on
|
|
608
|
+
// the environment object, not on GITHUB_TOKEN — so the workflow-level set
|
|
609
|
+
// is identical with and without it.
|
|
610
|
+
const withEnv = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml);
|
|
611
|
+
const withoutEnv = parseFile(generateGithubOpPipeline([APPLY]).files[0].yaml);
|
|
612
|
+
expect(withEnv.permissions).toEqual(withoutEnv.permissions);
|
|
613
|
+
expect(withEnv.permissions).toEqual({ contents: "read" });
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("applies to any trigger, so a pull_request plan can name a review environment too", () => {
|
|
617
|
+
const spec: ScheduledOpSpec = {
|
|
618
|
+
name: "app-plan",
|
|
619
|
+
trigger: { kind: "pull_request", branches: ["main"] },
|
|
620
|
+
findingMode: "comment",
|
|
621
|
+
environment: { name: "review", url: "${{ steps.deploy.outputs.url }}" },
|
|
622
|
+
};
|
|
623
|
+
const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-plan"];
|
|
624
|
+
expect(job.environment?.name).toBe("review");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("refuses a blank environment name, which resolves to nothing", () => {
|
|
628
|
+
expect(() =>
|
|
629
|
+
generateGithubOpPipeline([{ ...APPLY, environment: { name: " " } }]),
|
|
630
|
+
).toThrow(/environment has an empty `name`/);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("refuses a url that is neither absolute nor an expression, which renders as a dead link", () => {
|
|
634
|
+
expect(() =>
|
|
635
|
+
generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "/deploys" } }]),
|
|
636
|
+
).toThrow(/neither an absolute http\(s\) URL nor a/);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("refuses an empty url rather than emitting one", () => {
|
|
640
|
+
expect(() =>
|
|
641
|
+
generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "" } }]),
|
|
642
|
+
).toThrow(/has an empty `url`/);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
@@ -46,12 +46,24 @@
|
|
|
46
46
|
* reason) and never touching one it does; {@link mergePermissions} refuses a
|
|
47
47
|
* blanket grant, an overlap with the mode's own set, an unknown scope name,
|
|
48
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.
|
|
49
60
|
*/
|
|
50
61
|
|
|
51
62
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
52
63
|
import { resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
53
64
|
import type {
|
|
54
65
|
ComponentPipelineOptions as GenerateGithubOpOptions,
|
|
66
|
+
OpEnvironment,
|
|
55
67
|
OpFindingMode,
|
|
56
68
|
OpPipelineJob,
|
|
57
69
|
OpPipelineResult as GenerateGithubOpResult,
|
|
@@ -70,6 +82,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
|
70
82
|
* `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
|
|
71
83
|
*/
|
|
72
84
|
export interface GithubOpPipelineDoc {
|
|
85
|
+
/**
|
|
86
|
+
* Comment lines emitted above the document, `#` prefix included, when a
|
|
87
|
+
* dialect has something to say about what it could not carry across
|
|
88
|
+
* (#2257). Empty on github, which drops nothing; the forgejo dialect uses
|
|
89
|
+
* it to name the `environment:` its runner has no concept of, so the fact
|
|
90
|
+
* that a reviewer gate did not survive is readable in the generated file
|
|
91
|
+
* rather than only in a build warning.
|
|
92
|
+
*/
|
|
93
|
+
header?: string[];
|
|
73
94
|
/**
|
|
74
95
|
* The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
|
|
75
96
|
* (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
|
|
@@ -93,8 +114,10 @@ export interface GithubOpPipelineDoc {
|
|
|
93
114
|
* Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
|
|
94
115
|
* simply not copying it: the job shells to `gh` against the GitHub API and
|
|
95
116
|
* needs `gh` on the runner, which is the same reason the `comment` finding
|
|
96
|
-
* mode is refused on forgejo
|
|
97
|
-
*
|
|
117
|
+
* mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
|
|
118
|
+
* into `jobs:` for the forges that can. GitLab reaches the same outcome
|
|
119
|
+
* without this job at all: its push job writes the pending block to an
|
|
120
|
+
* artifact instead (#2256).
|
|
98
121
|
*/
|
|
99
122
|
gatedNoticeDoc?: Record<string, unknown>;
|
|
100
123
|
}
|
|
@@ -408,6 +431,43 @@ export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
|
|
|
408
431
|
});
|
|
409
432
|
}
|
|
410
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Validate a spec's `environment` (#2257). GitHub creates an environment it
|
|
436
|
+
* has never seen on first use rather than failing the run, and an environment
|
|
437
|
+
* created that way carries no protection rules at all — so a job can name one
|
|
438
|
+
* and read as gated while being gated by nothing. Neither this generator nor
|
|
439
|
+
* GitHub can tell those apart at build time (the environment and its
|
|
440
|
+
* reviewers are repository configuration, not workflow content), which is why
|
|
441
|
+
* what is refused here is only the shape that could never bind: a name that
|
|
442
|
+
* is blank, and a `url` that is neither absolute nor an expression the forge
|
|
443
|
+
* resolves. The rest is the README's job to say out loud.
|
|
444
|
+
*/
|
|
445
|
+
export function assertEnvironment(name: string, environment: OpEnvironment): void {
|
|
446
|
+
const where = `Scheduled Op "${name}" environment`;
|
|
447
|
+
if (environment.name.trim() === "") {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`${where} has an empty \`name\`. An environment is named repository configuration — the ` +
|
|
450
|
+
`protection rules and reviewers live on the environment, not in this workflow — so there is ` +
|
|
451
|
+
`nothing for a blank name to resolve to. Give it the environment's name, or drop the option.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (environment.url === undefined) return;
|
|
455
|
+
const url = environment.url.trim();
|
|
456
|
+
if (url === "") {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (!/^https?:\/\//.test(url) && !url.includes("${{")) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
|
|
464
|
+
`http(s) URL nor a \${{ }} expression. GitHub renders this value as the deployment's own link, ` +
|
|
465
|
+
`so a relative path becomes a dead link on the environment page rather than an error anywhere. ` +
|
|
466
|
+
`Write the full URL, or an expression the run resolves to one.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
411
471
|
/**
|
|
412
472
|
* Merge a spec's additive `permissions` over the finding-mode's own set
|
|
413
473
|
* (#2242), refusing by name anything that is not strictly additive:
|
|
@@ -472,6 +532,18 @@ export function mergePermissions(
|
|
|
472
532
|
return merged;
|
|
473
533
|
}
|
|
474
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Emit a spec's environment as the job's `environment:` mapping. Always the
|
|
537
|
+
* mapping form, never the `environment: name` string shorthand, so adding a
|
|
538
|
+
* `url` later is a new key rather than a reshaped value.
|
|
539
|
+
*/
|
|
540
|
+
function environmentDoc(environment: OpEnvironment): Record<string, unknown> {
|
|
541
|
+
return {
|
|
542
|
+
name: environment.name,
|
|
543
|
+
...(environment.url === undefined ? {} : { url: environment.url }),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
475
547
|
/** Emit one setup entry as a GitHub Actions step. */
|
|
476
548
|
function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
|
|
477
549
|
if ("uses" in step) {
|
|
@@ -527,6 +599,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
527
599
|
|
|
528
600
|
const setup = spec.setup ?? [];
|
|
529
601
|
assertSetupSteps(spec.name, setup);
|
|
602
|
+
if (spec.environment) assertEnvironment(spec.name, spec.environment);
|
|
530
603
|
|
|
531
604
|
// A `push` job is the one that has to survive a gate (#2243): the apply
|
|
532
605
|
// runs with `--gated-exit 0` so a pending approval is a green run, and
|
|
@@ -563,6 +636,11 @@ export function buildGithubOpPipelineDocs(
|
|
|
563
636
|
[jobName]: {
|
|
564
637
|
"runs-on": "ubuntu-latest",
|
|
565
638
|
container: image,
|
|
639
|
+
// On this Op's own job and never on the notice job beside it: the
|
|
640
|
+
// notice exists to say a chant gate is pending, and putting it
|
|
641
|
+
// behind the same reviewer would hold the message back until
|
|
642
|
+
// somebody had already acted.
|
|
643
|
+
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
566
644
|
...(gated
|
|
567
645
|
? {
|
|
568
646
|
outputs: Object.fromEntries(
|
|
@@ -592,6 +670,10 @@ export function buildGithubOpPipelineDocs(
|
|
|
592
670
|
*/
|
|
593
671
|
export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
|
|
594
672
|
const sections: string[] = [];
|
|
673
|
+
// A dialect's note about what it could not carry (#2257), when there is
|
|
674
|
+
// one. Absent on github, so an unannotated document is emitted exactly as
|
|
675
|
+
// it was before the field existed.
|
|
676
|
+
if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
|
|
595
677
|
sections.push("on:" + emitYAML(doc.on, 1));
|
|
596
678
|
if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
|
|
597
679
|
sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
|