@tahminator/pipeline 1.0.89 → 1.0.90-beta.g1f4d73ad

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
@@ -30,11 +30,149 @@ bun run src/index.ts
30
30
  > [!WARNING]
31
31
  > This repository is iterating quickly & as such may have rough edges. I will always be happy to respond to & fix any issues anyone may have :)
32
32
 
33
+ ## Slash commands action
34
+
35
+ Use [`actions/commands`](./actions/commands/README.md) to run your repository's workflows from PR comments such as `/merge` or `/ai`.
36
+
37
+ Before using it:
38
+
39
+ - Add the GitHub App ID and private key as repository secrets.
40
+ - Include `yargs` and your loader's dependencies in `.github/scripts/package.json`.
41
+ - Replace `<published-commit-sha>` with the reviewed pipeline commit you want to use.
42
+
43
+ Choose one command source: a hardcoded list or a Bun script.
44
+
45
+ | | Hardcoded | Dynamic |
46
+ | ------------ | -------------------------------------- | ------------------------------------------------ |
47
+ | Input | `COMMANDS` | `DYNAMIC_COMMANDS_BUN_SCRIPT` |
48
+ | Command list | Written directly in the workflow. | Printed to stdout by a Bun script. |
49
+ | Source | Default-branch workflow configuration. | Script from the authorized PR head. |
50
+ | Best for | A fixed list of commands. | Computing the list from repository code or data. |
51
+
52
+ Both use the same permission checks and dispatch to repository-owned command workflows.
53
+
54
+ ### Examples
55
+
56
+ Create these two files to add a `/hello` command that replies to the PR.
57
+
58
+ **`.github/workflows/slash.yml`**
59
+
60
+ ```yaml
61
+ name: Slash Command Dispatch
62
+
63
+ on:
64
+ issue_comment:
65
+ types: [created]
66
+
67
+ permissions:
68
+ contents: read
69
+
70
+ jobs:
71
+ dispatch:
72
+ if: >-
73
+ github.event.issue.pull_request &&
74
+ startsWith(github.event.comment.body, '/')
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
78
+ with:
79
+ # please set these to keep this workflow secure
80
+ ref: ${{ github.event.repository.default_branch }}
81
+ persist-credentials: false
82
+
83
+ - uses: tahminator/pipeline/actions/setup@<published-commit-sha>
84
+ with:
85
+ LINT_CI: false
86
+
87
+ - uses: tahminator/pipeline/actions/commands@<published-commit-sha>
88
+ with:
89
+ APP_ID: ${{ secrets._GITHUB_APP_APP_ID }}
90
+ PRIVATE_KEY: ${{ secrets._GITHUB_APP_PEM_CONTENT }}
91
+ COMMANDS: |
92
+ hello
93
+ merge
94
+ ```
95
+
96
+ **`.github/workflows/hello-command.yml`**
97
+
98
+ ```yaml
99
+ name: Hello command
100
+
101
+ on:
102
+ workflow_dispatch:
103
+ inputs:
104
+ repository:
105
+ description: Repository where the command was posted
106
+ required: true
107
+ type: string
108
+ comment-id:
109
+ description: ID of the slash-command comment
110
+ required: true
111
+ type: string
112
+ prId:
113
+ description: Pull request number
114
+ required: true
115
+ type: string
116
+ author:
117
+ description: Username that posted the command
118
+ required: true
119
+ type: string
120
+
121
+ permissions:
122
+ pull-requests: write
123
+
124
+ jobs:
125
+ hello:
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - name: Reply to the PR
129
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
130
+ with:
131
+ script: |
132
+ const { prId, author } = context.payload.inputs;
133
+ await github.rest.issues.createComment({
134
+ ...context.repo,
135
+ issue_number: Number(prId),
136
+ body: `Hello @${author}! This command ran from \`${context.ref}\`.`,
137
+ });
138
+ ```
139
+
140
+ Once both files are on the default branch, a user with write access can comment `/hello` on an open, same-repository PR. The dispatcher runs `hello-command.yml` from the PR branch, and the handler replies with that branch's name. Replace the reply step with your command's work.
141
+
142
+ ### Commands from a Bun script
143
+
144
+ Keep the same trigger, trusted checkout, and setup steps. Replace the final action step with:
145
+
146
+ ```yaml
147
+ - uses: tahminator/pipeline/actions/commands@<published-commit-sha>
148
+ with:
149
+ APP_ID: ${{ secrets._GITHUB_APP_APP_ID }}
150
+ PRIVATE_KEY: ${{ secrets._GITHUB_APP_PEM_CONTENT }}
151
+ DYNAMIC_COMMANDS_BUN_SCRIPT: .github/scripts/src/load-slash-commands/index.ts
152
+ ```
153
+
154
+ Create that file and print the available command names:
155
+
156
+ ```ts
157
+ const commands = ["merge", "ai", "copy"];
158
+ console.log(commands.join("\n"));
159
+ ```
160
+
161
+ Print only command names to stdout, separated by newlines or commas. Use `console.error` for logs. Empty or invalid output, or a nonzero exit, fails the action.
162
+
163
+ The action checks write access and rejects forks before loading this script from the PR head. Run `setup` first to provide Bun and dependencies; this action installs nothing.
164
+
165
+ > [!IMPORTANT]
166
+ > Do not check out the PR head and install its dependencies before calling this action. The gate cannot protect earlier workflow steps. Keep pre-authorization setup on trusted code, as in the example.
167
+
168
+ See the [action documentation](./actions/commands/README.md) for GitHub App permissions and the full dispatch contract.
169
+
33
170
  ## Available Clients
34
171
 
35
172
  ```ts
