@tahminator/pipeline 1.0.90 → 1.0.91-beta.gabf5f4ea

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/README.md CHANGED
@@ -172,6 +172,7 @@ See the [action documentation](./actions/commands/README.md) for GitHub App perm
172
172
  ```ts
173
173
  import {
174
174
  DockerClient,
175
+ FluxClient,
175
176
  EnvClient,
176
177
  GitHubClient,
177
178
  NPMClient,
@@ -194,6 +195,7 @@ Jump to client documentation
194
195
 
195
196
  - [`GitHubClient`](#githubclient)
196
197
  - [`DockerClient`](#dockerclient)
198
+ - [`FluxClient`](#fluxclient)
197
199
  - [`ProtobufCompilerClient`](#protobufcompilerclient)
198
200
  - [Setup action versions](#setup-action-versions)
199
201
  - [`NPMClient`](#npmclient)
@@ -257,6 +259,14 @@ await client.sendPrMessage({
257
259
  message: "Deployed and healthy.",
258
260
  });
259
261
 
262
+ // uploads files as an artifact of the current workflow run; returns { id, url }
263
+ // requires the Actions runtime token, which the setup action exposes
264
+ await client.uploadArtifact({
265
+ name: "report",
266
+ files: ["/tmp/report/report.md"],
267
+ rootDirectory: "/tmp/report",
268
+ });
269
+
260
270
  // use VersioningClient to compute your next semver tag.
261
271
  // look for `VersioningClient` section for how to configure / use
262
272
  const versioning = new VersioningClient(client, VersionUpdatingStrategy.JSTS);
@@ -286,6 +296,39 @@ await client.outputToGithubOutput({
286
296
  });
287
297
  ```
288
298
 
