@izykitten/ce 0.0.1 → 0.0.3-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ce.js ADDED
@@ -0,0 +1,1456 @@
1
+ // src/command.ts
2
+ var VALUE_OPTIONS = {
3
+ docker: {
4
+ long: new Set(["config", "context", "host", "log-level", "tlscacert", "tlscert", "tlskey", "builder"]),
5
+ short: "cHl"
6
+ },
7
+ podman: {
8
+ long: new Set([
9
+ "cdi-spec-dir",
10
+ "cgroup-manager",
11
+ "cni-config-dir",
12
+ "config",
13
+ "conmon",
14
+ "connection",
15
+ "context",
16
+ "cpu-profile",
17
+ "db-backend",
18
+ "default-mounts-file",
19
+ "events-backend",
20
+ "hooks-dir",
21
+ "host",
22
+ "identity",
23
+ "imagestore",
24
+ "log-level",
25
+ "max-workers",
26
+ "memory-profile",
27
+ "module",
28
+ "namespace",
29
+ "network-backend",
30
+ "network-cmd-path",
31
+ "network-config-dir",
32
+ "out",
33
+ "pull-option",
34
+ "registries-conf",
35
+ "root",
36
+ "runroot",
37
+ "runtime",
38
+ "runtime-flag",
39
+ "ssh",
40
+ "storage-driver",
41
+ "storage-opt",
42
+ "tmpdir",
43
+ "url",
44
+ "volumepath"
45
+ ]),
46
+ short: "cH"
47
+ },
48
+ buildah: {
49
+ long: new Set([
50
+ "cgroup-manager",
51
+ "cpu-profile",
52
+ "default-mounts-file",
53
+ "experimental-timeout",
54
+ "imagestore",
55
+ "log-level",
56
+ "memory-profile",
57
+ "registries-conf",
58
+ "registries-conf-dir",
59
+ "root",
60
+ "runroot",
61
+ "short-name-alias-conf",
62
+ "storage-driver",
63
+ "storage-opt",
64
+ "userns-gid-map",
65
+ "userns-uid-map"
66
+ ]),
67
+ short: ""
68
+ }
69
+ };
70
+ function takesNext(engine, arg) {
71
+ const options = VALUE_OPTIONS[engine];
72
+ if (arg.startsWith("--"))
73
+ return options.long.has(arg.slice(2));
74
+ for (let j = 1;j < arg.length; j++) {
75
+ if (options.short.includes(arg[j]))
76
+ return j === arg.length - 1;
77
+ }
78
+ return false;
79
+ }
80
+ var COMMANDS = {
81
+ docker: [
82
+ { words: ["build"] },
83
+ { words: ["image", "build"] },
84
+ { words: ["builder", "build"] },
85
+ { words: ["buildx", "build"] },
86
+ { words: ["buildx", "b"] },
87
+ { words: ["buildx", "imagetools", "create"], tagsOnly: true }
88
+ ],
89
+ podman: [{ words: ["build"] }, { words: ["image", "build"] }, { words: ["buildx", "build"] }],
90
+ buildah: [{ words: ["build"] }, { words: ["bud"] }]
91
+ };
92
+ function findBuildCommand(engine, argv) {
93
+ const words = [];
94
+ for (let i = 0;i < argv.length; i++) {
95
+ const arg = argv[i];
96
+ if (arg === "--")
97
+ return null;
98
+ if (arg.startsWith("-")) {
99
+ if (takesNext(engine, arg))
100
+ i++;
101
+ continue;
102
+ }
103
+ words.push(arg);
104
+ const candidates = COMMANDS[engine].filter((command) => words.every((word, j) => command.words[j] === word));
105
+ const match = candidates.find((command) => command.words.length === words.length);
106
+ if (match)
107
+ return { start: i + 1, name: arg, tagsOnly: match.tagsOnly ?? false };
108
+ if (!candidates.length)
109
+ return null;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // src/errors.ts
115
+ class CeError extends Error {
116
+ name = "CeError";
117
+ }
118
+ function isErrno(exc, code) {
119
+ if (!(exc instanceof Error))
120
+ return false;
121
+ const actual = exc.code;
122
+ return typeof actual === "string" && (code === undefined || actual === code);
123
+ }
124
+ function errorMessage(exc) {
125
+ return exc instanceof Error ? exc.message : String(exc);
126
+ }
127
+
128
+ // src/json.ts
129
+ import { randomUUID } from "node:crypto";
130
+ import fs from "node:fs";
131
+ import path from "node:path";
132
+ function isObject(value) {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+ function readJsonFile(file, fallback) {
136
+ try {
137
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(file)));
138
+ } catch (exc) {
139
+ if (isErrno(exc, "ENOENT"))
140
+ return fallback;
141
+ throw new CeError(`cannot read JSON file ${JSON.stringify(file)}: ${errorMessage(exc)}`);
142
+ }
143
+ }
144
+ function dumpJson(payload) {
145
+ return JSON.stringify(payload, (_key, value) => isObject(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : value);
146
+ }
147
+ function writePrivateJson(file, payload) {
148
+ const directory = path.dirname(file);
149
+ fs.mkdirSync(directory, { recursive: true, mode: 448 });
150
+ const temporary = path.join(directory, `.ce-forgejo-${randomUUID().slice(0, 8)}`);
151
+ const fd = fs.openSync(temporary, "wx", 384);
152
+ try {
153
+ try {
154
+ fs.fchmodSync(fd, 384);
155
+ fs.writeSync(fd, `${dumpJson(payload)}
156
+ `);
157
+ fs.fsyncSync(fd);
158
+ } finally {
159
+ fs.closeSync(fd);
160
+ }
161
+ fs.renameSync(temporary, file);
162
+ } finally {
163
+ fs.rmSync(temporary, { force: true });
164
+ }
165
+ }
166
+
167
+ // src/url.ts
168
+ function sameAuthority(a, b) {
169
+ return a[0] === b[0] && a[1] === b[1];
170
+ }
171
+ var SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//iu;
172
+ function parseHttpUrl(value) {
173
+ const raw = value.trim();
174
+ if (!raw)
175
+ throw new CeError("empty URL or host");
176
+ let url;
177
+ try {
178
+ url = new URL(SCHEME_RE.test(raw) ? raw : `https://${raw}`);
179
+ } catch {
180
+ throw new CeError(`invalid URL or host ${JSON.stringify(value)}`);
181
+ }
182
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
183
+ throw new CeError(`unsupported URL scheme ${JSON.stringify(url.protocol.slice(0, -1))}`);
184
+ }
185
+ if (!url.hostname)
186
+ throw new CeError(`missing hostname in ${JSON.stringify(value)}`);
187
+ return url;
188
+ }
189
+ function urlAuthority(value) {
190
+ const url = parseHttpUrl(value);
191
+ const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : 443;
192
+ return [url.hostname.replace(/^\[(.*)\]$/u, "$1"), port];
193
+ }
194
+ function assertAbsoluteHttpUrl(value, error) {
195
+ try {
196
+ if (!SCHEME_RE.test(value.trim()))
197
+ throw new CeError(error);
198
+ parseHttpUrl(value);
199
+ } catch {
200
+ throw new CeError(error);
201
+ }
202
+ }
203
+ function registryServer(value) {
204
+ const url = parseHttpUrl(value);
205
+ const authority = value.trim().replace(SCHEME_RE, "").split(/[/\\?#]/u, 1)[0].split("@").at(-1);
206
+ const port = url.port || /:(\d+)$/u.exec(authority)?.[1];
207
+ return port ? `${url.hostname}:${Number(port)}` : url.hostname;
208
+ }
209
+ function registryKey(value) {
210
+ return registryServer(value).toLowerCase();
211
+ }
212
+
213
+ // src/metadata.ts
214
+ var CI_VARIABLES = [
215
+ "image",
216
+ "ref",
217
+ "branch",
218
+ "pr",
219
+ "tag",
220
+ "default",
221
+ "revision",
222
+ "sha",
223
+ "version",
224
+ "major",
225
+ "minor",
226
+ "patch",
227
+ "stable"
228
+ ];
229
+ var ENV_VARIABLE = /^env\.([A-Za-z_][A-Za-z0-9_]*)$/u;
230
+ var SEMVER = /^v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?)$/u;
231
+ var nonEmpty = (value) => value ? value : undefined;
232
+
233
+ class CiMetadata {
234
+ #env;
235
+ #cache = new Map;
236
+ #ref;
237
+ #event;
238
+ constructor(env = process.env) {
239
+ this.#env = env;
240
+ }
241
+ known(name) {
242
+ return CI_VARIABLES.includes(name) || ENV_VARIABLE.test(name);
243
+ }
244
+ get(name) {
245
+ const envName = ENV_VARIABLE.exec(name)?.[1];
246
+ if (envName)
247
+ return nonEmpty(this.#env[envName]);
248
+ if (!this.#cache.has(name))
249
+ this.#cache.set(name, this.#resolve(name));
250
+ return this.#cache.get(name);
251
+ }
252
+ labels() {
253
+ const labels = {};
254
+ const add = (key, value) => {
255
+ try {
256
+ const v = value();
257
+ if (v)
258
+ labels[`org.opencontainers.image.${key}`] = v;
259
+ } catch (exc) {
260
+ if (!(exc instanceof CeError))
261
+ throw exc;
262
+ }
263
+ };
264
+ const server = nonEmpty(this.#ciEnv("SERVER_URL"))?.replace(/\/+$/u, "");
265
+ const repository = nonEmpty(this.#ciEnv("REPOSITORY"));
266
+ add("source", () => server && repository ? `${server}/${repository}` : undefined);
267
+ add("revision", () => this.#ciEnv("SHA"));
268
+ add("version", () => this.get("version") ?? this.#rawRef());
269
+ add("title", () => repository?.split("/").at(-1));
270
+ add("description", () => {
271
+ const repo = this.#payload().repository;
272
+ return isObject(repo) && typeof repo.description === "string" ? repo.description : undefined;
273
+ });
274
+ return labels;
275
+ }
276
+ #resolve(name) {
277
+ switch (name) {
278
+ case "image": {
279
+ const server = this.#require("SERVER_URL", "{image}");
280
+ const repository = this.#require("REPOSITORY", "{image}");
281
+ return `${registryServer(server)}/${repository}`.toLowerCase();
282
+ }
283
+ case "ref":
284
+ return this.#rawRef();
285
+ case "branch": {
286
+ const ref = this.#parsedRef();
287
+ return ref.kind === "branch" ? ref.name : undefined;
288
+ }
289
+ case "tag": {
290
+ const ref = this.#parsedRef();
291
+ return ref.kind === "tag" ? ref.name : undefined;
292
+ }
293
+ case "pr": {
294
+ const ref = this.#parsedRef();
295
+ return ref.kind === "pr" ? ref.number : undefined;
296
+ }
297
+ case "default": {
298
+ const ref = this.#parsedRef();
299
+ if (ref.kind !== "branch")
300
+ return;
301
+ return ref.name === this.#defaultBranch() ? ref.name : undefined;
302
+ }
303
+ case "revision":
304
+ return this.#require("SHA", `{${name}}`);
305
+ case "sha":
306
+ return this.#require("SHA", `{${name}}`).slice(0, 7);
307
+ case "version":
308
+ return this.#semver()?.[1];
309
+ case "major":
310
+ return this.#semver()?.[2];
311
+ case "minor":
312
+ return this.#semver()?.[3];
313
+ case "patch":
314
+ return this.#semver()?.[4];
315
+ case "stable": {
316
+ const semver = this.#semver();
317
+ return semver && semver[5] === undefined ? semver[1] : undefined;
318
+ }
319
+ }
320
+ throw new CeError(`ce: unknown placeholder {${name}}`);
321
+ }
322
+ #ciEnv(name) {
323
+ return nonEmpty(this.#env[`FORGEJO_${name}`]) ?? nonEmpty(this.#env[`GITHUB_${name}`]);
324
+ }
325
+ #require(name, user) {
326
+ const value = this.#ciEnv(name);
327
+ if (!value) {
328
+ throw new CeError(`ce: ${user} needs GITHUB_${name} (or FORGEJO_${name}), which is not set; is this a Forgejo Actions run?`);
329
+ }
330
+ return value;
331
+ }
332
+ #rawRef() {
333
+ const ref = this.#parsedRef();
334
+ return ref.kind === "pr" ? `pr-${ref.number}` : ref.name;
335
+ }
336
+ #parsedRef() {
337
+ if (this.#ref)
338
+ return this.#ref;
339
+ const ref = this.#require("REF", "{ref}, {branch}, {tag} or {pr}");
340
+ const event = this.#ciEnv("EVENT_NAME") ?? "";
341
+ const pull = /^refs\/pull\/(\d+)\//u.exec(ref);
342
+ if (event.startsWith("pull_request") || pull) {
343
+ const payload = pull ? {} : this.#payload();
344
+ const fromPayload = [isObject(payload.pull_request) && payload.pull_request.number, payload.number].find(Number.isInteger);
345
+ const number = pull?.[1] ?? (fromPayload === undefined ? undefined : String(fromPayload));
346
+ if (!number)
347
+ throw new CeError(`ce: cannot find the pull request number for ${event} on ${ref}`);
348
+ this.#ref = { kind: "pr", number };
349
+ } else if (ref.startsWith("refs/heads/")) {
350
+ this.#ref = { kind: "branch", name: ref.slice("refs/heads/".length) };
351
+ } else if (ref.startsWith("refs/tags/")) {
352
+ this.#ref = { kind: "tag", name: ref.slice("refs/tags/".length) };
353
+ } else {
354
+ throw new CeError(`ce: cannot tell what ${JSON.stringify(ref)} is: not a branch, tag or pull request`);
355
+ }
356
+ return this.#ref;
357
+ }
358
+ #semver() {
359
+ const ref = this.#parsedRef();
360
+ return ref.kind === "tag" ? SEMVER.exec(ref.name) : null;
361
+ }
362
+ #defaultBranch() {
363
+ const repository = this.#payload().repository;
364
+ const branch = isObject(repository) ? repository.default_branch : undefined;
365
+ if (typeof branch !== "string" || !branch) {
366
+ throw new CeError("ce: {default} needs repository.default_branch in the event file, which it does not have");
367
+ }
368
+ return branch;
369
+ }
370
+ #payload() {
371
+ if (this.#event)
372
+ return this.#event;
373
+ const file = this.#require("EVENT_PATH", "reading the event file");
374
+ let payload;
375
+ try {
376
+ payload = readJsonFile(file, undefined);
377
+ } catch (exc) {
378
+ throw new CeError(`ce: ${errorMessage(exc)}`);
379
+ }
380
+ if (!isObject(payload))
381
+ throw new CeError(`ce: the event file ${JSON.stringify(file)} is missing or not a JSON object`);
382
+ this.#event = payload;
383
+ return payload;
384
+ }
385
+ }
386
+
387
+ // src/system.ts
388
+ import { spawnSync } from "node:child_process";
389
+ import fs2 from "node:fs";
390
+ import path2 from "node:path";
391
+ import { setTimeout } from "node:timers/promises";
392
+ var io = {
393
+ stdout: (text) => void fs2.writeSync(1, text),
394
+ stderr: (text) => void fs2.writeSync(2, text),
395
+ stdin: () => fs2.readFileSync(0, "utf8"),
396
+ argv0: () => process.argv[1] ?? "ce"
397
+ };
398
+ function log(line) {
399
+ io.stdout(`${line}
400
+ `);
401
+ }
402
+ function logError(line) {
403
+ io.stderr(`${line}
404
+ `);
405
+ }
406
+ function childEnv() {
407
+ return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
408
+ }
409
+ function which(command) {
410
+ const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean).map((dir) => path2.join(dir, command));
411
+ for (const candidate of candidates) {
412
+ try {
413
+ fs2.accessSync(candidate, fs2.constants.X_OK);
414
+ if (fs2.statSync(candidate).isFile())
415
+ return candidate;
416
+ } catch {}
417
+ }
418
+ return null;
419
+ }
420
+ var system = {
421
+ which,
422
+ run(argv, input) {
423
+ const result = spawnSync(argv[0], argv.slice(1), {
424
+ input,
425
+ stdio: [input === undefined ? "ignore" : "pipe", "ignore", "pipe"],
426
+ env: childEnv(),
427
+ encoding: "utf8",
428
+ maxBuffer: Infinity
429
+ });
430
+ if (result.error)
431
+ throw result.error;
432
+ return { status: result.status ?? 1, stderr: result.stderr ?? "" };
433
+ },
434
+ sleep: (ms) => setTimeout(ms),
435
+ exec(file, argv) {
436
+ const resolved = system.which(file);
437
+ if (!resolved) {
438
+ logError(`ce: ${file} not found on PATH`);
439
+ return 1;
440
+ }
441
+ process.execve(resolved, argv, childEnv());
442
+ throw new Error(`execve returned for ${file}`);
443
+ }
444
+ };
445
+ function binDir() {
446
+ return fs2.realpathSync(path2.join(import.meta.dirname, "..", "bin"));
447
+ }
448
+ function ensureHelperOnPath() {
449
+ const dir = binDir();
450
+ const parts = (process.env.PATH ?? "").split(path2.delimiter);
451
+ if (!parts.includes(dir))
452
+ process.env.PATH = [dir, ...parts].join(path2.delimiter);
453
+ }
454
+
455
+ // src/template.ts
456
+ var PLACEHOLDER = /\{([^{}]*)\}/gu;
457
+ function hasPlaceholders(value) {
458
+ return value.includes("{") || value.includes("}");
459
+ }
460
+ function parse(template, vars) {
461
+ const parts = [];
462
+ const text = (value) => {
463
+ if (hasPlaceholders(value))
464
+ throw new CeError(`ce: unmatched brace in ${JSON.stringify(template)}`);
465
+ if (value)
466
+ parts.push({ text: value });
467
+ };
468
+ let last = 0;
469
+ for (const match of template.matchAll(PLACEHOLDER)) {
470
+ text(template.slice(last, match.index));
471
+ last = match.index + match[0].length;
472
+ const guard = match[1].startsWith("?");
473
+ const name = guard ? match[1].slice(1) : match[1];
474
+ if (!vars.known(name))
475
+ throw new CeError(`ce: unknown placeholder ${match[0]} in ${JSON.stringify(template)}`);
476
+ parts.push({ name, guard });
477
+ }
478
+ text(template.slice(last));
479
+ return parts;
480
+ }
481
+ function expand(template, vars, encode) {
482
+ const parts = parse(template, vars);
483
+ for (const part of parts) {
484
+ if ("name" in part && part.guard && vars.get(part.name) === undefined)
485
+ return null;
486
+ }
487
+ let out = "";
488
+ for (const part of parts) {
489
+ if ("text" in part)
490
+ out += part.text;
491
+ else if (!part.guard) {
492
+ const value = vars.get(part.name);
493
+ if (value === undefined) {
494
+ throw new CeError(`ce: {${part.name}} has no value here, in ${JSON.stringify(template)}. ` + `To leave the tag out instead, guard it with {?${part.name}}.`);
495
+ }
496
+ out += encode(value);
497
+ }
498
+ }
499
+ return out;
500
+ }
501
+ function checkValue(template, vars, what) {
502
+ if (parse(template, vars).some((part) => ("guard" in part) && part.guard)) {
503
+ throw new CeError(`ce: ${what} ${JSON.stringify(template)} cannot use {?guards}; they only apply to tags`);
504
+ }
505
+ }
506
+ function expandValue(template, vars, encode, what) {
507
+ checkValue(template, vars, what);
508
+ return expand(template, vars, encode);
509
+ }
510
+
511
+ // src/tags.ts
512
+ var INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
513
+ var INVALID_SCOPE_CHARS = /[^A-Za-z0-9_.:/\-]/gu;
514
+ var VALID_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u;
515
+ var HOST_PORT = /^([^/:]+):(?:\d|\{[^{}]*\})+\//u;
516
+ var SEPARATORS = "-_.";
517
+ function sanitizeChars(value, charRegex) {
518
+ return value.replace(charRegex, "_");
519
+ }
520
+ function assertAscii(label, value) {
521
+ for (const ch of value) {
522
+ const code = ch.codePointAt(0);
523
+ if (code >= 128) {
524
+ const hex = code.toString(16).toUpperCase().padStart(4, "0");
525
+ throw new CeError(`ce: refusing to sanitize non-ASCII character ${JSON.stringify(ch)} ` + `(U+${hex}) in ${label} ${JSON.stringify(value)} — rename the ref ` + `or pass an explicit ASCII tag.`);
526
+ }
527
+ }
528
+ }
529
+ function stripProto(value) {
530
+ return value.replace(/^https?:\/\//u, "");
531
+ }
532
+ function splitOnce(value, sep) {
533
+ const i = value.indexOf(sep);
534
+ return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
535
+ }
536
+ function splitRef(value) {
537
+ const host = HOST_PORT.exec(value);
538
+ const isHost = host && (host[1].includes(".") || host[1] === "localhost" || value.includes(":", host[0].length));
539
+ const offset = isHost ? host[0].length : 0;
540
+ const i = value.indexOf(":", offset);
541
+ return i < 0 ? null : [value.slice(0, i), value.slice(i + 1)];
542
+ }
543
+ function assertValidTag(repo, tag) {
544
+ if (!VALID_TAG.test(tag)) {
545
+ throw new CeError(`ce: invalid tag ${JSON.stringify(tag)} in ${JSON.stringify(`${repo}:${tag}`)}: a tag is up to 128 letters, ` + `digits, "_", "." and "-", and cannot start with "." or "-".`);
546
+ }
547
+ return `${repo}:${tag}`;
548
+ }
549
+ function encodeTagValue(value) {
550
+ assertAscii("placeholder value", value);
551
+ return value.replace(INVALID_TAG_CHARS, "-");
552
+ }
553
+ function encodeRepoValue(value) {
554
+ assertAscii("placeholder value", value);
555
+ if (/[^A-Za-z0-9._:/-]/u.test(value))
556
+ throw new CeError(`ce: ${JSON.stringify(value)} cannot go in an image name`);
557
+ return value;
558
+ }
559
+ function expandImage(template, vars) {
560
+ const image = expandValue(template, vars, encodeRepoValue, "image name");
561
+ if (!/^(?:[^/:]+:\d+\/)?[^:]*$/u.test(image)) {
562
+ throw new CeError(`ce: image name ${JSON.stringify(image)} from ${JSON.stringify(template)} has a ":" that is not a registry port`);
563
+ }
564
+ return image;
565
+ }
566
+ function expandPrefix(prefix, vars, what) {
567
+ return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what) : prefix;
568
+ }
569
+ function literalRef(repo, tag) {
570
+ if (tag.includes("/")) {
571
+ const [first, rest] = splitOnce(tag, "/");
572
+ return `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
573
+ }
574
+ return `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
575
+ }
576
+ function computeSafePrefix(prefix) {
577
+ assertAscii("tag prefix", prefix);
578
+ const p = sanitizeChars(prefix, INVALID_TAG_CHARS);
579
+ return p && !SEPARATORS.includes(p.at(-1)) ? `${p}-` : p;
580
+ }
581
+ function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
582
+ assertAscii("tag", value);
583
+ const v = stripProto(value);
584
+ if (!v)
585
+ throw new CeError("ce: empty -t/--tag value");
586
+ const [repoTemplate, tagList] = splitRef(v) ?? [v, undefined];
587
+ if (hasPlaceholders(repoTemplate))
588
+ checkValue(repoTemplate, vars, "image name");
589
+ if (hasPlaceholders(tagPrefix))
590
+ checkValue(tagPrefix, vars, "tag prefix");
591
+ let repo;
592
+ const expandRepo = () => repo ??= hasPlaceholders(repoTemplate) ? expandImage(repoTemplate, vars) : repoTemplate;
593
+ if (tagList === undefined)
594
+ return [expandRepo()];
595
+ const tags = tagList.split(/[,\s]+/u).filter(Boolean);
596
+ if (!tags.length)
597
+ throw new CeError(`ce: no tag after the ":" in ${JSON.stringify(value)}`);
598
+ let safePrefix;
599
+ const refs = [];
600
+ for (const tag of tags) {
601
+ const expanded = hasPlaceholders(tag) ? expand(tag, vars, encodeTagValue) : tag;
602
+ if (expanded === null)
603
+ continue;
604
+ safePrefix ??= computeSafePrefix(expandPrefix(tagPrefix, vars, "tag prefix"));
605
+ if (hasPlaceholders(tag)) {
606
+ refs.push(assertValidTag(expandRepo(), `${safePrefix}${sanitizeChars(expanded, INVALID_TAG_CHARS)}`));
607
+ } else {
608
+ const ref = literalRef(expandRepo(), `${safePrefix}${tag}`);
609
+ const i = ref.lastIndexOf(":");
610
+ refs.push(assertValidTag(ref.slice(0, i), ref.slice(i + 1)));
611
+ }
612
+ }
613
+ return refs;
614
+ }
615
+ function csvFields(value) {
616
+ value = value.replace(/\r?\n$/u, "");
617
+ if (!value)
618
+ return null;
619
+ const fields = [];
620
+ let i = 0;
621
+ for (;; ) {
622
+ if (value[i] !== '"') {
623
+ const end = value.indexOf(",", i);
624
+ const field = end < 0 ? value.slice(i) : value.slice(i, end);
625
+ if (field.includes('"'))
626
+ return null;
627
+ fields.push(field);
628
+ if (end < 0)
629
+ return fields;
630
+ i = end + 1;
631
+ continue;
632
+ }
633
+ let field = "";
634
+ for (i++;; i++) {
635
+ const quote = value.indexOf('"', i);
636
+ if (quote < 0)
637
+ return null;
638
+ field += value.slice(i, quote);
639
+ i = quote + 1;
640
+ if (value[i] !== '"')
641
+ break;
642
+ field += '"';
643
+ }
644
+ fields.push(field);
645
+ if (i === value.length)
646
+ return fields;
647
+ if (value[i] !== ",")
648
+ return null;
649
+ i++;
650
+ }
651
+ }
652
+ function csvJoin(fields) {
653
+ return fields.map((field) => /^[ \t]|[",\r\n]/u.test(field) ? `"${field.replaceAll('"', '""')}"` : field).join(",");
654
+ }
655
+ var fieldKey = (field) => field.split("=", 1)[0].toLowerCase();
656
+ function ghaFields(value) {
657
+ const fields = csvFields(value);
658
+ const type = fields?.findLast((field) => fieldKey(field) === "type");
659
+ return type?.slice("type=".length) === "gha" ? fields : null;
660
+ }
661
+ var scopeIndex = (fields) => fields.findLastIndex((field) => fieldKey(field) === "scope");
662
+ function cacheScopeName(value) {
663
+ const fields = ghaFields(value);
664
+ if (!fields)
665
+ return value;
666
+ const i = scopeIndex(fields);
667
+ if (i < 0)
668
+ return "";
669
+ const scope = fields[i].slice("scope=".length);
670
+ assertAscii("cache scope", scope);
671
+ return sanitizeChars(scope, INVALID_SCOPE_CHARS);
672
+ }
673
+ function normalizeCacheScope(value, cachePrefix) {
674
+ const fields = ghaFields(value);
675
+ if (!fields)
676
+ return value;
677
+ if (!cachePrefix)
678
+ return value;
679
+ assertAscii("cache prefix", cachePrefix);
680
+ const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
681
+ let scope = cacheScopeName(value);
682
+ if (!scope) {
683
+ scope = prefix.replace(/[-_.]+$/u, "");
684
+ } else if (prefix) {
685
+ scope = SEPARATORS.includes(prefix.at(-1)) ? `${prefix}${scope}` : `${prefix}-${scope}`;
686
+ }
687
+ const i = scopeIndex(fields);
688
+ if (i < 0)
689
+ fields.push(`scope=${scope}`);
690
+ else
691
+ fields[i] = `scope=${scope}`;
692
+ return csvJoin(fields);
693
+ }
694
+
695
+ // src/build-args.ts
696
+ function consumeValue(argv, flag) {
697
+ if (argv.length === 0)
698
+ throw new CeError(`ce: ${flag} requires a value but none was provided.`);
699
+ const v = argv[0];
700
+ if (v.length > 1 && v.startsWith("-")) {
701
+ throw new CeError(`ce: ${flag} requires a value but the next token ${JSON.stringify(v)} looks like a flag.`);
702
+ }
703
+ return [v, argv.slice(1)];
704
+ }
705
+ var SUMMARIZED = {
706
+ "-f": "dockerfile",
707
+ "--file": "dockerfile",
708
+ "--target": "target",
709
+ "--platform": "platform",
710
+ "--build-arg": "buildArgs",
711
+ "--label": "labels"
712
+ };
713
+ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
714
+ const args = [];
715
+ const rawCacheFrom = [];
716
+ const rawCacheTo = [];
717
+ const summary = {
718
+ tags: [],
719
+ cacheFrom: [],
720
+ cacheTo: [],
721
+ buildArgs: [],
722
+ labels: [],
723
+ dockerfile: "",
724
+ target: "",
725
+ platform: "",
726
+ context: ""
727
+ };
728
+ let templated = false;
729
+ let rest = [];
730
+ const addTags = (value, flag) => {
731
+ templated ||= hasPlaceholders(value);
732
+ const refs = rewriteTagValue(value, tagPrefix, vars);
733
+ for (const ref of refs)
734
+ args.push(`${flag}${ref}`);
735
+ summary.tags.push(...refs);
736
+ };
737
+ const summarize = (flag, value) => {
738
+ const field = SUMMARIZED[flag];
739
+ if (field === "buildArgs" || field === "labels")
740
+ summary[field].push(value);
741
+ else
742
+ summary[field] = value;
743
+ };
744
+ for (let i = 0;i < argv.length; i++) {
745
+ const arg = argv[i];
746
+ const [name, attached] = arg.startsWith("--") && arg.includes("=") ? splitAt(arg, "=") : [arg, undefined];
747
+ const next = () => consumeValue(argv.slice(++i), arg)[0];
748
+ if (arg === "--") {
749
+ rest = argv.slice(i);
750
+ if (rest.length > 1)
751
+ summary.context = rest.at(-1);
752
+ break;
753
+ } else if (arg === "-t")
754
+ addTags(next(), "-t");
755
+ else if (arg === "--tag")
756
+ addTags(next(), "--tag=");
757
+ else if (arg.startsWith("-t"))
758
+ addTags(arg.slice(2), "-t");
759
+ else if (name === "--tag")
760
+ addTags(attached, "--tag=");
761
+ else if (name === "--cache-from")
762
+ rawCacheFrom.push(attached ?? next());
763
+ else if (name === "--cache-to")
764
+ rawCacheTo.push(attached ?? next());
765
+ else if (name === "--cache") {
766
+ const v = attached ?? next();
767
+ rawCacheFrom.push(v);
768
+ rawCacheTo.push(v);
769
+ } else if (Object.hasOwn(SUMMARIZED, name)) {
770
+ const flag = name;
771
+ if (attached === undefined) {
772
+ const v = next();
773
+ args.push(arg, v);
774
+ summarize(flag, v);
775
+ } else {
776
+ args.push(arg);
777
+ summarize(flag, attached);
778
+ }
779
+ } else if (arg.startsWith("-f") && arg.length > 2) {
780
+ args.push(arg);
781
+ summary.dockerfile = arg.slice(2);
782
+ } else {
783
+ args.push(arg);
784
+ if (!arg.startsWith("-"))
785
+ summary.context = arg;
786
+ }
787
+ }
788
+ if (templated && !summary.tags.length) {
789
+ throw new CeError("ce: the guards in the -t templates left out every tag, so the image would have none");
790
+ }
791
+ const ownKeys = new Set(summary.labels.map((label) => label.split("=", 1)[0]));
792
+ const added = Object.entries(labels).filter(([key]) => !ownKeys.has(key)).map(([key, value]) => `${key}=${value}`);
793
+ args.unshift(...added.map((label) => `--label=${label}`));
794
+ summary.labels.unshift(...added);
795
+ if (rawCacheFrom.length || rawCacheTo.length)
796
+ cachePrefix = expandPrefix(cachePrefix, vars, "cache prefix");
797
+ const toNames = new Set(rawCacheTo.map(cacheScopeName));
798
+ for (const c of rawCacheFrom) {
799
+ const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
800
+ args.push(`--cache-from=${rewritten}`);
801
+ summary.cacheFrom.push(rewritten);
802
+ }
803
+ for (const c of rawCacheTo) {
804
+ const rewritten = normalizeCacheScope(c, cachePrefix);
805
+ args.push(`--cache-to=${rewritten}`);
806
+ summary.cacheTo.push(rewritten);
807
+ }
808
+ args.push(...rest);
809
+ return { args, summary };
810
+ }
811
+ function splitAt(value, sep) {
812
+ const i = value.indexOf(sep);
813
+ return [value.slice(0, i), value.slice(i + 1)];
814
+ }
815
+ function printBuildSummary(engine, args, summary) {
816
+ const command = findBuildCommand(engine, args);
817
+ if (!command || command.tagsOnly)
818
+ return;
819
+ const subcommand = command.name;
820
+ const out = ["[ce] build summary:", ` engine: ${engine}`, ` subcommand: ${subcommand}`];
821
+ const list = (title, items) => {
822
+ if (items.length)
823
+ out.push(` ${title}:`, ...items.map((item) => ` - ${item}`));
824
+ };
825
+ list("tags", summary.tags);
826
+ list("cache-from", summary.cacheFrom);
827
+ list("cache-to", summary.cacheTo);
828
+ if (summary.dockerfile)
829
+ out.push(` dockerfile: ${summary.dockerfile}`);
830
+ if (summary.target)
831
+ out.push(` target: ${summary.target}`);
832
+ if (summary.platform)
833
+ out.push(` platform: ${summary.platform}`);
834
+ list("build args", summary.buildArgs);
835
+ list("labels", summary.labels);
836
+ if (summary.context)
837
+ out.push(` context: ${summary.context}`);
838
+ io.stderr(`${out.join(`
839
+ `)}
840
+
841
+ `);
842
+ }
843
+
844
+ // src/oidc.ts
845
+ var REFRESH_WINDOW_S = 5 * 60;
846
+ var REQUEST_TIMEOUT_MS = 30000;
847
+ var decodeUtf8 = (bytes) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
848
+ async function forgejoOidcJwt(audience, requestUrl, requestToken) {
849
+ assertAbsoluteHttpUrl(requestUrl, "token request URL must be an absolute HTTP(S) URL");
850
+ let tokenUrl;
851
+ let headers;
852
+ try {
853
+ const url = new URL(requestUrl);
854
+ if (url.username || url.password)
855
+ throw new Error("credentials in URL");
856
+ url.searchParams.set("audience", audience);
857
+ tokenUrl = url.href;
858
+ headers = { Accept: "application/json", Authorization: `bearer ${requestToken}` };
859
+ new Headers(headers);
860
+ } catch {
861
+ throw new CeError("token request URL or headers are invalid");
862
+ }
863
+ let body;
864
+ try {
865
+ const response = await fetch(tokenUrl, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
866
+ if (!response.ok)
867
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
868
+ body = await response.arrayBuffer();
869
+ } catch (exc) {
870
+ throw new CeError(`token request failed: ${errorMessage(exc)}`);
871
+ }
872
+ let payload;
873
+ try {
874
+ payload = JSON.parse(decodeUtf8(body));
875
+ } catch {
876
+ throw new CeError("token response is not valid UTF-8 JSON");
877
+ }
878
+ if (!isObject(payload))
879
+ throw new CeError("token response is not a JSON object");
880
+ const jwt = payload.value;
881
+ if (typeof jwt !== "string" || !jwt.trim()) {
882
+ throw new CeError("token response has no non-empty string 'value'");
883
+ }
884
+ return jwt.trim();
885
+ }
886
+ function jwtExpiry(jwt) {
887
+ const parts = jwt.split(".");
888
+ if (parts.length !== 3)
889
+ throw new CeError("OIDC token is not a three-part JWT");
890
+ let payload;
891
+ try {
892
+ payload = JSON.parse(decodeUtf8(Buffer.from(parts[1], "base64url")));
893
+ } catch {
894
+ throw new CeError("OIDC token has an invalid JWT payload");
895
+ }
896
+ const expiry = isObject(payload) ? payload.exp : undefined;
897
+ if (typeof expiry !== "number")
898
+ throw new CeError("OIDC token has no numeric 'exp' claim");
899
+ return expiry;
900
+ }
901
+ function needsRefresh(expiry) {
902
+ return expiry <= Date.now() / 1000 + REFRESH_WINDOW_S;
903
+ }
904
+
905
+ // src/state.ts
906
+ import fs4 from "node:fs";
907
+ import { homedir } from "node:os";
908
+ import path3 from "node:path";
909
+
910
+ // src/lock.ts
911
+ import fs3 from "node:fs";
912
+ import { hostname } from "node:os";
913
+ import { setTimeout as setTimeout2 } from "node:timers/promises";
914
+ var STALE_AFTER_MS = 120000;
915
+ var POLL_MS = 50;
916
+ function snapshot(lockPath) {
917
+ try {
918
+ const { ino, mtimeMs } = fs3.statSync(lockPath);
919
+ return { ino, mtimeMs, owner: fs3.readFileSync(lockPath, "utf8").trim() };
920
+ } catch (exc) {
921
+ if (isErrno(exc, "ENOENT"))
922
+ return null;
923
+ throw exc;
924
+ }
925
+ }
926
+ function isStale({ mtimeMs, owner }) {
927
+ if (Date.now() - mtimeMs > STALE_AFTER_MS)
928
+ return true;
929
+ const match = /^(\d+)@(.*)$/u.exec(owner);
930
+ if (!match || match[2] !== hostname())
931
+ return false;
932
+ try {
933
+ process.kill(Number(match[1]), 0);
934
+ return false;
935
+ } catch (exc) {
936
+ return !isErrno(exc, "EPERM");
937
+ }
938
+ }
939
+ function breakIfStale(lockPath) {
940
+ const stale = snapshot(lockPath);
941
+ if (!stale || !isStale(stale))
942
+ return;
943
+ const guard = `${lockPath}.break`;
944
+ try {
945
+ fs3.writeFileSync(guard, "", { flag: "wx", mode: 384 });
946
+ } catch (exc) {
947
+ if (!isErrno(exc, "EEXIST"))
948
+ throw exc;
949
+ const held = snapshot(guard);
950
+ if (held && Date.now() - held.mtimeMs > STALE_AFTER_MS) {
951
+ throw new CeError(`stale lock guard ${JSON.stringify(guard)}: remove it if no ce is running`);
952
+ }
953
+ return;
954
+ }
955
+ try {
956
+ const current = snapshot(lockPath);
957
+ if (current && current.ino === stale.ino && current.mtimeMs === stale.mtimeMs && current.owner === stale.owner) {
958
+ fs3.rmSync(lockPath, { force: true });
959
+ }
960
+ } finally {
961
+ fs3.rmSync(guard, { force: true });
962
+ }
963
+ }
964
+ async function withFileLock(lockPath, fn) {
965
+ const owner = `${process.pid}@${hostname()}`;
966
+ let ino;
967
+ for (;; ) {
968
+ let fd;
969
+ try {
970
+ fd = fs3.openSync(lockPath, "wx", 384);
971
+ } catch (exc) {
972
+ if (!isErrno(exc, "EEXIST"))
973
+ throw exc;
974
+ breakIfStale(lockPath);
975
+ await setTimeout2(POLL_MS);
976
+ continue;
977
+ }
978
+ try {
979
+ fs3.writeSync(fd, `${owner}
980
+ `);
981
+ ino = fs3.fstatSync(fd).ino;
982
+ } finally {
983
+ fs3.closeSync(fd);
984
+ }
985
+ break;
986
+ }
987
+ try {
988
+ return await fn();
989
+ } finally {
990
+ const current = snapshot(lockPath);
991
+ if (current?.ino === ino && current.owner === owner)
992
+ fs3.rmSync(lockPath, { force: true });
993
+ }
994
+ }
995
+
996
+ // src/state.ts
997
+ var HELPER_SUFFIX = "ce-forgejo";
998
+ var STATE_FILE = "ce-forgejo-credentials.json";
999
+ function dockerConfigDir() {
1000
+ const configured = process.env.DOCKER_CONFIG;
1001
+ if (!configured)
1002
+ return path3.join(homedir(), ".docker");
1003
+ const expanded = configured === "~" || configured.startsWith("~/") ? path3.join(homedir(), configured.slice(1)) : configured;
1004
+ return path3.resolve(expanded);
1005
+ }
1006
+ function helperStatePath() {
1007
+ return path3.join(dockerConfigDir(), STATE_FILE);
1008
+ }
1009
+ async function withHelperState(fn) {
1010
+ const statePath = helperStatePath();
1011
+ fs4.mkdirSync(path3.dirname(statePath), { recursive: true, mode: 448 });
1012
+ return withFileLock(`${statePath}.pidlock`, async () => {
1013
+ const state = readJsonFile(statePath, { version: 1, registries: {} });
1014
+ if (!isObject(state) || !isObject(state.registries)) {
1015
+ throw new CeError("Forgejo credential-helper state is invalid");
1016
+ }
1017
+ const before = JSON.stringify(state);
1018
+ const result = await fn(state);
1019
+ if (JSON.stringify(state) !== before)
1020
+ writePrivateJson(statePath, state);
1021
+ return result;
1022
+ });
1023
+ }
1024
+ async function saveHelperState(registry, username, audience, authority, jwt) {
1025
+ const expiry = jwtExpiry(jwt);
1026
+ if (needsRefresh(expiry))
1027
+ throw new CeError("OIDC token expires within the five-minute refresh window");
1028
+ await withHelperState((state) => {
1029
+ state.registries[registryKey(registry)] = {
1030
+ audience,
1031
+ authority: [authority[0], authority[1]],
1032
+ expires_at: expiry,
1033
+ token: jwt,
1034
+ username
1035
+ };
1036
+ });
1037
+ }
1038
+ async function configureDockerCredentialHelper(registry, username, audience, authority, jwt) {
1039
+ await saveHelperState(registry, username, audience, authority, jwt);
1040
+ const configPath = path3.join(dockerConfigDir(), "config.json");
1041
+ const config = readJsonFile(configPath, {});
1042
+ if (!isObject(config))
1043
+ throw new CeError("Docker config is not a JSON object");
1044
+ config.credHelpers ??= {};
1045
+ const helpers = config.credHelpers;
1046
+ if (!isObject(helpers))
1047
+ throw new CeError("Docker config 'credHelpers' is not a JSON object");
1048
+ helpers[registry] = HELPER_SUFFIX;
1049
+ const auths = config.auths;
1050
+ if (isObject(auths)) {
1051
+ for (const key of Object.keys(auths)) {
1052
+ if (registryKey(key) === registryKey(registry))
1053
+ delete auths[key];
1054
+ }
1055
+ }
1056
+ writePrivateJson(configPath, config);
1057
+ }
1058
+
1059
+ // src/credential-helper.ts
1060
+ async function helperGet(registry) {
1061
+ const key = registryKey(registry);
1062
+ return withHelperState(async (state) => {
1063
+ const entry = state.registries[key];
1064
+ if (!isObject(entry)) {
1065
+ throw new CeError(`no Forgejo OIDC credentials configured for ${JSON.stringify(registry)}`);
1066
+ }
1067
+ const { username, token, expires_at: expiry } = entry;
1068
+ if (typeof username !== "string" || !username) {
1069
+ throw new CeError("Forgejo credential-helper username is missing");
1070
+ }
1071
+ if (typeof token !== "string" || typeof expiry !== "number") {
1072
+ throw new CeError("Forgejo credential-helper token state is invalid");
1073
+ }
1074
+ if (!needsRefresh(expiry))
1075
+ return { Username: username, Secret: token };
1076
+ const { audience, authority } = entry;
1077
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
1078
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
1079
+ if (typeof audience !== "string" || !audience) {
1080
+ throw new CeError("Forgejo credential-helper audience is missing");
1081
+ }
1082
+ if (!(requestUrl && requestToken)) {
1083
+ throw new CeError("OIDC refresh requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN");
1084
+ }
1085
+ const [host, port] = urlAuthority(requestUrl);
1086
+ if (!Array.isArray(authority) || authority.length !== 2 || authority[0] !== host || authority[1] !== port) {
1087
+ throw new CeError("OIDC token-request host does not match the registry");
1088
+ }
1089
+ const refreshed = await forgejoOidcJwt(audience, requestUrl, requestToken);
1090
+ const refreshedExpiry = jwtExpiry(refreshed);
1091
+ if (needsRefresh(refreshedExpiry)) {
1092
+ throw new CeError("refreshed OIDC token expires within the five-minute window");
1093
+ }
1094
+ entry.token = refreshed;
1095
+ entry.expires_at = refreshedExpiry;
1096
+ return { Username: username, Secret: refreshed };
1097
+ });
1098
+ }
1099
+ async function helperStore(input) {
1100
+ const payload = JSON.parse(input);
1101
+ if (!isObject(payload))
1102
+ throw new CeError("credential-helper store payload is invalid");
1103
+ const { ServerURL: registry, Username: username, Secret: secret } = payload;
1104
+ const filled = (value) => typeof value === "string" && value !== "";
1105
+ if (!(filled(registry) && filled(username) && filled(secret))) {
1106
+ throw new CeError("credential-helper store fields are incomplete");
1107
+ }
1108
+ const expiry = jwtExpiry(secret);
1109
+ await withHelperState((state) => {
1110
+ const entry = state.registries[registryKey(registry)] ??= {};
1111
+ Object.assign(entry, { expires_at: expiry, token: secret, username });
1112
+ });
1113
+ }
1114
+ async function credentialHelper(argv) {
1115
+ const [command] = argv;
1116
+ if (argv.length !== 1 || !["get", "store", "erase", "list"].includes(command)) {
1117
+ logError("ce: credential-helper requires get|store|erase|list");
1118
+ return 1;
1119
+ }
1120
+ try {
1121
+ if (command === "get") {
1122
+ io.stdout(`${JSON.stringify(await helperGet(io.stdin().trim()))}
1123
+ `);
1124
+ } else if (command === "store") {
1125
+ await helperStore(io.stdin());
1126
+ } else if (command === "erase") {
1127
+ const key = registryKey(io.stdin().trim());
1128
+ await withHelperState((state) => void delete state.registries[key]);
1129
+ } else {
1130
+ const entries = await withHelperState((state) => Object.fromEntries(Object.entries(state.registries).filter(([, entry]) => isObject(entry)).map(([key, entry]) => [key, entry.username ?? ""])));
1131
+ io.stdout(`${JSON.stringify(entries)}
1132
+ `);
1133
+ }
1134
+ return 0;
1135
+ } catch (exc) {
1136
+ if (!(exc instanceof CeError || exc instanceof SyntaxError || isErrno(exc)))
1137
+ throw exc;
1138
+ logError(`ce: Forgejo credential helper: ${errorMessage(exc)}`);
1139
+ return 1;
1140
+ }
1141
+ }
1142
+
1143
+ // src/engine.ts
1144
+ var isEngine = (value) => ["docker", "podman", "buildah"].includes(value);
1145
+ var WAIT_ATTEMPTS = 30;
1146
+ async function waitForEngine(engine) {
1147
+ if (engine === "buildah")
1148
+ return true;
1149
+ for (let attempt = 1;attempt <= WAIT_ATTEMPTS; attempt++) {
1150
+ if (system.run([engine, "info"]).status === 0)
1151
+ return true;
1152
+ logError(`waiting for ${engine}... (${attempt}/${WAIT_ATTEMPTS})`);
1153
+ await system.sleep(1000);
1154
+ }
1155
+ logError(`no engine available: ${engine}`);
1156
+ return false;
1157
+ }
1158
+ function envFlag(name) {
1159
+ const value = (process.env[name] ?? "").toLowerCase();
1160
+ if (["", "0", "false", "no"].includes(value))
1161
+ return false;
1162
+ if (["1", "true", "yes"].includes(value))
1163
+ return true;
1164
+ throw new CeError(`ce: ${name} must be true or false, not ${JSON.stringify(process.env[name])}`);
1165
+ }
1166
+ async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1167
+ const command = findBuildCommand(engine, argv);
1168
+ let args = argv;
1169
+ let summary;
1170
+ if (command) {
1171
+ const vars = new CiMetadata;
1172
+ const labels = !command.tagsOnly && (ociLabels ?? envFlag("OCI_LABELS")) ? vars.labels() : {};
1173
+ const parsed = parseBuildArgs(argv.slice(command.start), tagPrefix, cachePrefix, { vars, labels });
1174
+ args = [...argv.slice(0, command.start), ...parsed.args];
1175
+ summary = parsed.summary;
1176
+ }
1177
+ ensureHelperOnPath();
1178
+ if (!system.which(engine)) {
1179
+ logError(`Engine not found: ${engine}`);
1180
+ return 1;
1181
+ }
1182
+ if (!await waitForEngine(engine))
1183
+ return 1;
1184
+ if (summary)
1185
+ printBuildSummary(engine, args, summary);
1186
+ if (engine === "docker")
1187
+ return system.exec("docker", ["docker", ...args]);
1188
+ if (system.which("gosu")) {
1189
+ const user = process.env.USER ?? "";
1190
+ if (!user) {
1191
+ logError("ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.");
1192
+ return 1;
1193
+ }
1194
+ return system.exec("gosu", ["gosu", user, engine, ...args]);
1195
+ }
1196
+ return system.exec(engine, [engine, ...args]);
1197
+ }
1198
+
1199
+ // src/login.ts
1200
+ var ENGINES = ["docker", "podman", "buildah"];
1201
+ function registryGroups() {
1202
+ const prefix = "REGISTRY_HOST__";
1203
+ const suffixed = Object.keys(process.env).filter((name) => name.startsWith(prefix) && name.length > prefix.length).sort().map((name) => name.slice(prefix.length));
1204
+ return [...process.env.REGISTRY_HOST ? [""] : [], ...suffixed];
1205
+ }
1206
+ function oidcConfig() {
1207
+ const audience = process.env.AUD ?? "";
1208
+ if (!audience)
1209
+ return null;
1210
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
1211
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
1212
+ if (!(requestUrl && requestToken)) {
1213
+ return "Forgejo OIDC login config is incomplete: AUD requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN";
1214
+ }
1215
+ try {
1216
+ assertAbsoluteHttpUrl(requestUrl, "must be an absolute HTTP(S) URL");
1217
+ return { audience, requestUrl, requestToken, authority: urlAuthority(requestUrl) };
1218
+ } catch (exc) {
1219
+ if (!(exc instanceof CeError))
1220
+ throw exc;
1221
+ return `Invalid ACTIONS_ID_TOKEN_REQUEST_URL: ${exc.message}`;
1222
+ }
1223
+ }
1224
+ function loginWithEngines(target, password) {
1225
+ for (const engine of ENGINES) {
1226
+ if (!system.which(engine))
1227
+ continue;
1228
+ let result;
1229
+ try {
1230
+ result = system.run([engine, "login", "-u", target.user, "--password-stdin", target.registry], `${password}
1231
+ `);
1232
+ } catch (exc) {
1233
+ if (isErrno(exc, "ENOENT"))
1234
+ continue;
1235
+ throw exc;
1236
+ }
1237
+ if (result.status === 0)
1238
+ return engine;
1239
+ logError(`${engine} login failed for '${target.label}' (${target.registry}):`);
1240
+ io.stderr(result.stderr);
1241
+ }
1242
+ return null;
1243
+ }
1244
+ async function login() {
1245
+ ensureHelperOnPath();
1246
+ const groups = registryGroups();
1247
+ if (groups.length === 0) {
1248
+ log("Skipping login: no REGISTRY_HOST env vars set");
1249
+ return 0;
1250
+ }
1251
+ const oidc = oidcConfig();
1252
+ if (typeof oidc === "string") {
1253
+ logError(oidc);
1254
+ return 1;
1255
+ }
1256
+ const targets = [];
1257
+ let failed = false;
1258
+ for (const group of groups) {
1259
+ const suffix = group ? `__${group}` : "";
1260
+ const host = process.env[`REGISTRY_HOST${suffix}`] ?? "";
1261
+ const user = process.env[`REGISTRY_USER${suffix}`] ?? "";
1262
+ const password = process.env[`REGISTRY_PASSWORD${suffix}`] ?? "";
1263
+ const label = group || "default";
1264
+ const incomplete = `Login config for '${label}' is incomplete: host/user/password not all set`;
1265
+ if (!(host && user)) {
1266
+ logError(incomplete);
1267
+ failed = true;
1268
+ continue;
1269
+ }
1270
+ for (const rawRegistry of host.split(/\s+/u).filter(Boolean)) {
1271
+ let authority;
1272
+ let registry;
1273
+ try {
1274
+ authority = urlAuthority(rawRegistry);
1275
+ registry = registryServer(rawRegistry);
1276
+ } catch (exc) {
1277
+ if (!(exc instanceof CeError))
1278
+ throw exc;
1279
+ logError(`Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`);
1280
+ failed = true;
1281
+ continue;
1282
+ }
1283
+ const useOidc = oidc !== null && sameAuthority(authority, oidc.authority);
1284
+ if (!useOidc && !password) {
1285
+ logError(incomplete);
1286
+ failed = true;
1287
+ continue;
1288
+ }
1289
+ targets.push({ registry, label, user, password, useOidc });
1290
+ }
1291
+ }
1292
+ let jwt = null;
1293
+ if (oidc && targets.some((target) => target.useOidc)) {
1294
+ try {
1295
+ jwt = await forgejoOidcJwt(oidc.audience, oidc.requestUrl, oidc.requestToken);
1296
+ } catch (exc) {
1297
+ if (!(exc instanceof CeError))
1298
+ throw exc;
1299
+ logError(`Failed to fetch Forgejo OIDC JWT: ${exc.message}`);
1300
+ return 1;
1301
+ }
1302
+ }
1303
+ for (const target of targets) {
1304
+ const { registry, label } = target;
1305
+ const password = target.useOidc ? jwt : target.password;
1306
+ if (password === null) {
1307
+ logError(`Forgejo OIDC JWT unavailable for '${label}' (${registry})`);
1308
+ failed = true;
1309
+ continue;
1310
+ }
1311
+ log(`Logging in to ${registry} (${label})...`);
1312
+ const engine = loginWithEngines(target, password);
1313
+ if (!engine) {
1314
+ logError(`Login failed for '${label}' (${registry})`);
1315
+ failed = true;
1316
+ continue;
1317
+ }
1318
+ log(`Logged in to ${registry} (${label}) via ${engine} ` + `(and all engines at or below it: docker < podman < buildah share credentials)`);
1319
+ if (target.useOidc && oidc && jwt) {
1320
+ try {
1321
+ await configureDockerCredentialHelper(registry, target.user, oidc.audience, oidc.authority, jwt);
1322
+ } catch (exc) {
1323
+ if (!(exc instanceof CeError || isErrno(exc)))
1324
+ throw exc;
1325
+ logError(`Failed to configure Forgejo credential helper for '${label}' (${registry}): ${errorMessage(exc)}`);
1326
+ failed = true;
1327
+ }
1328
+ }
1329
+ }
1330
+ return failed ? 1 : 0;
1331
+ }
1332
+
1333
+ // src/cli.ts
1334
+ function helpText() {
1335
+ return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
1336
+
1337
+ Options:
1338
+ --tag-prefix=<prefix>
1339
+ Prepend <prefix> to every tag passed via -t/--tag.
1340
+ A trailing "-" is appended automatically if the prefix
1341
+ does not already end with "-", "_", or ".".
1342
+ Also available via TAG_PREFIX environment variable.
1343
+ --cache-prefix=<prefix>
1344
+ Prepend <prefix> to the gha cache scopes in --cache-to,
1345
+ and in --cache-from where they match one; other
1346
+ --cache-from scopes stay shared. Defaults to the tag prefix
1347
+ (TAG_PREFIX); set to "" to keep cache scopes shared.
1348
+ Also available via CACHE_PREFIX environment variable.
1349
+ --cache=<spec> Shorthand for passing <spec> to both --cache-from and
1350
+ --cache-to. Supports the same forms as --cache-from/--cache-to.
1351
+ --oci-labels Add org.opencontainers.image.* labels (source, revision,
1352
+ version, title, description) from the Forgejo Actions
1353
+ run to builds. A --label with the same key wins.
1354
+ Also available via OCI_LABELS=true.
1355
+
1356
+ Placeholders, in build tags and in the tag and cache prefixes:
1357
+ {name} The value of name; an error if it has none in this run.
1358
+ {?name} Expands to nothing; leaves the tag out unless name has
1359
+ a value. Not allowed in prefixes.
1360
+ {image} Registry host and repository, lowercased
1361
+ {ref} Branch name, pr-<number>, or tag name
1362
+ {branch} {pr} {tag}
1363
+ Set only when building that kind of ref
1364
+ {default} Set only when building the default branch
1365
+ {revision} {sha} The commit, in full and as 7 characters
1366
+ {version} {major} {minor} {patch}
1367
+ From a SemVer tag such as v1.2.3 (v optional)
1368
+ {stable} Set only for a SemVer tag without a pre-release
1369
+ {env.NAME} The environment variable NAME
1370
+ Values are made safe for a tag ("ci/x" becomes "ci-x"). Quote templates
1371
+ in single quotes. Example:
1372
+ -t '{image}:{ref},{?default}latest'
1373
+
1374
+ Commands:
1375
+ login Log in to all available engines
1376
+
1377
+ Engines:
1378
+ docker [args] Passthrough to docker
1379
+ podman [args] Passthrough to podman (via gosu if available)
1380
+ buildah [args] Passthrough to buildah (via gosu if available)
1381
+
1382
+ Environment variables (used by login):
1383
+ Each registry is a "group". The default group uses the bare variables
1384
+ below; additional registries use a __<name> suffix that ties the three
1385
+ variables together.
1386
+ REGISTRY_USER[__<name>] username
1387
+ REGISTRY_PASSWORD[__<name>] password
1388
+ REGISTRY_HOST[__<name>] registry host(s) (https:// stripped);
1389
+ whitespace-separated list logs in to each
1390
+ with the same creds, e.g. "docker.io dhi.io"
1391
+ AUD Forgejo Authorized Integration audience.
1392
+ When set, the registry matching the Forgejo
1393
+ Actions token-request host uses a short-lived
1394
+ OIDC JWT instead of REGISTRY_PASSWORD.
1395
+ Docker uses a per-host credential helper that
1396
+ refreshes the JWT five minutes before expiry.
1397
+ Requires ACTIONS_ID_TOKEN_REQUEST_URL and
1398
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN from a job
1399
+ with enable-openid-connect: true.
1400
+ Defining REGISTRY_HOST__<name> adds a group; its creds come from
1401
+ REGISTRY_USER__<name> and REGISTRY_PASSWORD__<name>. Example:
1402
+ REGISTRY_HOST__DOCKER="docker.io dhi.io" + REGISTRY_USER__DOCKER +
1403
+ REGISTRY_PASSWORD__DOCKER logs in to both Docker Hub and dhi.io with one
1404
+ set of creds, alongside the default registry. The bare (unsuffixed)
1405
+ variables are the default group — no suffix needed for the primary
1406
+ registry.
1407
+ `;
1408
+ }
1409
+ async function main(argv) {
1410
+ let ociLabels;
1411
+ let tagPrefix = process.env.TAG_PREFIX ?? "";
1412
+ let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
1413
+ for (;; ) {
1414
+ const a = argv[0];
1415
+ if (a === "--tag-prefix")
1416
+ [tagPrefix, argv] = consumeValue(argv.slice(1), a);
1417
+ else if (a?.startsWith("--tag-prefix="))
1418
+ [tagPrefix, argv] = [a.slice("--tag-prefix=".length), argv.slice(1)];
1419
+ else if (a === "--cache-prefix")
1420
+ [cachePrefix, argv] = consumeValue(argv.slice(1), a);
1421
+ else if (a?.startsWith("--cache-prefix="))
1422
+ [cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
1423
+ else if (a === "--oci-labels")
1424
+ [ociLabels, argv] = [true, argv.slice(1)];
1425
+ else
1426
+ break;
1427
+ }
1428
+ const [command, ...rest] = argv;
1429
+ if (command === undefined) {
1430
+ io.stderr(helpText());
1431
+ return 0;
1432
+ }
1433
+ if (command === "login")
1434
+ return login();
1435
+ if (command === "credential-helper")
1436
+ return credentialHelper(rest);
1437
+ if (isEngine(command))
1438
+ return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
1439
+ io.stderr(helpText());
1440
+ return 1;
1441
+ }
1442
+ async function entry(argv) {
1443
+ try {
1444
+ process.exit(await main(argv));
1445
+ } catch (exc) {
1446
+ if (!(exc instanceof CeError))
1447
+ throw exc;
1448
+ logError(exc.message);
1449
+ process.exit(2);
1450
+ }
1451
+ }
1452
+ export {
1453
+ entry,
1454
+ helpText,
1455
+ main
1456
+ };