36
173
  import {
37
174
  DockerClient,
175
+ FluxClient,
38
176
  EnvClient,
39
177
  GitHubClient,
40
178
  NPMClient,
@@ -57,6 +195,7 @@ Jump to client documentation
57
195
 
58
196
  - [`GitHubClient`](#githubclient)
59
197
  - [`DockerClient`](#dockerclient)
198
+ - [`FluxClient`](#fluxclient)
60
199
  - [`ProtobufCompilerClient`](#protobufcompilerclient)
61
200
  - [Setup action versions](#setup-action-versions)
62
201
  - [`NPMClient`](#npmclient)
@@ -120,6 +259,14 @@ await client.sendPrMessage({
120
259
  message: "Deployed and healthy.",
121
260
  });
122
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
+
123
270
  // use VersioningClient to compute your next semver tag.
124
271
  // look for `VersioningClient` section for how to configure / use
125
272
  const versioning = new VersioningClient(client, VersionUpdatingStrategy.JSTS);
@@ -149,6 +296,39 @@ await client.outputToGithubOutput({
149
296
  });
150
297
  ```
151
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. Files that only patch an object are not recorded, so a change made by a patch is attributed to the object's original file. A renamed object (e.g. a new generator hash suffix) shows as one removal and one addition.
331
+
152
332
  ### `DockerClient`
153
333
 
154
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,222 @@
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 { 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
+ /**
159
+ * Pairs base and head objects by API group, kind, namespace and name, and diffs the ones whose
160
+ * rendered YAML differs. An apiVersion bump within a group is a change, not a remove + add.
161
+ */
162
+ async function diffObjects(baseObjects, headObjects, nextDir) {
163
+ const key = (object) => {
164
+ const group = object.apiVersion.includes("/") ? object.apiVersion.split("/")[0] : "";
165
+ return [group, object.kind, object.namespace, object.name].join("\0");
166
+ };
167
+ const pairs = new Map();
168
+ for (const object of baseObjects) {
169
+ pairs.set(key(object), { base: object });
170
+ }
171
+ for (const object of headObjects) {
172
+ const k = key(object);
173
+ pairs.set(k, { ...pairs.get(k), head: object });
174
+ }
175
+ const diffs = await Promise.all([...pairs.values()].map(async ({ base, head }) => {
176
+ if (base?.yaml === head?.yaml) {
177
+ return null;
178
+ }
179
+ const current = (head ?? base);
180
+ const { kind, namespace, name, file } = current;
181
+ const diff = await unifiedDiff(nextDir(), [kind, namespace, name].filter(Boolean).join("/"), base?.yaml ?? "", head?.yaml ?? "");
182
+ const lines = diff.split("\n");
183
+ const objectDiff = {
184
+ kind,
185
+ namespace,
186
+ name,
187
+ ...(file ? { file } : {}),
188
+ status: !base ? "added"
189
+ : !head ? "removed"
190
+ : "changed",
191
+ diff,
192
+ additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
193
+ deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
194
+ };
195
+ return objectDiff;
196
+ }));
197
+ return diffs
198
+ .filter((diff) => diff !== null)
199
+ .sort((a, b) =>
200
+ // objects without a recorded file sort last
201
+ (a.file ?? "\uffff").localeCompare(b.file ?? "\uffff") ||
202
+ a.kind.localeCompare(b.kind) ||
203
+ a.namespace.localeCompare(b.namespace) ||
204
+ a.name.localeCompare(b.name));
205
+ }
206
+ async function unifiedDiff(dir, targetPath, baseOutput, headOutput) {
207
+ await mkdir(dir, { recursive: true });
208
+ const baseFile = path.join(dir, "base.yaml");
209
+ const headFile = path.join(dir, "head.yaml");
210
+ await Promise.all([
211
+ Bun.write(baseFile, baseOutput),
212
+ Bun.write(headFile, headOutput),
213
+ ]);
214
+ const result = await $ `diff -u --label ${`a/${targetPath}`} --label ${`b/${targetPath}`} ${baseFile} ${headFile}`
215
+ .quiet()
216
+ .nothrow();
217
+ // diff exits 1 when the files differ and 2 on error
218
+ if (result.exitCode > 1) {
219
+ throw new Error(`diff failed for ${targetPath}: ${result.stderr}`);
220
+ }
221
+ return result.stdout.toString();
222
+ }
@@ -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,44 @@
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
+ /** the object's rendered YAML, without the origin annotation */
13
+ yaml: string;
14
+ };
15
+ type RenderedKustomization = {
16
+ /** rendered objects, in kustomize's output order; empty when `error` is set */
17
+ objects: RenderedObject[];
18
+ /** `kustomize build` error output, when the build failed */
19
+ error?: string;
20
+ notes: string[];
21
+ };
22
+ /**
23
+ * Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
24
+ * `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
25
+ * `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
26
+ * followed by `postBuild` variable substitution.
27
+ *
28
+ * Each rendered object records the file that defines it.
29
+ */
30
+ export declare function renderKustomization(root: string, kustomization: FluxKustomization): Promise<RenderedKustomization>;
31
+ /**
32
+ * Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
33
+ * with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
34
+ *
35
+ * Variables without a value and without a default become empty strings, as in Flux, unless
36
+ * `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
37
+ */
38
+ export declare function substituteVariables(input: string, vars: Record<string, string>, { keepUnresolved }: {
39
+ keepUnresolved: boolean;
40
+ }): {
41
+ output: string;
42
+ unresolved: string[];
43
+ };
44
+ export {};
@@ -0,0 +1,191 @@
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
+ /**
16
+ * Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
17
+ * `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
18
+ * `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
19
+ * followed by `postBuild` variable substitution.
20
+ *
21
+ * Each rendered object records the file that defines it.
22
+ */
23
+ export async function renderKustomization(root, kustomization) {
24
+ const notes = [];
25
+ const { spec } = kustomization;
26
+ const targetDir = path.join(root, kustomization.path);
27
+ // kustomize rejects absolute resource paths, so the wrapper must live inside `root`
28
+ const wrapper = await mkdtemp(path.join(root, ".flux-render-"));
29
+ try {
30
+ const relative = (to) => path.relative(wrapper, to);
31
+ let resources = [relative(targetDir)];
32
+ if (!(await hasKustomizationFile(targetDir))) {
33
+ resources = (await generateResources(targetDir)).map(relative);
34
+ notes.push(`\`${kustomization.path}\` has no kustomization.yaml; resources were generated the way Flux does.`);
35
+ }
36
+ const wrapperKustomization = {
37
+ apiVersion: "kustomize.config.k8s.io/v1beta1",
38
+ kind: "Kustomization",
39
+ namespace: spec.targetNamespace,
40
+ namePrefix: spec.namePrefix,
41
+ nameSuffix: spec.nameSuffix,
42
+ resources,
43
+ components: spec.components?.map((component) => relative(path.join(targetDir, component))),
44
+ images: spec.images,
45
+ patches: spec.patches,
46
+ labels: spec.commonMetadata?.labels ?
47
+ [{ pairs: spec.commonMetadata.labels, includeSelectors: false }]
48
+ : undefined,
49
+ commonAnnotations: spec.commonMetadata?.annotations,
50
+ buildMetadata: ["originAnnotations"],
51
+ };
52
+ await Bun.write(path.join(wrapper, "kustomization.yaml"), yaml.stringify(wrapperKustomization));
53
+ const result = await $ `kustomize build --load-restrictor LoadRestrictionsNone ${wrapper}`
54
+ .quiet()
55
+ .nothrow();
56
+ if (result.exitCode !== 0) {
57
+ return { objects: [], error: result.stderr.toString().trim(), notes };
58
+ }
59
+ let output = result.stdout.toString();
60
+ if (spec.postBuild) {
61
+ const hasExternalValues = (spec.postBuild.substituteFrom ?? []).length > 0;
62
+ const substituted = substituteVariables(output, spec.postBuild.substitute ?? {}, { keepUnresolved: hasExternalValues });
63
+ output = substituted.output;
64
+ if (substituted.unresolved.length > 0) {
65
+ notes.push(`\`postBuild.substituteFrom\` values live in the cluster and were left unresolved: ${substituted.unresolved
66
+ .map((name) => `\`\${${name}}\``)
67
+ .join(", ")}.`);
68
+ }
69
+ }
70
+ const docs = yaml.parseAllDocuments(output);
71
+ const invalid = docs.find((doc) => doc.errors.length > 0);
72
+ if (invalid) {
73
+ return {
74
+ objects: [],
75
+ error: `rendered output is not valid YAML: ${invalid.errors[0]?.message}`,
76
+ notes,
77
+ };
78
+ }
79
+ const objects = docs
80
+ .filter((doc) => doc.contents !== null)
81
+ .map((doc) => toRenderedObject(doc, root, wrapper));
82
+ return { objects, notes };
83
+ }
84
+ finally {
85
+ await rm(wrapper, { recursive: true, force: true });
86
+ }
87
+ }
88
+ function toRenderedObject(doc, root, wrapper) {
89
+ const origin = doc.getIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
90
+ doc.deleteIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
91
+ const annotations = doc.getIn(["metadata", "annotations"]);
92
+ if (yaml.isMap(annotations) && annotations.items.length === 0) {
93
+ doc.deleteIn(["metadata", "annotations"]);
94
+ }
95
+ const value = (doc.toJS() ?? {});
96
+ return {
97
+ apiVersion: String(value.apiVersion ?? ""),
98
+ kind: String(value.kind ?? ""),
99
+ namespace: String(value.metadata?.namespace ?? ""),
100
+ name: String(value.metadata?.name ?? ""),
101
+ file: typeof origin === "string" ?
102
+ originFile(origin, root, wrapper)
103
+ : undefined,
104
+ yaml: doc.toString({ lineWidth: 0 }).replace(/^---\n/, ""),
105
+ };
106
+ }
107
+ /**
108
+ * Converts an origin annotation (`path`, or `configuredIn` for generated objects, relative to
109
+ * the wrapper kustomization) to a repository-relative path. Remote origins are returned as
110
+ * `<repo>//<path>?ref=<ref>`.
111
+ */
112
+ function originFile(origin, root, wrapper) {
113
+ const parsed = yaml.parse(origin);
114
+ const file = parsed?.path ?? parsed?.configuredIn;
115
+ if (parsed?.repo) {
116
+ return `${parsed.repo}//${file ?? ""}${parsed.ref ? `?ref=${parsed.ref}` : ""}`;
117
+ }
118
+ return file ? path.relative(root, path.resolve(wrapper, file)) : undefined;
119
+ }
120
+ /**
121
+ * Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
122
+ * with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
123
+ *
124
+ * Variables without a value and without a default become empty strings, as in Flux, unless
125
+ * `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
126
+ */
127
+ export function substituteVariables(input, vars, { keepUnresolved }) {
128
+ const unresolved = new Set();
129
+ const output = input
130
+ .split(/^(?=---$)/m)
131
+ .map((doc) => {
132
+ if (SUBSTITUTE_DISABLED.test(doc)) {
133
+ return doc;
134
+ }
135
+ return doc.replace(VARIABLE, (match, escape, name, operator, fallback) => {
136
+ if (escape) {
137
+ return match.slice(1);
138
+ }
139
+ const value = vars[name];
140
+ if (operator !== undefined) {
141
+ const useValue = value !== undefined &&
142
+ (!operator.startsWith(":") || value !== "");
143
+ return useValue ? value : (fallback ?? "");
144
+ }
145
+ if (value !== undefined) {
146
+ return value;
147
+ }
148
+ if (keepUnresolved) {
149
+ unresolved.add(name);
150
+ return match;
151
+ }
152
+ return "";
153
+ });
154
+ })
155
+ .join("");
156
+ return { output, unresolved: [...unresolved].sort() };
157
+ }
158
+ async function hasKustomizationFile(dir) {
159
+ const entries = await readdir(dir).catch(() => []);
160
+ return entries.some((entry) => KUSTOMIZATION_FILES.includes(entry));
161
+ }
162
+ /**
163
+ * Mirrors Flux's generated kustomization for a path without one: every Kubernetes manifest
164
+ * under `dir`, with subdirectories that have their own kustomization included as a whole.
165
+ */
166
+ async function generateResources(dir) {
167
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
168
+ const resources = [];
169
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
170
+ const full = path.join(dir, entry.name);
171
+ if (entry.isDirectory()) {
172
+ if (await hasKustomizationFile(full)) {
173
+ resources.push(full);
174
+ }
175
+ else {
176
+ resources.push(...(await generateResources(full)));
177
+ }
178
+ }
179
+ else if (/\.ya?ml$/.test(entry.name) && (await isManifest(full))) {
180
+ resources.push(full);
181
+ }
182
+ }
183
+ return resources;
184
+ }
185
+ async function isManifest(file) {
186
+ const docs = yaml.parseAllDocuments(await Bun.file(file).text());
187
+ return docs.some((doc) => {
188
+ const value = doc.errors.length === 0 ? doc.toJS() : undefined;
189
+ return (typeof value?.apiVersion === "string" && typeof value?.kind === "string");
190
+ });
191
+ }
@@ -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";
@@ -0,0 +1,7 @@
1
+ /** Exactly one source: inline names or a repository-owned Bun loader. */
2
+ export declare function parseCommands(input: string, file: string): {
3
+ commands: string;
4
+ file: string;
5
+ };
6
+ /** Runs only after the action has checked write permission and rejected fork heads. */
7
+ export declare function loadCommands(file: string, workspace: string): Promise<string>;
@@ -0,0 +1,44 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Exactly one source: inline names or a repository-owned Bun loader. */
4
+ export function parseCommands(input, file) {
5
+ if (Boolean(input.trim()) === Boolean(file.trim())) {
6
+ throw new Error("Set exactly one of COMMANDS or DYNAMIC_COMMANDS_BUN_SCRIPT");
7
+ }
8
+ if (file.trim()) {
9
+ if (/[\r\n]/.test(file) ||
10
+ path.isAbsolute(file) ||
11
+ file.split(/[\\/]/).includes("..")) {
12
+ throw new Error("DYNAMIC_COMMANDS_BUN_SCRIPT must be a repository-relative file path");
13
+ }
14
+ return { commands: "", file };
15
+ }
16
+ const commands = input.split(/[\s,]+/).filter(Boolean);
17
+ if (!commands.length ||
18
+ commands.some((name) => !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name))) {
19
+ throw new Error("COMMANDS must contain command names separated by commas or newlines");
20
+ }
21
+ return { commands: commands.join("\n"), file: "" };
22
+ }
23
+ /** Runs only after the action has checked write permission and rejected fork heads. */
24
+ export async function loadCommands(file, workspace) {
25
+ const root = await realpath(workspace);
26
+ const source = await realpath(path.resolve(root, file));
27
+ const relative = path.relative(root, source);
28
+ if (relative.startsWith("..") ||
29
+ path.isAbsolute(relative) ||
30
+ !(await stat(source)).isFile()) {
31
+ throw new Error("The command loader must be a file inside the repository");
32
+ }
33
+ const child = Bun.spawn([process.execPath, "run", "--no-install", source], {
34
+ cwd: root,
35
+ stdout: "pipe",
36
+ stderr: "inherit",
37
+ });
38
+ const stdout = await new Response(child.stdout).text();
39
+ const exitCode = await child.exited;
40
+ if (exitCode !== 0) {
41
+ throw new Error(`Command loader exited with code ${exitCode}`);
42
+ }
43
+ return parseCommands(stdout, "").commands;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import yargs from "yargs";
3
+ import { loadCommands, parseCommands } from "./helper";
4
+ const { commands, dynamicCommandsBunScript, file, workspace } = await yargs(process.argv.slice(2))
5
+ .option("commands", {
6
+ type: "string",
7
+ describe: "Hardcoded command names separated by commas or newlines",
8
+ })
9
+ .option("dynamicCommandsBunScript", {
10
+ type: "string",
11
+ describe: "Repository-relative Bun loader path",
12
+ })
13
+ .option("file", {
14
+ type: "string",
15
+ describe: "Execute the previously validated Bun loader",
16
+ })
17
+ .option("workspace", {
18
+ type: "string",
19
+ default: process.env.GITHUB_WORKSPACE,
20
+ describe: "Repository root for loader execution",
21
+ })
22
+ .conflicts("file", ["commands", "dynamicCommandsBunScript"])
23
+ .strict()
24
+ .parse();
25
+ async function main() {
26
+ const output = process.env.GITHUB_OUTPUT;
27
+ if (!output)
28
+ throw new Error("GITHUB_OUTPUT is required");
29
+ let result;
30
+ if (file) {
31
+ if (!workspace)
32
+ throw new Error("--workspace or GITHUB_WORKSPACE is required");
33
+ result = { commands: await loadCommands(file, workspace), file: "" };
34
+ }
35
+ else {
36
+ result = parseCommands(commands ?? "", dynamicCommandsBunScript ?? "");
37
+ }
38
+ await appendFile(output, `commands<<__COMMANDS__\n${result.commands}\n__COMMANDS__\nfile=${result.file}\n`);
39
+ }
40
+ await main();
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.89",
6
+ "version": "1.0.90-beta.g1f4d73ad",
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",