299
+ ### `FluxClient`
300
+
301
+ Work with a repository of [Flux](https://fluxcd.io) manifests. Currently renders every Flux `Kustomization` the way kustomize-controller does and diffs the output between two commits, so a PR shows what would actually change in the cluster.
302
+
303
+ Requires `git`, `diff` and `kustomize` on `PATH` (`INSTALL_KUSTOMIZE: true` in the setup action).
304
+
305
+ ```ts
306
+ const gh = await GitHubClient.createWithGithubAppToken({ ... });
307
+
308
+ const flux = new FluxClient(gh, {
309
+ // optional; globs scanned for Flux Kustomizations. by default, it will check all yaml files
310
+ include: ["clusters/**/*.yaml"],
311
+ // optional; which sourceRefs are this repository. default: every GitRepository
312
+ isLocalSource: (ref) => ref.kind === "GitRepository" && ref.name === "flux-system",
313
+ });
314
+
315
+ // renders every Flux Kustomization at baseRef and in the working tree, then posts the
316
+ // changed ones as a new PR comment. If comment would exceed GitHub's comment size limit, the full
317
+ // comment is uploaded as a workflow run artifact and the PR comment links to it instead.
318
+ await flux.diff({
319
+ baseRef: process.env.BASE_SHA!,
320
+ owner: "tahminator",
321
+ repository: "infra",
322
+ prId: 123,
323
+ title: "Rendered manifest diff", // optional
324
+ description: "Secrets are shown SOPS-encrypted.", // optional
325
+ });
326
+ ```
327
+
328
+ Each Kustomization is rendered as `kustomize build` of `spec.path` with `targetNamespace`, `namePrefix`, `nameSuffix`, `components`, `images`, `patches` and `commonMetadata` applied on top, followed by `postBuild.substitute`. Paths without a `kustomization.yaml` get one generated, as Flux does. Values from `postBuild.substituteFrom` live in the cluster, so those variables are left as-is and listed in the diff's notes. A build failure is shown in the comment instead of being skipped. `HelmRelease` objects are diffed as manifests; charts are not templated.
329
+
330
+ The diff is split per Kubernetes object (matched by API group, kind, namespace and name), and each object is labelled with the file that defines it, taken from kustomize's `originAnnotations`. Generated ConfigMaps and Secrets point at the `kustomization.yaml` whose generator creates them, and are matched without their content-hash suffix, so editing a generator's data shows as a line diff of the same object rather than a removal and an addition. Files that only patch an object are not recorded, so a change made by a patch is attributed to the object's original file. Any other renamed object shows as one removal and one addition.
331
+
289
332
  ### `DockerClient`
290
333
 
291
334
  Interface with Docker to build & deploy images
@@ -0,0 +1,33 @@
1
+ import type { GitHubClient } from "../gh";
2
+ import { type FormatFluxDiffOpts } from "./format";
3
+ import { type FluxClientOpts, type FluxKustomization } from "./types";
4
+ /**
5
+ * Works with a repository of Flux manifests: finds its Flux `Kustomization`s, renders them the
6
+ * way kustomize-controller would, and reports how a change affects the rendered output.
7
+ *
8
+ * Requires `git`, `diff` and `kustomize` on `PATH`.
9
+ */
10
+ export declare class FluxClient {
11
+ private readonly ghClient;
12
+ private readonly include;
13
+ private readonly isLocalSource;
14
+ constructor(ghClient: GitHubClient, opts?: FluxClientOpts);
15
+ /** Returns every Flux `Kustomization` declared under `root`. */
16
+ findKustomizations(root?: string): Promise<FluxKustomization[]>;
17
+ /**
18
+ * Renders every Flux `Kustomization` at `baseRef` and in the working tree at `root`,
19
+ * and posts the ones whose output differs as a new PR comment.
20
+ *
21
+ * `baseRef` is fetched from `origin` if it is not available locally (e.g. shallow CI clones).
22
+ * If the comment would exceed GitHub's size limit, the full comment is uploaded as an
23
+ * artifact of the current workflow run and the PR comment links to it instead.
24
+ */
25
+ diff({ baseRef, root, owner, repository, prId, ...formatOpts }: {
26
+ baseRef: string;
27
+ root?: string;
28
+ owner: string;
29
+ repository: string;
30
+ prId: number;
31
+ } & Omit<FormatFluxDiffOpts, "artifactUrl">): Promise<void>;
32
+ private collectDiff;
33
+ }
@@ -0,0 +1,215 @@
1
+ import { $ } from "bun";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { findFluxKustomizations } from "./discover";
6
+ import { formatFluxDiff, label } from "./format";
7
+ import { objectKey, renderKustomization } from "./render";
8
+ import {} from "./types";
9
+ /** GitHub rejects comment bodies above 65536 characters. */
10
+ const MAX_COMMENT_LENGTH = 65_536;
11
+ const DEFAULT_INCLUDE = ["**/*.yaml", "**/*.yml"];
12
+ /**
13
+ * Works with a repository of Flux manifests: finds its Flux `Kustomization`s, renders them the
14
+ * way kustomize-controller would, and reports how a change affects the rendered output.
15
+ *
16
+ * Requires `git`, `diff` and `kustomize` on `PATH`.
17
+ */
18
+ export class FluxClient {
19
+ ghClient;
20
+ include;
21
+ isLocalSource;
22
+ constructor(ghClient, opts = {}) {
23
+ this.ghClient = ghClient;
24
+ this.include = opts.include ?? DEFAULT_INCLUDE;
25
+ this.isLocalSource =
26
+ opts.isLocalSource ?? ((sourceRef) => sourceRef.kind === "GitRepository");
27
+ }
28
+ /** Returns every Flux `Kustomization` declared under `root`. */
29
+ findKustomizations(root = ".") {
30
+ return findFluxKustomizations(root, this.include);
31
+ }
32
+ /**
33
+ * Renders every Flux `Kustomization` at `baseRef` and in the working tree at `root`,
34
+ * and posts the ones whose output differs as a new PR comment.
35
+ *
36
+ * `baseRef` is fetched from `origin` if it is not available locally (e.g. shallow CI clones).
37
+ * If the comment would exceed GitHub's size limit, the full comment is uploaded as an
38
+ * artifact of the current workflow run and the PR comment links to it instead.
39
+ */
40
+ async diff({ baseRef, root = ".", owner, repository, prId, ...formatOpts }) {
41
+ const result = await this.collectDiff(baseRef, root);
42
+ const full = formatFluxDiff(result, formatOpts);
43
+ if (full.length <= MAX_COMMENT_LENGTH) {
44
+ await this.ghClient.sendPrMessage({
45
+ owner,
46
+ repository,
47
+ prId,
48
+ message: full,
49
+ });
50
+ return;
51
+ }
52
+ const dir = await mkdtemp(path.join(tmpdir(), "flux-diff-artifact-"));
53
+ try {
54
+ const file = path.join(dir, "flux-diff.md");
55
+ await Bun.write(file, full);
56
+ const attempt = process.env.GITHUB_RUN_ATTEMPT ?? "1";
57
+ const { url } = await this.ghClient.uploadArtifact({
58
+ name: `flux-diff-pr-${prId}-attempt-${attempt}`,
59
+ files: [file],
60
+ rootDirectory: dir,
61
+ });
62
+ const message = formatFluxDiff(result, {
63
+ ...formatOpts,
64
+ artifactUrl: url,
65
+ });
66
+ await this.ghClient.sendPrMessage({ owner, repository, prId, message });
67
+ }
68
+ finally {
69
+ await rm(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+ async collectDiff(baseRef, root) {
73
+ const workdir = await mkdtemp(path.join(tmpdir(), "flux-diff-"));
74
+ const baseRoot = path.join(workdir, "base");
75
+ await $ `git -C ${root} fetch --depth=1 origin ${baseRef}`.quiet().nothrow();
76
+ await $ `git -C ${root} worktree add --detach ${baseRoot} ${baseRef}`.quiet();
77
+ try {
78
+ const [baseKustomizations, headKustomizations] = await Promise.all([
79
+ this.findKustomizations(baseRoot),
80
+ this.findKustomizations(root),
81
+ ]);
82
+ const pairs = new Map();
83
+ for (const kustomization of baseKustomizations) {
84
+ pairs.set(label(kustomization), { base: kustomization });
85
+ }
86
+ for (const kustomization of headKustomizations) {
87
+ const key = label(kustomization);
88
+ pairs.set(key, { ...pairs.get(key), head: kustomization });
89
+ }
90
+ const changed = [];
91
+ const skipped = [];
92
+ let nextDiffId = 0;
93
+ const nextDiffDir = () => path.join(workdir, "diffs", String(nextDiffId++));
94
+ await Promise.all([...pairs.values()].map(async ({ base, head }) => {
95
+ const current = (head ?? base);
96
+ const { name, namespace } = current;
97
+ const unrenderable = [base, head].find((kustomization) => kustomization &&
98
+ !this.isLocalSource(kustomization.spec.sourceRef));
99
+ if (unrenderable) {
100
+ const { kind, name: sourceName } = unrenderable.spec.sourceRef;
101
+ skipped.push({
102
+ name,
103
+ namespace,
104
+ reason: `source \`${kind}/${sourceName}\` is not this repository`,
105
+ });
106
+ return;
107
+ }
108
+ const [baseRender, headRender] = await Promise.all([
109
+ base ? renderKustomization(baseRoot, base) : undefined,
110
+ head ? renderKustomization(root, head) : undefined,
111
+ ]);
112
+ const buildError = headRender?.error;
113
+ const objects = buildError ?
114
+ []
115
+ : await diffObjects(baseRender?.objects ?? [], headRender?.objects ?? [], nextDiffDir);
116
+ const unchanged = buildError ?
117
+ buildError === baseRender?.error
118
+ : objects.length === 0;
119
+ if (unchanged) {
120
+ return;
121
+ }
122
+ const notes = new Set([
123
+ ...(baseRender?.notes ?? []),
124
+ ...(headRender?.notes ?? []),
125
+ ]);
126
+ if (baseRender?.error && !buildError) {
127
+ notes.add("`kustomize build` fails at the base commit, so every object is shown as added.");
128
+ }
129
+ changed.push({
130
+ name,
131
+ namespace,
132
+ path: current.path,
133
+ status: !base ? "added"
134
+ : !head ? "removed"
135
+ : "changed",
136
+ objects,
137
+ ...(buildError ? { buildError } : {}),
138
+ additions: objects.reduce((sum, o) => sum + o.additions, 0),
139
+ deletions: objects.reduce((sum, o) => sum + o.deletions, 0),
140
+ notes: [...notes],
141
+ });
142
+ }));
143
+ const byLabel = (a, b) => label(a).localeCompare(label(b));
144
+ return {
145
+ baseRef,
146
+ changed: changed.sort(byLabel),
147
+ skipped: skipped.sort(byLabel),
148
+ };
149
+ }
150
+ finally {
151
+ await $ `git -C ${root} worktree remove --force ${baseRoot}`
152
+ .quiet()
153
+ .nothrow();
154
+ await rm(workdir, { recursive: true, force: true });
155
+ }
156
+ }
157
+ }
158
+ /** Pairs base and head objects by {@link objectKey} and diffs the ones whose rendered YAML differs. */
159
+ async function diffObjects(baseObjects, headObjects, nextDir) {
160
+ const pairs = new Map();
161
+ for (const object of baseObjects) {
162
+ pairs.set(objectKey(object), { base: object });
163
+ }
164
+ for (const object of headObjects) {
165
+ const k = objectKey(object);
166
+ pairs.set(k, { ...pairs.get(k), head: object });
167
+ }
168
+ const diffs = await Promise.all([...pairs.values()].map(async ({ base, head }) => {
169
+ if (base?.yaml === head?.yaml) {
170
+ return null;
171
+ }
172
+ const current = (head ?? base);
173
+ const { kind, namespace, name, file } = current;
174
+ const diff = await unifiedDiff(nextDir(), [kind, namespace, name].filter(Boolean).join("/"), base?.yaml ?? "", head?.yaml ?? "");
175
+ const lines = diff.split("\n");
176
+ const objectDiff = {
177
+ kind,
178
+ namespace,
179
+ name,
180
+ ...(file ? { file } : {}),
181
+ status: !base ? "added"
182
+ : !head ? "removed"
183
+ : "changed",
184
+ diff,
185
+ additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
186
+ deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
187
+ };
188
+ return objectDiff;
189
+ }));
190
+ return diffs
191
+ .filter((diff) => diff !== null)
192
+ .sort((a, b) =>
193
+ // objects without a recorded file sort last
194
+ (a.file ?? "\uffff").localeCompare(b.file ?? "\uffff") ||
195
+ a.kind.localeCompare(b.kind) ||
196
+ a.namespace.localeCompare(b.namespace) ||
197
+ a.name.localeCompare(b.name));
198
+ }
199
+ async function unifiedDiff(dir, targetPath, baseOutput, headOutput) {
200
+ await mkdir(dir, { recursive: true });
201
+ const baseFile = path.join(dir, "base.yaml");
202
+ const headFile = path.join(dir, "head.yaml");
203
+ await Promise.all([
204
+ Bun.write(baseFile, baseOutput),
205
+ Bun.write(headFile, headOutput),
206
+ ]);
207
+ const result = await $ `diff -u --label ${`a/${targetPath}`} --label ${`b/${targetPath}`} ${baseFile} ${headFile}`
208
+ .quiet()
209
+ .nothrow();
210
+ // diff exits 1 when the files differ and 2 on error
211
+ if (result.exitCode > 1) {
212
+ throw new Error(`diff failed for ${targetPath}: ${result.stderr}`);
213
+ }
214
+ return result.stdout.toString();
215
+ }
@@ -0,0 +1,6 @@
1
+ import { type FluxKustomization } from "./types";
2
+ /**
3
+ * Returns every Flux `Kustomization` declared in files under `root` matching `include`,
4
+ * sorted by namespace then name. Hidden directories and `node_modules` are never scanned.
5
+ */
6
+ export declare function findFluxKustomizations(root: string, include: string[]): Promise<FluxKustomization[]>;
@@ -0,0 +1,51 @@
1
+ import { Glob } from "bun";
2
+ import path from "node:path";
3
+ import { parseAllDocuments } from "yaml";
4
+ import { fluxKustomizationSchema } from "./types";
5
+ const FLUX_API_GROUP = "kustomize.toolkit.fluxcd.io/";
6
+ /**
7
+ * Returns every Flux `Kustomization` declared in files under `root` matching `include`,
8
+ * sorted by namespace then name. Hidden directories and `node_modules` are never scanned.
9
+ */
10
+ export async function findFluxKustomizations(root, include) {
11
+ const files = new Set();
12
+ for (const pattern of include) {
13
+ for await (const file of new Glob(pattern).scan({ cwd: root })) {
14
+ if (!file.split(path.sep).includes("node_modules")) {
15
+ files.add(file);
16
+ }
17
+ }
18
+ }
19
+ const found = await Promise.all([...files].map(async (file) => {
20
+ const content = await Bun.file(path.join(root, file)).text();
21
+ // cheap pre-filter; most YAML in a manifest repo is not a Flux Kustomization
22
+ if (!content.includes(FLUX_API_GROUP)) {
23
+ return [];
24
+ }
25
+ return parseFile(file, content);
26
+ }));
27
+ return found
28
+ .flat()
29
+ .sort((a, b) => a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name));
30
+ }
31
+ function parseFile(file, content) {
32
+ const kustomizations = [];
33
+ for (const doc of parseAllDocuments(content)) {
34
+ if (doc.errors.length > 0) {
35
+ continue;
36
+ }
37
+ const parsed = fluxKustomizationSchema.safeParse(doc.toJS());
38
+ if (!parsed.success) {
39
+ continue;
40
+ }
41
+ const { metadata, spec } = parsed.data;
42
+ kustomizations.push({
43
+ file,
44
+ name: metadata.name,
45
+ namespace: metadata.namespace ?? "",
46
+ path: path.normalize(spec.path ?? "."),
47
+ spec,
48
+ });
49
+ }
50
+ return kustomizations;
51
+ }
@@ -0,0 +1,18 @@
1
+ import type { FluxDiffResult } from "./types";
2
+ export type FormatFluxDiffOpts = {
3
+ /** @default "Flux diff" */
4
+ title?: string;
5
+ /** extra markdown shown under the title, e.g. how secrets are displayed */
6
+ description?: string;
7
+ /**
8
+ * when set, diffs are replaced by a summary and a link here
9
+ * (used when the full comment would exceed GitHub's size limit)
10
+ */
11
+ artifactUrl?: string;
12
+ };
13
+ /** Formats a {@link FluxDiffResult} as a markdown PR comment. */
14
+ export declare function formatFluxDiff(result: FluxDiffResult, { title, description, artifactUrl }?: FormatFluxDiffOpts): string;
15
+ export declare function label({ name, namespace, }: {
16
+ name: string;
17
+ namespace: string;
18
+ }): string;
@@ -0,0 +1,50 @@
1
+ /** Formats a {@link FluxDiffResult} as a markdown PR comment. */
2
+ export function formatFluxDiff(result, { title = "Flux diff", description, artifactUrl } = {}) {
3
+ const base = `\`${result.baseRef.slice(0, 7)}\``;
4
+ const lines = [`### ${title}`, ""];
5
+ if (description) {
6
+ lines.push(description, "");
7
+ }
8
+ lines.push(result.changed.length === 0 ?
9
+ `No Flux Kustomization renders differently against ${base}.`
10
+ : `${result.changed.length} Flux Kustomization(s) render differently against ${base}.`, "");
11
+ if (artifactUrl) {
12
+ lines.push(`> [!WARNING]`, `> The rendered diff is too long for a GitHub comment. [Download the full diff from the workflow run](${artifactUrl}).`, "");
13
+ for (const change of result.changed) {
14
+ lines.push(`- ${summary(change)}`);
15
+ if (change.buildError) {
16
+ lines.push(" - `kustomize build` failed");
17
+ }
18
+ lines.push(...change.objects.map((object) => ` - ${objectSummary(object)}`));
19
+ }
20
+ lines.push("");
21
+ }
22
+ else {
23
+ for (const change of result.changed) {
24
+ lines.push(`<details><summary>${summary(change)}</summary>`, "", ...change.notes.map((note) => `> ${note}\n`));
25
+ if (change.buildError) {
26
+ lines.push("`kustomize build` failed:", "", "````text", change.buildError, "````", "");
27
+ }
28
+ for (const object of change.objects) {
29
+ lines.push(objectSummary(object), "", "````diff", object.diff.trimEnd(), "````", "");
30
+ }
31
+ lines.push("</details>", "");
32
+ }
33
+ }
34
+ if (result.skipped.length > 0) {
35
+ lines.push(`<details><summary>${result.skipped.length} Flux Kustomization(s) not rendered</summary>`, "", ...result.skipped.map((skipped) => `- <code>${label(skipped)}</code>: ${skipped.reason}`), "", "</details>", "");
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+ export function label({ name, namespace, }) {
40
+ return namespace ? `${namespace}/${name}` : name;
41
+ }
42
+ function summary(change) {
43
+ const counts = change.buildError ? "build failed" : (`${change.objects.length} object(s), +${change.additions} −${change.deletions}`);
44
+ return `<code>${label(change)}</code> — <code>${change.path}</code> (${change.status}, ${counts})`;
45
+ }
46
+ function objectSummary(object) {
47
+ const name = [object.namespace, object.name].filter(Boolean).join("/");
48
+ const file = object.file ? ` — \`${object.file}\`` : "";
49
+ return `**\`${object.kind}\` \`${name}\`**${file} (${object.status}, +${object.additions} −${object.deletions})`;
50
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./client";
2
+ export type { FluxClientOpts, FluxKustomization, FluxSourceRef } from "./types";
@@ -0,0 +1 @@
1
+ export * from "./client";
@@ -0,0 +1,52 @@
1
+ import type { FluxKustomization } from "./types";
2
+ export type RenderedObject = {
3
+ apiVersion: string;
4
+ kind: string;
5
+ namespace: string;
6
+ name: string;
7
+ /**
8
+ * repository-relative file that defines the object (for generated objects, the
9
+ * kustomization.yaml whose generator creates it); `undefined` if kustomize did not record one
10
+ */
11
+ file?: string;
12
+ /** created by a ConfigMap/Secret generator, so `name` may end in a content hash */
13
+ generated: boolean;
14
+ /** the object's rendered YAML, without the origin annotation */
15
+ yaml: string;
16
+ };
17
+ /**
18
+ * Identifies the same object across two renders: API group, kind, namespace and name.
19
+ * An apiVersion bump within a group keeps the key; generated objects drop the content hash
20
+ * from their name so an edited ConfigMap/Secret pairs with its previous version.
21
+ */
22
+ export declare function objectKey(object: RenderedObject): string;
23
+ type RenderedKustomization = {
24
+ /** rendered objects, in kustomize's output order; empty when `error` is set */
25
+ objects: RenderedObject[];
26
+ /** `kustomize build` error output, when the build failed */
27
+ error?: string;
28
+ notes: string[];
29
+ };
30
+ /**
31
+ * Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
32
+ * `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
33
+ * `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
34
+ * followed by `postBuild` variable substitution.
35
+ *
36
+ * Each rendered object records the file that defines it.
37
+ */
38
+ export declare function renderKustomization(root: string, kustomization: FluxKustomization): Promise<RenderedKustomization>;
39
+ /**
40
+ * Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
41
+ * with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
42
+ *
43
+ * Variables without a value and without a default become empty strings, as in Flux, unless
44
+ * `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
45
+ */
46
+ export declare function substituteVariables(input: string, vars: Record<string, string>, { keepUnresolved }: {
47
+ keepUnresolved: boolean;
48
+ }): {
49
+ output: string;
50
+ unresolved: string[];
51
+ };
52
+ export {};
@@ -0,0 +1,203 @@
1
+ import { $ } from "bun";
2
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import yaml from "yaml";
5
+ const KUSTOMIZATION_FILES = [
6
+ "kustomization.yaml",
7
+ "kustomization.yml",
8
+ "Kustomization",
9
+ ];
10
+ const SUBSTITUTE_DISABLED = /kustomize\.toolkit\.fluxcd\.io\/substitute:\s*["']?disabled["']?/;
11
+ /** `${VAR}`, `${VAR:-default}`, `${VAR-default}`, `${VAR:=default}`, `${VAR=default}`; `$${VAR}` escapes. */
12
+ const VARIABLE = /\$(\$)?\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-=])([^}]*))?\}/g;
13
+ /** kustomize's `originAnnotations` build metadata; records the file that defines each object. */
14
+ const ORIGIN_ANNOTATION = "config.kubernetes.io/origin";
15
+ const GENERATORS = ["ConfigMapGenerator", "SecretGenerator"];
16
+ /** kustomize's name hash: 10 characters from its vowel-free alphabet, appended last. */
17
+ const NAME_HASH = /-[2456789bcdfghkmt]{10}$/;
18
+ /**
19
+ * Identifies the same object across two renders: API group, kind, namespace and name.
20
+ * An apiVersion bump within a group keeps the key; generated objects drop the content hash
21
+ * from their name so an edited ConfigMap/Secret pairs with its previous version.
22
+ */
23
+ export function objectKey(object) {
24
+ const group = object.apiVersion.includes("/") ? object.apiVersion.split("/")[0] : "";
25
+ const name = object.generated ? object.name.replace(NAME_HASH, "") : object.name;
26
+ return [group, object.kind, object.namespace, name].join("\0");
27
+ }
28
+ /**
29
+ * Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
30
+ * `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
31
+ * `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
32
+ * followed by `postBuild` variable substitution.
33
+ *
34
+ * Each rendered object records the file that defines it.
35
+ */
36
+ export async function renderKustomization(root, kustomization) {
37
+ const notes = [];
38
+ const { spec } = kustomization;
39
+ const targetDir = path.join(root, kustomization.path);
40
+ // kustomize rejects absolute resource paths, so the wrapper must live inside `root`
41
+ const wrapper = await mkdtemp(path.join(root, ".flux-render-"));
42
+ try {
43
+ const relative = (to) => path.relative(wrapper, to);
44
+ let resources = [relative(targetDir)];
45
+ if (!(await hasKustomizationFile(targetDir))) {
46
+ resources = (await generateResources(targetDir)).map(relative);
47
+ notes.push(`\`${kustomization.path}\` has no kustomization.yaml; resources were generated the way Flux does.`);
48
+ }
49
+ const wrapperKustomization = {
50
+ apiVersion: "kustomize.config.k8s.io/v1beta1",
51
+ kind: "Kustomization",
52
+ namespace: spec.targetNamespace,
53
+ namePrefix: spec.namePrefix,
54
+ nameSuffix: spec.nameSuffix,
55
+ resources,
56
+ components: spec.components?.map((component) => relative(path.join(targetDir, component))),
57
+ images: spec.images,
58
+ patches: spec.patches,
59
+ labels: spec.commonMetadata?.labels ?
60
+ [{ pairs: spec.commonMetadata.labels, includeSelectors: false }]
61
+ : undefined,
62
+ commonAnnotations: spec.commonMetadata?.annotations,
63
+ buildMetadata: ["originAnnotations"],
64
+ };
65
+ await Bun.write(path.join(wrapper, "kustomization.yaml"), yaml.stringify(wrapperKustomization));
66
+ const result = await $ `kustomize build --load-restrictor LoadRestrictionsNone ${wrapper}`
67
+ .quiet()
68
+ .nothrow();
69
+ if (result.exitCode !== 0) {
70
+ return { objects: [], error: result.stderr.toString().trim(), notes };
71
+ }
72
+ let output = result.stdout.toString();
73
+ if (spec.postBuild) {
74
+ const hasExternalValues = (spec.postBuild.substituteFrom ?? []).length > 0;
75
+ const substituted = substituteVariables(output, spec.postBuild.substitute ?? {}, { keepUnresolved: hasExternalValues });
76
+ output = substituted.output;
77
+ if (substituted.unresolved.length > 0) {
78
+ notes.push(`\`postBuild.substituteFrom\` values live in the cluster and were left unresolved: ${substituted.unresolved
79
+ .map((name) => `\`\${${name}}\``)
80
+ .join(", ")}.`);
81
+ }
82
+ }
83
+ const docs = yaml.parseAllDocuments(output);
84
+ const invalid = docs.find((doc) => doc.errors.length > 0);
85
+ if (invalid) {
86
+ return {
87
+ objects: [],
88
+ error: `rendered output is not valid YAML: ${invalid.errors[0]?.message}`,
89
+ notes,
90
+ };
91
+ }
92
+ const objects = docs
93
+ .filter((doc) => doc.contents !== null)
94
+ .map((doc) => toRenderedObject(doc, root, wrapper));
95
+ return { objects, notes };
96
+ }
97
+ finally {
98
+ await rm(wrapper, { recursive: true, force: true });
99
+ }
100
+ }
101
+ function toRenderedObject(doc, root, wrapper) {
102
+ const raw = doc.getIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
103
+ doc.deleteIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
104
+ const annotations = doc.getIn(["metadata", "annotations"]);
105
+ if (yaml.isMap(annotations) && annotations.items.length === 0) {
106
+ doc.deleteIn(["metadata", "annotations"]);
107
+ }
108
+ const origin = typeof raw === "string" ? yaml.parse(raw) : null;
109
+ const value = (doc.toJS() ?? {});
110
+ return {
111
+ apiVersion: String(value.apiVersion ?? ""),
112
+ kind: String(value.kind ?? ""),
113
+ namespace: String(value.metadata?.namespace ?? ""),
114
+ name: String(value.metadata?.name ?? ""),
115
+ file: origin ? originFile(origin, root, wrapper) : undefined,
116
+ generated: GENERATORS.includes(origin?.configuredBy?.kind ?? ""),
117
+ yaml: doc.toString({ lineWidth: 0 }).replace(/^---\n/, ""),
118
+ };
119
+ }
120
+ /**
121
+ * Converts an origin annotation (`path`, or `configuredIn` for generated objects, relative to
122
+ * the wrapper kustomization) to a repository-relative path. Remote origins are returned as
123
+ * `<repo>//<path>?ref=<ref>`.
124
+ */
125
+ function originFile(origin, root, wrapper) {
126
+ const file = origin.path ?? origin.configuredIn;
127
+ if (origin.repo) {
128
+ return `${origin.repo}//${file ?? ""}${origin.ref ? `?ref=${origin.ref}` : ""}`;
129
+ }
130
+ return file ? path.relative(root, path.resolve(wrapper, file)) : undefined;
131
+ }
132
+ /**
133
+ * Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
134
+ * with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
135
+ *
136
+ * Variables without a value and without a default become empty strings, as in Flux, unless
137
+ * `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
138
+ */
139
+ export function substituteVariables(input, vars, { keepUnresolved }) {
140
+ const unresolved = new Set();
141
+ const output = input
142
+ .split(/^(?=---$)/m)
143
+ .map((doc) => {
144
+ if (SUBSTITUTE_DISABLED.test(doc)) {
145
+ return doc;
146
+ }
147
+ return doc.replace(VARIABLE, (match, escape, name, operator, fallback) => {
148
+ if (escape) {
149
+ return match.slice(1);
150
+ }
151
+ const value = vars[name];
152
+ if (operator !== undefined) {
153
+ const useValue = value !== undefined &&
154
+ (!operator.startsWith(":") || value !== "");
155
+ return useValue ? value : (fallback ?? "");
156
+ }
157
+ if (value !== undefined) {
158
+ return value;
159
+ }
160
+ if (keepUnresolved) {
161
+ unresolved.add(name);
162
+ return match;
163
+ }
164
+ return "";
165
+ });
166
+ })
167
+ .join("");
168
+ return { output, unresolved: [...unresolved].sort() };
169
+ }
170
+ async function hasKustomizationFile(dir) {
171
+ const entries = await readdir(dir).catch(() => []);
172
+ return entries.some((entry) => KUSTOMIZATION_FILES.includes(entry));
173
+ }
174
+ /**
175
+ * Mirrors Flux's generated kustomization for a path without one: every Kubernetes manifest
176
+ * under `dir`, with subdirectories that have their own kustomization included as a whole.
177
+ */
178
+ async function generateResources(dir) {
179
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
180
+ const resources = [];
181
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
182
+ const full = path.join(dir, entry.name);
183
+ if (entry.isDirectory()) {
184
+ if (await hasKustomizationFile(full)) {
185
+ resources.push(full);
186
+ }
187
+ else {
188
+ resources.push(...(await generateResources(full)));
189
+ }
190
+ }
191
+ else if (/\.ya?ml$/.test(entry.name) && (await isManifest(full))) {
192
+ resources.push(full);
193
+ }
194
+ }
195
+ return resources;
196
+ }
197
+ async function isManifest(file) {
198
+ const docs = yaml.parseAllDocuments(await Bun.file(file).text());
199
+ return docs.some((doc) => {
200
+ const value = doc.errors.length === 0 ? doc.toJS() : undefined;
201
+ return (typeof value?.apiVersion === "string" && typeof value?.kind === "string");
202
+ });
203
+ }
@@ -0,0 +1,117 @@
1
+ import z from "zod";
2
+ /**
3
+ * The subset of a Flux `Kustomization` (`kustomize.toolkit.fluxcd.io`) that affects rendered output.
4
+ */
5
+ export declare const fluxKustomizationSchema: z.ZodObject<{
6
+ apiVersion: z.ZodString;
7
+ kind: z.ZodLiteral<"Kustomization">;
8
+ metadata: z.ZodObject<{
9
+ name: z.ZodString;
10
+ namespace: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strip>;
12
+ spec: z.ZodObject<{
13
+ path: z.ZodOptional<z.ZodString>;
14
+ sourceRef: z.ZodObject<{
15
+ kind: z.ZodString;
16
+ name: z.ZodString;
17
+ namespace: z.ZodOptional<z.ZodString>;
18
+ }, z.core.$strip>;
19
+ targetNamespace: z.ZodOptional<z.ZodString>;
20
+ namePrefix: z.ZodOptional<z.ZodString>;
21
+ nameSuffix: z.ZodOptional<z.ZodString>;
22
+ components: z.ZodOptional<z.ZodArray<z.ZodString>>;
23
+ images: z.ZodOptional<z.ZodArray<z.ZodObject<{
24
+ name: z.ZodString;
25
+ newName: z.ZodOptional<z.ZodString>;
26
+ newTag: z.ZodOptional<z.ZodString>;
27
+ digest: z.ZodOptional<z.ZodString>;
28
+ }, z.core.$strip>>>;
29
+ patches: z.ZodOptional<z.ZodArray<z.ZodObject<{
30
+ patch: z.ZodString;
31
+ target: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
32
+ }, z.core.$strip>>>;
33
+ commonMetadata: z.ZodOptional<z.ZodObject<{
34
+ labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
35
+ annotations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
36
+ }, z.core.$strip>>;
37
+ postBuild: z.ZodOptional<z.ZodObject<{
38
+ substitute: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
39
+ substituteFrom: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
40
+ }, z.core.$strip>>;
41
+ }, z.core.$strip>;
42
+ }, z.core.$strip>;
43
+ type FluxKustomizationSpec = z.infer<typeof fluxKustomizationSchema>["spec"];
44
+ /** A Flux `Kustomization` found in the repository. */
45
+ export type FluxKustomization = {
46
+ /** repository-relative file the Kustomization is declared in */
47
+ file: string;
48
+ name: string;
49
+ /** `metadata.namespace`, or `""` when the manifest leaves it to an enclosing kustomization */
50
+ namespace: string;
51
+ /** normalized, repository-relative `spec.path` */
52
+ path: string;
53
+ spec: FluxKustomizationSpec;
54
+ };
55
+ export type FluxSourceRef = FluxKustomizationSpec["sourceRef"];
56
+ type FluxDiffStatus = "added" | "changed" | "removed";
57
+ /** A rendered Kubernetes object whose output differs between base and head. */
58
+ export type FluxObjectDiff = {
59
+ kind: string;
60
+ /** `""` for cluster-scoped objects */
61
+ namespace: string;
62
+ name: string;
63
+ /**
64
+ * repository-relative file that defines the object, from kustomize's origin annotations.
65
+ * Generated objects (ConfigMap/Secret generators) point at the kustomization.yaml that
66
+ * generates them. Files that only patch the object are not recorded.
67
+ */
68
+ file?: string;
69
+ status: FluxDiffStatus;
70
+ /** unified diff of the object's rendered YAML */
71
+ diff: string;
72
+ additions: number;
73
+ deletions: number;
74
+ };
75
+ export type FluxKustomizationDiff = {
76
+ name: string;
77
+ namespace: string;
78
+ path: string;
79
+ status: FluxDiffStatus;
80
+ /** changed objects, sorted by file then kind, namespace and name */
81
+ objects: FluxObjectDiff[];
82
+ /** `kustomize build` error at head; `objects` is empty when set */
83
+ buildError?: string;
84
+ /** totals across `objects` */
85
+ additions: number;
86
+ deletions: number;
87
+ /** caveats about how faithful the render is, e.g. unresolvable `substituteFrom` variables */
88
+ notes: string[];
89
+ };
90
+ export type FluxSkippedKustomization = {
91
+ name: string;
92
+ namespace: string;
93
+ reason: string;
94
+ };
95
+ export type FluxDiffResult = {
96
+ baseRef: string;
97
+ /** Kustomizations whose rendered output differs, sorted by name */
98
+ changed: FluxKustomizationDiff[];
99
+ /** Kustomizations that could not be rendered locally */
100
+ skipped: FluxSkippedKustomization[];
101
+ };
102
+ export type FluxClientOpts = {
103
+ /**
104
+ * Globs (relative to the repository root) scanned for Flux Kustomizations.
105
+ *
106
+ * @default ["**\/*.yaml", "**\/*.yml"]
107
+ */
108
+ include?: string[];
109
+ /**
110
+ * Decides whether a Kustomization's `sourceRef` points at the repository being diffed.
111
+ * Kustomizations with any other source are reported as skipped.
112
+ *
113
+ * @default every `GitRepository` source
114
+ */
115
+ isLocalSource?: (sourceRef: FluxSourceRef) => boolean;
116
+ };
117
+ export {};
@@ -0,0 +1,50 @@
1
+ import z from "zod";
2
+ /**
3
+ * The subset of a Flux `Kustomization` (`kustomize.toolkit.fluxcd.io`) that affects rendered output.
4
+ */
5
+ export const fluxKustomizationSchema = z.object({
6
+ apiVersion: z.string().startsWith("kustomize.toolkit.fluxcd.io/"),
7
+ kind: z.literal("Kustomization"),
8
+ metadata: z.object({
9
+ name: z.string(),
10
+ namespace: z.string().optional(),
11
+ }),
12
+ spec: z.object({
13
+ path: z.string().optional(),
14
+ sourceRef: z.object({
15
+ kind: z.string(),
16
+ name: z.string(),
17
+ namespace: z.string().optional(),
18
+ }),
19
+ targetNamespace: z.string().optional(),
20
+ namePrefix: z.string().optional(),
21
+ nameSuffix: z.string().optional(),
22
+ components: z.array(z.string()).optional(),
23
+ images: z
24
+ .array(z.object({
25
+ name: z.string(),
26
+ newName: z.string().optional(),
27
+ newTag: z.string().optional(),
28
+ digest: z.string().optional(),
29
+ }))
30
+ .optional(),
31
+ patches: z
32
+ .array(z.object({
33
+ patch: z.string(),
34
+ target: z.record(z.string(), z.unknown()).optional(),
35
+ }))
36
+ .optional(),
37
+ commonMetadata: z
38
+ .object({
39
+ labels: z.record(z.string(), z.string()).optional(),
40
+ annotations: z.record(z.string(), z.string()).optional(),
41
+ })
42
+ .optional(),
43
+ postBuild: z
44
+ .object({
45
+ substitute: z.record(z.string(), z.string()).optional(),
46
+ substituteFrom: z.array(z.unknown()).optional(),
47
+ })
48
+ .optional(),
49
+ }),
50
+ });
@@ -0,0 +1,28 @@
1
+ export declare class GitHubArtifactManager {
2
+ private readonly client;
3
+ constructor();
4
+ /**
5
+ * Uploads `files` as an artifact of the current workflow run and returns a link to it.
6
+ *
7
+ * @note must run inside a GitHub Actions job with the runtime token exposed
8
+ * (the `setup` action automatically exposes it).
9
+ */
10
+ uploadArtifact({ name, files, rootDirectory, retentionDays, }: {
11
+ /**
12
+ * must be unique within the workflow run
13
+ */
14
+ name: string;
15
+ /**
16
+ * absolute paths of the files to upload
17
+ */
18
+ files: string[];
19
+ /**
20
+ * paths inside the artifact are relative to this directory
21
+ */
22
+ rootDirectory: string;
23
+ retentionDays?: number;
24
+ }): Promise<{
25
+ id: number;
26
+ url: string;
27
+ }>;
28
+ }
@@ -0,0 +1,29 @@
1
+ import { DefaultArtifactClient } from "@actions/artifact";
2
+ export class GitHubArtifactManager {
3
+ client;
4
+ constructor() {
5
+ this.client = new DefaultArtifactClient();
6
+ }
7
+ /**
8
+ * Uploads `files` as an artifact of the current workflow run and returns a link to it.
9
+ *
10
+ * @note must run inside a GitHub Actions job with the runtime token exposed
11
+ * (the `setup` action automatically exposes it).
12
+ */
13
+ async uploadArtifact({ name, files, rootDirectory, retentionDays, }) {
14
+ const serverUrl = process.env.GITHUB_SERVER_URL;
15
+ const repository = process.env.GITHUB_REPOSITORY;
16
+ const runId = process.env.GITHUB_RUN_ID;
17
+ if (!serverUrl || !repository || !runId) {
18
+ throw new Error("uploadArtifact must run inside a GitHub Actions job (missing GITHUB_SERVER_URL, GITHUB_REPOSITORY or GITHUB_RUN_ID)");
19
+ }
20
+ const { id } = await this.client.uploadArtifact(name, files, rootDirectory, { retentionDays });
21
+ if (id === undefined) {
22
+ throw new Error(`Artifact ${name} was uploaded but GitHub returned no id`);
23
+ }
24
+ return {
25
+ id,
26
+ url: `${serverUrl}/${repository}/actions/runs/${runId}/artifacts/${id}`,
27
+ };
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { GitHubArtifactManager } from "./artifact";
1
2
  import { GitHubOutputManager } from "./output";
2
3
  import { GitHubPRManager } from "./pr";
3
4
  import { GitHubTagManager } from "./tag";
@@ -6,6 +7,7 @@ export declare class GitHubClient {
6
7
  private readonly client;
7
8
  private readonly isExplicitToken;
8
9
  private readonly tagManager;
10
+ private readonly artifactManager;
9
11
  private readonly outputManager;
10
12
  private readonly prManager;
11
13
  private readonly teamManager;
@@ -82,6 +84,15 @@ export declare class GitHubClient {
82
84
  */
83
85
  updateK8sTagWithPR(...args: Parameters<GitHubPRManager["updateK8sTagWithPR"]>): Promise<void>;
84
86
  sendPrMessage(...args: Parameters<GitHubPRManager["sendPrMessage"]>): Promise<void>;
87
+ /**
88
+ * Uploads files as an artifact of the current workflow run and returns its download URL.
89
+ *
90
+ * @note requires the Actions runtime token, which the `setup` action exposes.
91
+ */
92
+ uploadArtifact(...args: Parameters<GitHubArtifactManager["uploadArtifact"]>): Promise<{
93
+ id: number;
94
+ url: string;
95
+ }>;
85
96
  /**
86
97
  * @note if the PR doesn't exist yet, just use `updateK8sTagWithPR` instead, since it
87
98
  * creates the PR with the necessary changes and merges it for you in one call.
package/dist/gh/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createAppAuth } from "@octokit/auth-app";
2
2
  import { Octokit } from "@octokit/rest";
3
+ import { GitHubArtifactManager } from "./artifact";
3
4
  import { GitHubOutputManager } from "./output";
4
5
  import { GitHubPRManager } from "./pr";
5
6
  import { GitHubTagManager } from "./tag";
@@ -8,6 +9,7 @@ export class GitHubClient {
8
9
  client;
9
10
  isExplicitToken;
10
11
  tagManager;
12
+ artifactManager;
11
13
  outputManager;
12
14
  prManager;
13
15
  teamManager;
@@ -15,6 +17,7 @@ export class GitHubClient {
15
17
  this.client = client;
16
18
  this.isExplicitToken = isExplicitToken;
17
19
  this.tagManager = new GitHubTagManager(this.client, this.isExplicitToken);
20
+ this.artifactManager = new GitHubArtifactManager();
18
21
  this.outputManager = new GitHubOutputManager();
19
22
  this.prManager = new GitHubPRManager(this.client);
20
23
  this.teamManager = new GitHubTeamManager(this.client);
@@ -112,6 +115,14 @@ export class GitHubClient {
112
115
  sendPrMessage(...args) {
113
116
  return this.prManager.sendPrMessage(...args);
114
117
  }
118
+ /**
119
+ * Uploads files as an artifact of the current workflow run and returns its download URL.
120
+ *
121
+ * @note requires the Actions runtime token, which the `setup` action exposes.
122
+ */
123
+ uploadArtifact(...args) {
124
+ return this.artifactManager.uploadArtifact(...args);
125
+ }
115
126
  /**
116
127
  * @note if the PR doesn't exist yet, just use `updateK8sTagWithPR` instead, since it
117
128
  * creates the PR with the necessary changes and merges it for you in one call.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./gh";
2
+ export * from "./flux";
2
3
  export * from "./docker";
3
4
  export * from "./npm";
4
5
  export * from "./sonar";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./gh";
2
+ export * from "./flux";
2
3
  export * from "./docker";
3
4
  export * from "./npm";
4
5
  export * from "./sonar";
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "type": "module",
4
4
  "author": "Tahmid Ahmed",
5
5
  "description": "A collection of Bun shell scripts that can be re-used in various CICD pipelines.",
6
- "version": "1.0.90",
6
+ "version": "1.0.91-beta.gabf5f4ea",
7
7
  "repository": {
8
8
  "url": "git+https://github.com/tahminator/pipeline.git"
9
9
  },
@@ -47,6 +47,7 @@
47
47
  "typescript": "^5"
48
48
  },
49
49
  "dependencies": {
50
+ "@actions/artifact": "^6.2.1",
50
51
  "@octokit/auth-app": "^8.2.0",
51
52
  "@octokit/rest": "^22.0.1",
52
53
  "@pulumi/pulumi": "^3.258.0",