@intentius/chant-lexicon-gitlab 0.49.0 → 0.50.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Generate mode — scheduled Op → GitLab CI YAML (#927).
3
+ *
4
+ * The Op counterpart to `./generate-pipeline.ts` (#563): that module
5
+ * synthesizes a deploy-time component graph as one `.gitlab-ci.yml`; this one
6
+ * synthesizes a cron-triggered job per stateless Op — the CI-native
7
+ * alternative to a Temporal `TemporalSchedule` for downstream projects that
8
+ * don't run Temporal (`WorkflowAuditOp`/`PipelineAuditOp`/`ReconcileOp` all
9
+ * accept an optional `schedule` precisely for this).
10
+ *
11
+ * Unlike GitHub Actions' per-workflow `on.schedule`, GitLab has no in-file
12
+ * cron at all — a schedule is a project-level object (Settings → CI/CD →
13
+ * Schedules) that runs the project's *existing* `.gitlab-ci.yml` with a
14
+ * chosen cron and CI/CD variables. So every scheduled Op here becomes one
15
+ * job in a single generated file, gated to run only under its own Pipeline
16
+ * Schedule (`$CI_PIPELINE_SOURCE == "schedule"` plus a per-op selector
17
+ * variable) — the cron itself is configured on the Pipeline Schedule, not in
18
+ * this YAML, and the generated file's header comment states what to set up.
19
+ * Each job runs exactly one invocation, `chant run <name>` by default — never
20
+ * inlined audit/reconcile logic. The finding-mode itself is already baked
21
+ * into the Op's own activity args at build time by the composite that
22
+ * created it; GitLab has no per-job `permissions:` concept (unlike GitHub
23
+ * Actions), so a non-`report` mode's write access comes from whatever
24
+ * `GITLAB_TOKEN`/CI-CD-variable configuration the project already has —
25
+ * this generator documents the requirement rather than fabricating a
26
+ * variable nothing reads.
27
+ */
28
+ import type { ComponentPipelineOptions as GenerateGitlabOpOptions, OpPipelineResult as GenerateGitlabOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
29
+ export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
30
+ /**
31
+ * Synthesize one `.gitlab-ci.yml` job per scheduled Op, all in a single file
32
+ * (GitLab has no per-file cron — see the module doc). Wired into core's Op
33
+ * generate mode via the gitlab lexicon plugin's `generateOpPipeline`
34
+ * (../plugin.ts).
35
+ */
36
+ export declare function generateGitlabOpPipeline(ops: ScheduledOpSpec[], options?: GenerateGitlabOpOptions): GenerateGitlabOpResult;
37
+ //# sourceMappingURL=generate-op-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAGH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAGnD,gBAAgB,IAAI,sBAAsB,EAC1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAmBhE;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CA6CxB"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { gitlabSerializer } from "./serializer.js";
2
2
  export { gitlabPlugin } from "./plugin.js";
3
+ export { gitlabPipeline } from "./op/builders.js";
3
4
  export { reference, ReferenceIntrinsic } from "./intrinsics.js";
4
5
  export { CI } from "./variables.js";
5
6
  export * from "./generated/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAG7D,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAIjC,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnI,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGtI,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpF,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGzH,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAQxC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAG7D,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAIjC,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnI,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGtI,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpF,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGzH,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "6e5bfe31912b7af3fa1bd5241c2a22ae87b5e95afbe871d0736fd8d96cd3cd69",
4
+ "manifest.json": "b1cc9643fe6dbb20872dbccf6f0ac296fd7b2ab57a3d92b8a45915b1eeaf7743",
5
5
  "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
6
  "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -48,10 +48,12 @@
48
48
  "rules/wgl046.ts": "bbd8f5075aa390db546f5e787ce542f31a9d5eb031578780480e770cd86f9b5b",
49
49
  "rules/wgl047.ts": "e6dba6a0c6c042e90a200859e52a0da28c56ce52705e147760eca0a0a37c3255",
50
50
  "rules/wgl048.ts": "810590da4e7eaf70a6332ae247dbd34e000e2af57c89d3bd5a19bb2a923fcbb1",
51
+ "rules/wgl049.ts": "875ba2203d66a2a444edf17b43c2ae438a3a23a7f579b81a448efc3d5dd4336e",
52
+ "rules/wgl050.ts": "c96ef683ecf6e55699ea62d669ba6359616d723dd1bc24880823da0ff6c86503",
51
53
  "rules/yaml-helpers.ts": "5835982d5d088eb4788ebad8160b181bfdb6fc94d546eabee90a4c278a913988",
52
54
  "skills/chant-gitlab.md": "1e26a0c50ea891423479bd7fec3bc133f9185727c4a3eaadd525d7e7436056b6",
53
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
54
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
55
57
  },
56
- "composite": "9f91a75e02d3e027b840d19762da56a8155b6c95227917df289e27fe46d48f8b"
58
+ "composite": "ea16ce1e577f7c99329bab5b18295456acec8809608d0fb006f6715479863b74"
57
59
  }
@@ -1 +1 @@
1
- {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAA0E,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAEvI,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAwCvD,CAAC"}
1
+ {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAA0E,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAEvI,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA6CvD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAyCvE,eAAO,MAAM,eAAe,EAAE,cAAc,EAwC3C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AA2CvE,eAAO,MAAM,eAAe,EAAE,cAAc,EA0C3C,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * WGL049: Dependency Install Without a Cache
3
+ *
4
+ * Flags a job whose `script:` runs a package-manager install command (`npm
5
+ * ci`, `pip install`, `bundle install`, etc.) with no `cache:` in scope —
6
+ * neither on the job itself, nor at the pipeline's `default:`/top level.
7
+ * Every run re-fetches the same dependencies from a cold cache. A job that
8
+ * `extends:` another config is left alone — the cache may be inherited and
9
+ * this check has no way to confirm that. Efficiency (#444), not a
10
+ * correctness or security issue.
11
+ */
12
+ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
13
+ export declare const wgl049: PostSynthCheck;
14
+ //# sourceMappingURL=wgl049.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wgl049.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/wgl049.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAyC,MAAM,kCAAkC,CAAC;AAY9G,eAAO,MAAM,MAAM,EAAE,cAgCpB,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * WGL050: Merge-Request Job Missing interruptible
3
+ *
4
+ * Flags a job reachable from merge-request pipelines that doesn't set
5
+ * `interruptible: true`. Without it, GitLab's auto-cancel-redundant-pipelines
6
+ * setting can't cancel the job when a new push supersedes it, so the runner
7
+ * keeps spending capacity on a pipeline nobody wants anymore. Deploy jobs are
8
+ * left alone — cancelling mid-deploy is its own hazard, not an efficiency
9
+ * win. Efficiency (#444), not a correctness or security issue.
10
+ */
11
+ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
12
+ export declare const wgl050: PostSynthCheck;
13
+ //# sourceMappingURL=wgl050.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wgl050.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/wgl050.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAyC,MAAM,kCAAkC,CAAC;AAK9G,eAAO,MAAM,MAAM,EAAE,cAgCpB,CAAC"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/dist/okf/index.md CHANGED
@@ -53,3 +53,5 @@ okf_version: '0.2'
53
53
  * [WGL046](/rules/WGL046.md) - Cache populated in a merge-request pipeline (poisoning risk)
54
54
  * [WGL047](/rules/WGL047.md) - Software fetched and piped to a shell without verification
55
55
  * [WGL048](/rules/WGL048.md) - Pipeline defines workflow: but no workflow:name
56
+ * [WGL049](/rules/WGL049.md) - Job installs dependencies with no cache: in scope
57
+ * [WGL050](/rules/WGL050.md) - Merge-request-reachable job is not interruptible
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: WGL049
4
+ description: 'Job installs dependencies with no cache: in scope'
5
+ id: WGL049
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: gitlab
9
+ docs: https://intentius.io/chant/lexicons/gitlab/rules/
10
+ ---
11
+ Job installs dependencies with no cache: in scope
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: WGL050
4
+ description: Merge-request-reachable job is not interruptible
5
+ id: WGL050
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: gitlab
9
+ docs: https://intentius.io/chant/lexicons/gitlab/rules/
10
+ ---
11
+ Merge-request-reachable job is not interruptible
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -84,3 +84,5 @@ resource_type: GitLab::CI::Job
84
84
  - [WGL045](/rules/WGL045.md): Artifact path that may capture a credential file
85
85
  - [WGL046](/rules/WGL046.md): Cache populated in a merge-request pipeline (poisoning risk)
86
86
  - [WGL047](/rules/WGL047.md): Software fetched and piped to a shell without verification
87
+ - [WGL049](/rules/WGL049.md): Job installs dependencies with no cache: in scope
88
+ - [WGL050](/rules/WGL050.md): Merge-request-reachable job is not interruptible
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Typed step-builder wrapper for this lexicon's `gitlabPipeline` activity
3
+ * (chant #1288 Stage 2 — "regenerate the step builders as fully typed
4
+ * wrappers"). Core cannot import a lexicon's `*Args` types (lexicons depend
5
+ * on core, not the other way around), so a fully typed `gitlabPipeline` has
6
+ * to live beside {@link GitlabPipelineArgs} — here, next to
7
+ * `./activities/gitlab.ts`. `opts`'s type below IS `GitlabPipelineArgs`
8
+ * itself (via `Omit`/`WithStepRefs`) — never a hand-restated mirror.
9
+ *
10
+ * `core`'s own `gitlabPipeline` (in `@intentius/chant/op`, re-exported from
11
+ * `@intentius/chant-lexicon-temporal`) is UNCHANGED and produces a
12
+ * byte-identical `ActivityStep` for the same inputs — purely additive, and
13
+ * deliberately not swapped into the temporal barrel (see
14
+ * `lexicons/k8s/src/op/builders.ts`'s module doc for why: that would make
15
+ * temporal depend on this package at runtime). An author who wants the typed
16
+ * surface imports it from here — `@intentius/chant-lexicon-gitlab`.
17
+ */
18
+ import { type ActivityStep, type NamedActivityStep, type WithStepRefs } from "@intentius/chant/op";
19
+ import type { GitlabPipelineArgs } from "./activities/gitlab.js";
20
+ /**
21
+ * Trigger and wait for a GitLab CI pipeline to complete — the fully typed
22
+ * twin of core's `gitlabPipeline`. `opts` is {@link GitlabPipelineArgs}
23
+ * itself, minus the positional `name`. Defaults to the `longInfra` profile.
24
+ */
25
+ export declare const gitlabPipeline: (name: string, opts?: WithStepRefs<Omit<GitlabPipelineArgs, "name">> & {
26
+ profile?: ActivityStep["profile"];
27
+ id?: string;
28
+ }) => NamedActivityStep;
29
+ //# sourceMappingURL=builders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builders.d.ts","sourceRoot":"","sources":["../../src/op/builders.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;GAIG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,MAAM,EACZ,OAAO,YAAY,CAAC,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC,GAAG;IAAE,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,KACzG,iBAGF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAmB7F,eAAO,MAAM,YAAY,EAAE,aA4gB1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAoB7F,eAAO,MAAM,YAAY,EAAE,aA8gB1B,CAAC"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WGL049: Dependency Install Without a Cache
3
+ *
4
+ * Flags a job whose `script:` runs a package-manager install command (`npm
5
+ * ci`, `pip install`, `bundle install`, etc.) with no `cache:` in scope —
6
+ * neither on the job itself, nor at the pipeline's `default:`/top level.
7
+ * Every run re-fetches the same dependencies from a cold cache. A job that
8
+ * `extends:` another config is left alone — the cache may be inherited and
9
+ * this check has no way to confirm that. Efficiency (#444), not a
10
+ * correctness or security issue.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
15
+
16
+ const DEP_INSTALL_RE = /\b(npm (install|ci)|yarn install|pnpm install|pip install|pip3 install|bundle install|composer install|go mod download|mvn (install|dependency:resolve))\b/i;
17
+
18
+ function hasPipelineWideCache(yaml: string): boolean {
19
+ const sections = yaml.split("\n\n");
20
+ if (sections.some((s) => /^cache:/.test(s))) return true;
21
+ const defaultSection = sections.find((s) => /^default:/.test(s));
22
+ return !!defaultSection && /\n\s+cache:/.test(defaultSection);
23
+ }
24
+
25
+ export const wgl049: PostSynthCheck = {
26
+ id: "WGL049",
27
+ description: "Job installs dependencies with no cache: in scope",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const diagnostics: PostSynthDiagnostic[] = [];
31
+
32
+ for (const [, output] of ctx.outputs) {
33
+ const yaml = getPrimaryOutput(output);
34
+ if (hasPipelineWideCache(yaml)) continue;
35
+
36
+ for (const [jobName, job] of extractJobs(yaml)) {
37
+ if (jobName.startsWith(".")) continue; // hidden/template job, not run directly
38
+ if (job.extends && job.extends.length > 0) continue; // cache may be inherited; can't confirm
39
+
40
+ const section = extractJobSection(yaml, jobName);
41
+ if (!section) continue;
42
+ if (/^\s+cache:/m.test(section)) continue; // job-level cache present
43
+ if (!DEP_INSTALL_RE.test(section)) continue;
44
+
45
+ diagnostics.push({
46
+ checkId: "WGL049",
47
+ severity: "info",
48
+ message: `Job "${jobName}" installs dependencies with no cache: in scope — every run re-fetches from the registry. Add a cache: covering the dependency directory.`,
49
+ entity: jobName,
50
+ lexicon: "gitlab",
51
+ });
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * WGL050: Merge-Request Job Missing interruptible
3
+ *
4
+ * Flags a job reachable from merge-request pipelines that doesn't set
5
+ * `interruptible: true`. Without it, GitLab's auto-cancel-redundant-pipelines
6
+ * setting can't cancel the job when a new push supersedes it, so the runner
7
+ * keeps spending capacity on a pipeline nobody wants anymore. Deploy jobs are
8
+ * left alone — cancelling mid-deploy is its own hazard, not an efficiency
9
+ * win. Efficiency (#444), not a correctness or security issue.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, isMergeRequestReachable } from "./yaml-helpers";
14
+
15
+ const RESERVED_TOP_LEVEL_KEYS = new Set(["stages", "default", "workflow", "variables", "include", "cache"]);
16
+
17
+ export const wgl050: PostSynthCheck = {
18
+ id: "WGL050",
19
+ description: "Merge-request-reachable job is not interruptible",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+
27
+ for (const section of yaml.split("\n\n")) {
28
+ const top = section.split("\n")[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
29
+ if (!top) continue;
30
+ const jobName = top[1];
31
+ if (jobName.startsWith(".") || RESERVED_TOP_LEVEL_KEYS.has(jobName)) continue;
32
+ if (/deploy/i.test(jobName)) continue; // cancelling mid-deploy is a hazard, not a win
33
+
34
+ if (!isMergeRequestReachable(section)) continue;
35
+ if (/^\s+interruptible:\s*true\s*$/m.test(section)) continue;
36
+
37
+ diagnostics.push({
38
+ checkId: "WGL050",
39
+ severity: "info",
40
+ message: `Job "${jobName}" runs on merge-request pipelines but is not interruptible: true — when a new commit supersedes this pipeline, this job keeps running instead of being cancelled. Add interruptible: true.`,
41
+ entity: jobName,
42
+ lexicon: "gitlab",
43
+ });
44
+ }
45
+ }
46
+
47
+ return diagnostics;
48
+ },
49
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.49.0",
71
- "@intentius/chant-lexicon-github": "^0.49.0",
70
+ "@intentius/chant": "^0.50.0",
71
+ "@intentius/chant-lexicon-github": "^0.50.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for generate mode's scheduled Op → GitLab CI YAML synthesis (#927).
3
+ * Three things this must prove, mirroring `./generate-pipeline.test.ts`'s
4
+ * acceptance style:
5
+ *
6
+ * 1. Every scheduled Op lands as one job in a single generated file (GitLab
7
+ * has no in-file cron — see the module doc), each job gated to its own
8
+ * Pipeline Schedule via `rules:`.
9
+ * 2. The header comment names every Op's cron and finding-mode, since the
10
+ * cron itself can't live in the YAML.
11
+ * 3. A cross-cutting generator change (runCommand/beforeScript/extraScript)
12
+ * is a single edit reflected in every job's script.
13
+ */
14
+
15
+ import { describe, test, expect } from "vitest";
16
+ import { parseYAML } from "@intentius/chant/yaml";
17
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
18
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
19
+
20
+ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
21
+ test("produces a single file with stages: and one job per Op", () => {
22
+ const specs: ScheduledOpSpec[] = [
23
+ { name: "actions-audit", schedule: "0 6 * * *" },
24
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "merge-request" },
25
+ ];
26
+ const result = generateGitlabOpPipeline(specs);
27
+
28
+ expect(result.files).toHaveLength(1);
29
+ expect(result.files[0].name).toBe("scheduled-ops.gitlab-ci.yml");
30
+
31
+ const parsed = parseYAML(result.files[0].yaml);
32
+ expect(parsed.stages).toEqual(["scheduled-ops"]);
33
+ expect(parsed["actions-audit"]).toBeDefined();
34
+ expect(parsed["prod-reconcile"]).toBeDefined();
35
+ });
36
+
37
+ test("each job is gated to its own Pipeline Schedule via rules:, runs chant run <name>", () => {
38
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
39
+ const parsed = parseYAML(result.files[0].yaml);
40
+ const job = parsed["actions-audit"] as Record<string, unknown>;
41
+
42
+ expect(job.stage).toBe("scheduled-ops");
43
+ expect(job.rules).toEqual([
44
+ { if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "actions-audit"' },
45
+ ]);
46
+ expect(job.script).toEqual(["chant run actions-audit"]);
47
+ });
48
+
49
+ test("the header comment names every Op's cron, selector value, and finding-mode", () => {
50
+ const result = generateGitlabOpPipeline([
51
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
52
+ ]);
53
+ expect(result.files[0].yaml).toContain('cron "0 6 * * *"');
54
+ expect(result.files[0].yaml).toContain('CHANT_SCHEDULED_OP="actions-audit"');
55
+ expect(result.files[0].yaml).toContain("finding-mode issue");
56
+ expect(result.files[0].yaml).toContain("GITLAB_TOKEN");
57
+ });
58
+
59
+ test("report mode's header line carries no token requirement", () => {
60
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
61
+ expect(result.files[0].yaml).not.toContain("GITLAB_TOKEN");
62
+ });
63
+
64
+ test("an empty Op set still produces the (empty) stages file", () => {
65
+ const result = generateGitlabOpPipeline([]);
66
+ expect(result.files).toHaveLength(1);
67
+ expect(result.jobs).toEqual([]);
68
+ const parsed = parseYAML(result.files[0].yaml);
69
+ expect(parsed.stages).toEqual(["scheduled-ops"]);
70
+ });
71
+ });
72
+
73
+ describe("generateGitlabOpPipeline: a cross-cutting change is one generator edit, not per-job", () => {
74
+ test("runCommand/beforeScript/extraScript apply uniformly across every job", () => {
75
+ const specs: ScheduledOpSpec[] = [
76
+ { name: "actions-audit", schedule: "0 6 * * *" },
77
+ { name: "prod-reconcile", schedule: "0 * * * *" },
78
+ ];
79
+ const result = generateGitlabOpPipeline(specs, {
80
+ runCommand: ["chant", "run", "{name}", "--temporal"],
81
+ beforeScript: ["npm ci"],
82
+ extraScript: ["echo done"],
83
+ });
84
+ const parsed = parseYAML(result.files[0].yaml);
85
+
86
+ for (const spec of specs) {
87
+ const job = parsed[spec.name] as Record<string, unknown>;
88
+ const script = job.script as string[];
89
+ expect(script[0]).toBe("npm ci");
90
+ expect(script[1]).toContain("--temporal");
91
+ expect(script[2]).toBe("echo done");
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Generate mode — scheduled Op → GitLab CI YAML (#927).
3
+ *
4
+ * The Op counterpart to `./generate-pipeline.ts` (#563): that module
5
+ * synthesizes a deploy-time component graph as one `.gitlab-ci.yml`; this one
6
+ * synthesizes a cron-triggered job per stateless Op — the CI-native
7
+ * alternative to a Temporal `TemporalSchedule` for downstream projects that
8
+ * don't run Temporal (`WorkflowAuditOp`/`PipelineAuditOp`/`ReconcileOp` all
9
+ * accept an optional `schedule` precisely for this).
10
+ *
11
+ * Unlike GitHub Actions' per-workflow `on.schedule`, GitLab has no in-file
12
+ * cron at all — a schedule is a project-level object (Settings → CI/CD →
13
+ * Schedules) that runs the project's *existing* `.gitlab-ci.yml` with a
14
+ * chosen cron and CI/CD variables. So every scheduled Op here becomes one
15
+ * job in a single generated file, gated to run only under its own Pipeline
16
+ * Schedule (`$CI_PIPELINE_SOURCE == "schedule"` plus a per-op selector
17
+ * variable) — the cron itself is configured on the Pipeline Schedule, not in
18
+ * this YAML, and the generated file's header comment states what to set up.
19
+ * Each job runs exactly one invocation, `chant run <name>` by default — never
20
+ * inlined audit/reconcile logic. The finding-mode itself is already baked
21
+ * into the Op's own activity args at build time by the composite that
22
+ * created it; GitLab has no per-job `permissions:` concept (unlike GitHub
23
+ * Actions), so a non-`report` mode's write access comes from whatever
24
+ * `GITLAB_TOKEN`/CI-CD-variable configuration the project already has —
25
+ * this generator documents the requirement rather than fabricating a
26
+ * variable nothing reads.
27
+ */
28
+
29
+ import { emitYAML } from "@intentius/chant/yaml";
30
+ import type {
31
+ ComponentPipelineOptions as GenerateGitlabOpOptions,
32
+ OpFindingMode,
33
+ OpPipelineJob,
34
+ OpPipelineResult as GenerateGitlabOpResult,
35
+ ScheduledOpSpec,
36
+ } from "@intentius/chant/lexicon";
37
+
38
+ export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
39
+
40
+ /** GitLab CI job names must be safe YAML keys; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
41
+ function toJobName(opName: string): string {
42
+ return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
43
+ }
44
+
45
+ const DEFAULT_IMAGE = "node:22-slim";
46
+ const STAGE = "scheduled-ops";
47
+
48
+ /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
49
+ const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
50
+
51
+ /** One setup line per Op in the generated file's header comment. */
52
+ function setupLine(spec: ScheduledOpSpec, jobName: string, mode: OpFindingMode): string {
53
+ const tokenNote = mode === "report" ? "" : " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)";
54
+ return `# ${jobName}: cron "${spec.schedule}", ${SELECTOR_VAR}="${spec.name}", finding-mode ${mode}${tokenNote}`;
55
+ }
56
+
57
+ /**
58
+ * Synthesize one `.gitlab-ci.yml` job per scheduled Op, all in a single file
59
+ * (GitLab has no per-file cron — see the module doc). Wired into core's Op
60
+ * generate mode via the gitlab lexicon plugin's `generateOpPipeline`
61
+ * (../plugin.ts).
62
+ */
63
+ export function generateGitlabOpPipeline(
64
+ ops: ScheduledOpSpec[],
65
+ options: GenerateGitlabOpOptions = {},
66
+ ): GenerateGitlabOpResult {
67
+ const image = options.image ?? DEFAULT_IMAGE;
68
+ const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
69
+ const beforeScript = options.beforeScript ?? [];
70
+ const extraScript = options.extraScript ?? [];
71
+
72
+ const jobs: OpPipelineJob[] = [];
73
+ const doc: Record<string, unknown> = { stages: [STAGE] };
74
+ if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
75
+
76
+ const headerLines = [
77
+ "# Scheduled Ops (chant #927) — GitLab has no in-file cron. Create one",
78
+ "# Pipeline Schedule per Op below (Settings > CI/CD > Schedules): set its",
79
+ `# cron to the value noted here and its ${SELECTOR_VAR} CI/CD variable to`,
80
+ "# the Op's name, so only that job runs on that schedule.",
81
+ "#",
82
+ ];
83
+
84
+ for (const spec of ops) {
85
+ const findingMode = spec.findingMode ?? "report";
86
+ const jobName = toJobName(spec.name);
87
+ jobs.push({ jobName, op: spec.name, schedule: spec.schedule, findingMode });
88
+ headerLines.push(setupLine(spec, jobName, findingMode));
89
+
90
+ const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
91
+ const script = [...beforeScript, runParts.join(" "), ...extraScript];
92
+
93
+ doc[jobName] = {
94
+ stage: STAGE,
95
+ image,
96
+ rules: [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }],
97
+ script,
98
+ };
99
+ }
100
+
101
+ const sections: string[] = [];
102
+ sections.push("stages:" + emitYAML(doc.stages, 1));
103
+ if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
104
+ for (const { jobName } of jobs) {
105
+ sections.push(`${jobName}:` + emitYAML(doc[jobName], 1));
106
+ }
107
+
108
+ const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
109
+
110
+ return { files: [{ name: "scheduled-ops.gitlab-ci.yml", yaml }], jobs };
111
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,14 @@ export { gitlabSerializer } from "./serializer";
4
4
  // Plugin
5
5
  export { gitlabPlugin } from "./plugin";
6
6
 
7
+ // Typed Op step-builder wrapper (chant #1288 Stage 2) — gitlabPipeline with
8
+ // authoring-time types derived from this lexicon's own GitlabPipelineArgs
9
+ // (see lexicons/k8s/src/op/builders.ts's module doc for why this lives here
10
+ // rather than in core or the temporal barrel). Opt-in:
11
+ // `@intentius/chant-lexicon-temporal`'s same-named export is core's original
12
+ // untyped builder, unchanged, for cloud-agnostic authoring.
13
+ export { gitlabPipeline } from "./op/builders";
14
+
7
15
  // Intrinsics
8
16
  export { reference, ReferenceIntrinsic } from "./intrinsics";
9
17
 
@@ -44,4 +44,9 @@ export const gitlabAuditCatalog: Record<string, RuleMeta> = {
44
44
  WGL046: auditRule("WGL046", "merge-worthy", "guidance", "Cache populated in a merge-request pipeline", "Don't populate caches from merge-request pipelines (poisoning risk).", { authority: [GH_PWN] }),
45
45
  WGL047: auditRule("WGL047", "merge-worthy", "guidance", "Software piped to a shell without verification", "Verify a checksum/signature before executing fetched scripts.", { authority: [SCORECARD_PINNED] }),
46
46
  WGL048: auditRule("WGL048", "report-only", "guidance", "Pipeline without workflow:name", "Add a `workflow:name` for clearer pipeline naming.", { category: "best-practice" }),
47
+
48
+ // Efficiency (#444) — waste, not a safety/correctness issue. Always
49
+ // report-only: none of these warrant a merge on their own.
50
+ WGL049: auditRule("WGL049", "report-only", "guidance", "Dependency install without a cache", "Add a `cache:` covering the dependency directory.", { category: "efficiency" }),
51
+ WGL050: auditRule("WGL050", "report-only", "guidance", "Merge-request job missing interruptible", "Add `interruptible: true` so a superseded pipeline can be cancelled.", { category: "efficiency" }),
47
52
  };
@@ -39,6 +39,8 @@ import { wgl045 } from "./wgl045";
39
39
  import { wgl046 } from "./wgl046";
40
40
  import { wgl047 } from "./wgl047";
41
41
  import { wgl048 } from "./wgl048";
42
+ import { wgl049 } from "./wgl049";
43
+ import { wgl050 } from "./wgl050";
42
44
 
43
45
  export const postSynthChecks: PostSynthCheck[] = [
44
46
  wgl010,
@@ -80,4 +82,6 @@ export const postSynthChecks: PostSynthCheck[] = [
80
82
  wgl046,
81
83
  wgl047,
82
84
  wgl048,
85
+ wgl049,
86
+ wgl050,
83
87
  ];
@@ -0,0 +1,71 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl049 } from "./wgl049";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["gitlab", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("WGL049: dependency install without a cache", () => {
14
+ test("flags a job that installs deps with no cache in scope", () => {
15
+ const yaml = `build:
16
+ stage: build
17
+ script:
18
+ - npm ci
19
+ - npm run build
20
+ `;
21
+ const diags = wgl049.check(makeCtx(yaml));
22
+ expect(diags).toHaveLength(1);
23
+ expect(diags[0].checkId).toBe("WGL049");
24
+ expect(diags[0].entity).toBe("build");
25
+ });
26
+
27
+ test("does not flag when the job has its own cache", () => {
28
+ const yaml = `build:
29
+ stage: build
30
+ cache:
31
+ key: npm-cache
32
+ paths:
33
+ - node_modules/
34
+ script:
35
+ - npm ci
36
+ `;
37
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
38
+ });
39
+
40
+ test("does not flag when a pipeline-wide cache: block exists", () => {
41
+ const yaml = `cache:
42
+ key: global
43
+ paths:
44
+ - node_modules/
45
+
46
+ build:
47
+ stage: build
48
+ script:
49
+ - npm ci
50
+ `;
51
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
52
+ });
53
+
54
+ test("does not flag a job that extends another config", () => {
55
+ const yaml = `build:
56
+ extends: .node-job
57
+ script:
58
+ - npm ci
59
+ `;
60
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
61
+ });
62
+
63
+ test("does not flag a job with no dependency install command", () => {
64
+ const yaml = `lint:
65
+ stage: test
66
+ script:
67
+ - eslint .
68
+ `;
69
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
70
+ });
71
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WGL049: Dependency Install Without a Cache
3
+ *
4
+ * Flags a job whose `script:` runs a package-manager install command (`npm
5
+ * ci`, `pip install`, `bundle install`, etc.) with no `cache:` in scope —
6
+ * neither on the job itself, nor at the pipeline's `default:`/top level.
7
+ * Every run re-fetches the same dependencies from a cold cache. A job that
8
+ * `extends:` another config is left alone — the cache may be inherited and
9
+ * this check has no way to confirm that. Efficiency (#444), not a
10
+ * correctness or security issue.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
15
+
16
+ const DEP_INSTALL_RE = /\b(npm (install|ci)|yarn install|pnpm install|pip install|pip3 install|bundle install|composer install|go mod download|mvn (install|dependency:resolve))\b/i;
17
+
18
+ function hasPipelineWideCache(yaml: string): boolean {
19
+ const sections = yaml.split("\n\n");
20
+ if (sections.some((s) => /^cache:/.test(s))) return true;
21
+ const defaultSection = sections.find((s) => /^default:/.test(s));
22
+ return !!defaultSection && /\n\s+cache:/.test(defaultSection);
23
+ }
24
+
25
+ export const wgl049: PostSynthCheck = {
26
+ id: "WGL049",
27
+ description: "Job installs dependencies with no cache: in scope",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const diagnostics: PostSynthDiagnostic[] = [];
31
+
32
+ for (const [, output] of ctx.outputs) {
33
+ const yaml = getPrimaryOutput(output);
34
+ if (hasPipelineWideCache(yaml)) continue;
35
+
36
+ for (const [jobName, job] of extractJobs(yaml)) {
37
+ if (jobName.startsWith(".")) continue; // hidden/template job, not run directly
38
+ if (job.extends && job.extends.length > 0) continue; // cache may be inherited; can't confirm
39
+
40
+ const section = extractJobSection(yaml, jobName);
41
+ if (!section) continue;
42
+ if (/^\s+cache:/m.test(section)) continue; // job-level cache present
43
+ if (!DEP_INSTALL_RE.test(section)) continue;
44
+
45
+ diagnostics.push({
46
+ checkId: "WGL049",
47
+ severity: "info",
48
+ message: `Job "${jobName}" installs dependencies with no cache: in scope — every run re-fetches from the registry. Add a cache: covering the dependency directory.`,
49
+ entity: jobName,
50
+ lexicon: "gitlab",
51
+ });
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl050 } from "./wgl050";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["gitlab", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("WGL050: merge-request job missing interruptible", () => {
14
+ test("flags a merge-request-reachable job with no interruptible: true", () => {
15
+ const yaml = `test:
16
+ rules:
17
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
18
+ script:
19
+ - npm test
20
+ `;
21
+ const diags = wgl050.check(makeCtx(yaml));
22
+ expect(diags).toHaveLength(1);
23
+ expect(diags[0].checkId).toBe("WGL050");
24
+ expect(diags[0].entity).toBe("test");
25
+ });
26
+
27
+ test("does not flag when interruptible: true is set", () => {
28
+ const yaml = `test:
29
+ rules:
30
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
31
+ interruptible: true
32
+ script:
33
+ - npm test
34
+ `;
35
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
36
+ });
37
+
38
+ test("does not flag a deploy job", () => {
39
+ const yaml = `deploy-app:
40
+ rules:
41
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
42
+ script:
43
+ - ./deploy.sh
44
+ `;
45
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
46
+ });
47
+
48
+ test("does not flag a job not reachable from merge requests", () => {
49
+ const yaml = `test:
50
+ rules:
51
+ - if: $CI_COMMIT_BRANCH == "main"
52
+ script:
53
+ - npm test
54
+ `;
55
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
56
+ });
57
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * WGL050: Merge-Request Job Missing interruptible
3
+ *
4
+ * Flags a job reachable from merge-request pipelines that doesn't set
5
+ * `interruptible: true`. Without it, GitLab's auto-cancel-redundant-pipelines
6
+ * setting can't cancel the job when a new push supersedes it, so the runner
7
+ * keeps spending capacity on a pipeline nobody wants anymore. Deploy jobs are
8
+ * left alone — cancelling mid-deploy is its own hazard, not an efficiency
9
+ * win. Efficiency (#444), not a correctness or security issue.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, isMergeRequestReachable } from "./yaml-helpers";
14
+
15
+ const RESERVED_TOP_LEVEL_KEYS = new Set(["stages", "default", "workflow", "variables", "include", "cache"]);
16
+
17
+ export const wgl050: PostSynthCheck = {
18
+ id: "WGL050",
19
+ description: "Merge-request-reachable job is not interruptible",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+
27
+ for (const section of yaml.split("\n\n")) {
28
+ const top = section.split("\n")[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
29
+ if (!top) continue;
30
+ const jobName = top[1];
31
+ if (jobName.startsWith(".") || RESERVED_TOP_LEVEL_KEYS.has(jobName)) continue;
32
+ if (/deploy/i.test(jobName)) continue; // cancelling mid-deploy is a hazard, not a win
33
+
34
+ if (!isMergeRequestReachable(section)) continue;
35
+ if (/^\s+interruptible:\s*true\s*$/m.test(section)) continue;
36
+
37
+ diagnostics.push({
38
+ checkId: "WGL050",
39
+ severity: "info",
40
+ message: `Job "${jobName}" runs on merge-request pipelines but is not interruptible: true — when a new commit supersedes this pipeline, this job keeps running instead of being cancelled. Add interruptible: true.`,
41
+ entity: jobName,
42
+ lexicon: "gitlab",
43
+ });
44
+ }
45
+ }
46
+
47
+ return diagnostics;
48
+ },
49
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed step-builder wrapper (chant #1288 Stage 2) — see
3
+ * `lexicons/k8s/src/op/builders.test.ts`'s module doc for what's asserted
4
+ * and why.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { gitlabPipeline as gitlabPipelineOld, stepOutput, type StepOutputRef } from "@intentius/chant/op";
9
+ import { gitlabPipeline } from "./builders";
10
+
11
+ describe("gitlab typed step builder (#1288 Stage 2)", () => {
12
+ test("gitlabPipeline: identical ActivityStep to core's original for a minimal call", () => {
13
+ expect(gitlabPipeline("alb-api")).toEqual(gitlabPipelineOld("alb-api"));
14
+ });
15
+
16
+ test("gitlabPipeline: identical ActivityStep with opts", () => {
17
+ const opts = { ref: "main", intervalMs: 15000, profile: "fastIdempotent" as const };
18
+ expect(gitlabPipeline("alb-api", opts)).toEqual(gitlabPipelineOld("alb-api", opts));
19
+ });
20
+
21
+ test("gitlabPipeline: accepts a StepOutputRef in a typed slot", () => {
22
+ const ref = stepOutput("resolve-ref", "sha");
23
+ const step = gitlabPipeline("alb-api", { ref });
24
+ expect(step.args?.ref).toBe(ref);
25
+ });
26
+
27
+ test("gitlabPipeline: .out is reachable when an id is given", () => {
28
+ const step = gitlabPipeline("alb-api", { id: "pipeline" });
29
+ const ref: StepOutputRef = step.out.name;
30
+ expect(ref.step).toBe("pipeline");
31
+ });
32
+ });
33
+
34
+ // ── Compile-time-only: authoring-time type errors (never executed) ──────────
35
+ function _typeChecksOnly(): void {
36
+ // @ts-expect-error — "project" is not a key of GitlabPipelineArgs (the
37
+ // field is `name`, and it's positional here besides).
38
+ gitlabPipeline("alb-api", { project: "group/other" });
39
+
40
+ // @ts-expect-error — intervalMs must be a number.
41
+ gitlabPipeline("alb-api", { intervalMs: "15000" });
42
+ }
43
+ void _typeChecksOnly;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Typed step-builder wrapper for this lexicon's `gitlabPipeline` activity
3
+ * (chant #1288 Stage 2 — "regenerate the step builders as fully typed
4
+ * wrappers"). Core cannot import a lexicon's `*Args` types (lexicons depend
5
+ * on core, not the other way around), so a fully typed `gitlabPipeline` has
6
+ * to live beside {@link GitlabPipelineArgs} — here, next to
7
+ * `./activities/gitlab.ts`. `opts`'s type below IS `GitlabPipelineArgs`
8
+ * itself (via `Omit`/`WithStepRefs`) — never a hand-restated mirror.
9
+ *
10
+ * `core`'s own `gitlabPipeline` (in `@intentius/chant/op`, re-exported from
11
+ * `@intentius/chant-lexicon-temporal`) is UNCHANGED and produces a
12
+ * byte-identical `ActivityStep` for the same inputs — purely additive, and
13
+ * deliberately not swapped into the temporal barrel (see
14
+ * `lexicons/k8s/src/op/builders.ts`'s module doc for why: that would make
15
+ * temporal depend on this package at runtime). An author who wants the typed
16
+ * surface imports it from here — `@intentius/chant-lexicon-gitlab`.
17
+ */
18
+
19
+ import {
20
+ activity,
21
+ takeProfileAndId,
22
+ type ActivityStep,
23
+ type NamedActivityStep,
24
+ type WithStepRefs,
25
+ } from "@intentius/chant/op";
26
+ import type { GitlabPipelineArgs } from "./activities/gitlab";
27
+
28
+ /**
29
+ * Trigger and wait for a GitLab CI pipeline to complete — the fully typed
30
+ * twin of core's `gitlabPipeline`. `opts` is {@link GitlabPipelineArgs}
31
+ * itself, minus the positional `name`. Defaults to the `longInfra` profile.
32
+ */
33
+ export const gitlabPipeline = (
34
+ name: string,
35
+ opts?: WithStepRefs<Omit<GitlabPipelineArgs, "name">> & { profile?: ActivityStep["profile"]; id?: string },
36
+ ): NamedActivityStep => {
37
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
38
+ return activity("gitlabPipeline", { name, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
39
+ };
@@ -87,7 +87,7 @@ describe("gitlabPlugin", () => {
87
87
 
88
88
  test("returns post-synth checks", () => {
89
89
  const checks = gitlabPlugin.postSynthChecks!();
90
- expect(checks).toHaveLength(39);
90
+ expect(checks).toHaveLength(41);
91
91
  const ids = checks.map((c) => c.id);
92
92
  expect(ids).toContain("WGL010");
93
93
  expect(ids).toContain("WGL011");
package/src/plugin.ts CHANGED
@@ -23,12 +23,15 @@ import { gitlabHover } from "./lsp/hover";
23
23
  import { GitLabParser } from "./import/parser";
24
24
  import { GitLabGenerator } from "./import/generator";
25
25
  import { generateGitlabPipeline } from "./components/generate-pipeline";
26
+ import { generateGitlabOpPipeline } from "./components/generate-op-pipeline";
26
27
 
27
28
  export const gitlabPlugin: LexiconPlugin = {
28
29
  name: "gitlab",
29
30
  auditCatalog: () => gitlabAuditCatalog,
30
31
  // Generate mode (#688): synthesize a .gitlab-ci.yml from the component graph.
31
32
  generateComponentPipeline: (components, options) => generateGitlabPipeline(components, options),
33
+ // Generate mode, Op counterpart (#927): scheduled Op → GitLab CI job.
34
+ generateOpPipeline: (ops, options) => generateGitlabOpPipeline(ops, options),
32
35
  // Self-upgrade: where the pinned GitLab schema version lives + its upstream (#685).
33
36
  upstreamPin: {
34
37
  file: "src/codegen/fetch.ts",