@izykitten/ce 0.0.2 → 0.0.3-dev.1

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.
Files changed (4) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +58 -1
  3. package/dist/ce.js +698 -167
  4. package/package.json +3 -3
package/LICENSE ADDED
@@ -0,0 +1,11 @@
1
+ Copyright (c) 2026 izy
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
+
11
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -24,9 +24,66 @@ In a Forgejo workflow:
24
24
  REGISTRY_HOST: ${{ forgejo.server_url }}
25
25
  run: bunx @izykitten/ce login
26
26
  - name: Container Build & Push
27
- run: bunx @izykitten/ce docker buildx build --push -t ${{ forgejo.server_url }}/${{ forgejo.repository }}:${{ forgejo.ref_name }} .
27
+ run: bunx @izykitten/ce docker buildx build --push -t '{image}:{ref},{?default}latest' .
28
28
  ```
29
29
 
30
+ Only build commands are rewritten: `build`, `buildx build` and `image build` for Docker and Podman, `build` and `bud` for Buildah, and the tags of `docker buildx imagetools create`. Everything else, such as `docker run -t`, reaches the engine unchanged.
31
+
32
+ ## Tags from the Forgejo run
33
+
34
+ Tags and the `TAG_PREFIX`/`CACHE_PREFIX` values can use placeholders, filled from the Forgejo Actions environment and its event file. ce makes no API calls and does not look at the checkout.
35
+
36
+ | Placeholder | Value |
37
+ |---|---|
38
+ | `{image}` | registry host and repository, lowercased, e.g. `code.izy.sh/library/gateway` |
39
+ | `{ref}` | the branch name, `pr-<number>` for a pull request, or the tag name |
40
+ | `{branch}`, `{pr}`, `{tag}` | the branch, PR number or tag, set only when building that kind of ref |
41
+ | `{default}` | set only when building the default branch, never for a pull request or tag |
42
+ | `{revision}`, `{sha}` | the commit, in full and as its first 7 characters |
43
+ | `{version}`, `{major}`, `{minor}`, `{patch}` | from a SemVer tag such as `v1.2.3` or `1.2.3-rc.1` |
44
+ | `{stable}` | set only for a SemVer tag without a pre-release |
45
+ | `{env.NAME}` | the environment variable `NAME`, e.g. a value from an earlier step |
46
+
47
+ `{name}` must have a value (an empty variable has none): if it has none in this run, ce fails with exit 2 before anything runs. To leave a tag out instead, guard it with `{?name}`, which expands to nothing and keeps the tag only when `name` has a value. Every guard in a tag has to pass, and a tag's other values are only needed when it is kept. Names are checked in every tag, so a typo fails even in one a guard leaves out. If the guards leave out every tag, ce fails rather than build an untagged image.
48
+
49
+ Values are made safe for a tag: any ASCII character a tag cannot have becomes `-`, so branch `ci/modernize-build` gives `ci-modernize-build`; a non-ASCII character is an error. A value never adds a tag or changes the image name. Prefixes can use `{name}` but not guards.
50
+
51
+ Guards decide from the run's metadata, so they need it too: outside a Forgejo Actions run, `{?default}` fails like `{default}` would. Only `{env.NAME}` works everywhere. To tell whether a branch is the default one, ce reads `repository.default_branch` from the event file, or `DEFAULT_BRANCH` when the event has none, as on a scheduled run; set `DEFAULT_BRANCH: main` in such a workflow. Pull requests and tags are never the default branch, so they need neither.
52
+
53
+ Put templates in single quotes: `?` is a glob character in zsh.
54
+
55
+ A branch, PR and default-branch build:
56
+
57
+ ```yaml
58
+ - name: Container Build & Push
59
+ env:
60
+ CACHE_PREFIX: '{ref}'
61
+ CADDY_VERSION: ${{ steps.caddy.outputs.version }}
62
+ run: >
63
+ bunx @izykitten/ce docker buildx build --push --cache type=gha,mode=max
64
+ -t '{image}:{ref},{?default}latest,{?default}caddy-{env.CADDY_VERSION}' .
65
+ ```
66
+
67
+ With `CACHE_PREFIX='{ref}'`, each branch and pull request gets a gha cache of its own, so a new one starts cold. To also read the default branch's cache, add it as a `--cache-from`. ce only prefixes a `--cache-from` whose scope matches a `--cache-to`, so this one is passed on unchanged and only read:
68
+
69
+ ```sh
70
+ --cache type=gha,mode=max --cache-from type=gha,scope=main
71
+ ```
72
+
73
+ PR 3 then reads `pr-3` and `main` and writes only `pr-3`; main reads its own cache a second time. With a scope of its own, such as `--cache type=gha,mode=max,scope=js`, the cache to read is `main-js`. This only picks what is read: whether it hits depends on what main last wrote.
74
+
75
+ A release on a SemVer tag, with the version aliases only for stable releases:
76
+
77
+ ```sh
78
+ -t '{image}:{ref},{?version}{version},{?stable}{major}.{minor},{?stable}{major},sha-{sha}'
79
+ ```
80
+
81
+ `{stable}` does not mean newest: rebuilding an older release moves its aliases back to it.
82
+
83
+ ### OCI labels
84
+
85
+ With `OCI_LABELS=true`, or `--oci-labels` before the engine (`ce --oci-labels docker buildx build …`, like the prefix options), builds get `org.opencontainers.image.source`, `revision`, `version`, `title` and `description` labels from the run. Labels the run cannot tell are left out. They override a Dockerfile `LABEL` with the same key, and a `--label` you pass wins over them. `created` is not set, so the labels do not change the image on every rebuild.
86
+
30
87
  ## The credential helper
31
88
 
32
89
  With `AUD` set (and `enable-openid-connect: true` on the job), `ce login` logs in to the Forgejo registry with a short-lived OIDC token. It then writes `"credHelpers": {"<host>": "ce-forgejo"}` to the Docker config. From then on, Docker gets credentials for that host by running `docker-credential-ce-forgejo get`, which returns the saved token or fetches a fresh one when it is within five minutes of expiry. That is what lets a long build still push.
package/dist/ce.js CHANGED
@@ -1,3 +1,116 @@
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
+
1
114
  // src/errors.ts
2
115
  class CeError extends Error {
3
116
  name = "CeError";
@@ -12,15 +125,275 @@ function errorMessage(exc) {
12
125
  return exc instanceof Error ? exc.message : String(exc);
13
126
  }
14
127
 
15
- // src/system.ts
16
- import { spawnSync } from "node:child_process";
128
+ // src/json.ts
129
+ import { randomUUID } from "node:crypto";
17
130
  import fs from "node:fs";
18
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.#ciEnv("EVENT_PATH") ? this.#payload().repository : undefined;
364
+ const fromEvent = isObject(repository) ? repository.default_branch : undefined;
365
+ const branch = typeof fromEvent === "string" && fromEvent || nonEmpty(this.#env.DEFAULT_BRANCH);
366
+ if (!branch) {
367
+ throw new CeError("ce: {default} needs repository.default_branch in the event file or DEFAULT_BRANCH, and neither is set");
368
+ }
369
+ return branch;
370
+ }
371
+ #payload() {
372
+ if (this.#event)
373
+ return this.#event;
374
+ const file = this.#require("EVENT_PATH", "reading the event file");
375
+ let payload;
376
+ try {
377
+ payload = readJsonFile(file, undefined);
378
+ } catch (exc) {
379
+ throw new CeError(`ce: ${errorMessage(exc)}`);
380
+ }
381
+ if (!isObject(payload))
382
+ throw new CeError(`ce: the event file ${JSON.stringify(file)} is missing or not a JSON object`);
383
+ this.#event = payload;
384
+ return payload;
385
+ }
386
+ }
387
+
388
+ // src/system.ts
389
+ import { spawnSync } from "node:child_process";
390
+ import fs2 from "node:fs";
391
+ import path2 from "node:path";
19
392
  import { setTimeout } from "node:timers/promises";
20
393
  var io = {
21
- stdout: (text) => void fs.writeSync(1, text),
22
- stderr: (text) => void fs.writeSync(2, text),
23
- stdin: () => fs.readFileSync(0, "utf8"),
394
+ stdout: (text) => void fs2.writeSync(1, text),
395
+ stderr: (text) => void fs2.writeSync(2, text),
396
+ stdin: () => fs2.readFileSync(0, "utf8"),
24
397
  argv0: () => process.argv[1] ?? "ce"
25
398
  };
26
399
  function log(line) {
@@ -35,11 +408,11 @@ function childEnv() {
35
408
  return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
36
409
  }
37
410
  function which(command) {
38
- const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, command));
411
+ const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean).map((dir) => path2.join(dir, command));
39
412
  for (const candidate of candidates) {
40
413
  try {
41
- fs.accessSync(candidate, fs.constants.X_OK);
42
- if (fs.statSync(candidate).isFile())
414
+ fs2.accessSync(candidate, fs2.constants.X_OK);
415
+ if (fs2.statSync(candidate).isFile())
43
416
  return candidate;
44
417
  } catch {}
45
418
  }
@@ -71,20 +444,81 @@ var system = {
71
444
  }
72
445
  };
73
446
  function binDir() {
74
- return fs.realpathSync(path.join(import.meta.dirname, "..", "bin"));
447
+ return fs2.realpathSync(path2.join(import.meta.dirname, "..", "bin"));
75
448
  }
76
449
  function ensureHelperOnPath() {
77
450
  const dir = binDir();
78
- const parts = (process.env.PATH ?? "").split(path.delimiter);
451
+ const parts = (process.env.PATH ?? "").split(path2.delimiter);
79
452
  if (!parts.includes(dir))
80
- process.env.PATH = [dir, ...parts].join(path.delimiter);
453
+ process.env.PATH = [dir, ...parts].join(path2.delimiter);
454
+ }
455
+
456
+ // src/template.ts
457
+ var PLACEHOLDER = /\{([^{}]*)\}/gu;
458
+ function hasPlaceholders(value) {
459
+ return value.includes("{") || value.includes("}");
460
+ }
461
+ function parse(template, vars) {
462
+ const parts = [];
463
+ const text = (value) => {
464
+ if (hasPlaceholders(value))
465
+ throw new CeError(`ce: unmatched brace in ${JSON.stringify(template)}`);
466
+ if (value)
467
+ parts.push({ text: value });
468
+ };
469
+ let last = 0;
470
+ for (const match of template.matchAll(PLACEHOLDER)) {
471
+ text(template.slice(last, match.index));
472
+ last = match.index + match[0].length;
473
+ const guard = match[1].startsWith("?");
474
+ const name = guard ? match[1].slice(1) : match[1];
475
+ if (!vars.known(name))
476
+ throw new CeError(`ce: unknown placeholder ${match[0]} in ${JSON.stringify(template)}`);
477
+ parts.push({ name, guard });
478
+ }
479
+ text(template.slice(last));
480
+ return parts;
481
+ }
482
+ function expand(template, vars, encode) {
483
+ const parts = parse(template, vars);
484
+ for (const part of parts) {
485
+ if ("name" in part && part.guard && vars.get(part.name) === undefined)
486
+ return null;
487
+ }
488
+ let out = "";
489
+ for (const part of parts) {
490
+ if ("text" in part)
491
+ out += part.text;
492
+ else if (!part.guard) {
493
+ const value = vars.get(part.name);
494
+ if (value === undefined) {
495
+ 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}}.`);
496
+ }
497
+ out += encode(value);
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+ function checkValue(template, vars, what) {
503
+ if (parse(template, vars).some((part) => ("guard" in part) && part.guard)) {
504
+ throw new CeError(`ce: ${what} ${JSON.stringify(template)} cannot use {?guards}; they only apply to tags`);
505
+ }
506
+ }
507
+ function expandValue(template, vars, encode, what) {
508
+ checkValue(template, vars, what);
509
+ for (const part of parse(template, vars)) {
510
+ if ("name" in part && vars.get(part.name) === undefined) {
511
+ throw new CeError(`ce: {${part.name}} has no value here, in the ${what} ${JSON.stringify(template)}`);
512
+ }
513
+ }
514
+ return expand(template, vars, encode);
81
515
  }
82
516
 
83
517
  // src/tags.ts
84
518
  var INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
85
519
  var INVALID_SCOPE_CHARS = /[^A-Za-z0-9_.:/\-]/gu;
86
- var RENOVATE_RE = /^(.*)\/renovate_(.*)$/u;
87
- var GHA_RE = /^type=gha(,mode=max)?(?:,scope=(.*))?$/u;
520
+ var VALID_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u;
521
+ var HOST_PORT = /^([^/:]+):(?:\d|\{[^{}]*\})+\//u;
88
522
  var SEPARATORS = "-_.";
89
523
  function sanitizeChars(value, charRegex) {
90
524
  return value.replace(charRegex, "_");
@@ -105,62 +539,163 @@ function splitOnce(value, sep) {
105
539
  const i = value.indexOf(sep);
106
540
  return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
107
541
  }
108
- function sanitizeTag(value) {
109
- assertAscii("tag", value);
110
- value = stripProto(value);
111
- let out = value;
112
- if (value.includes(":")) {
113
- const [repo, tag] = splitOnce(value, ":");
114
- if (tag.includes("/")) {
115
- const [first, rest] = splitOnce(tag, "/");
116
- out = `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
117
- } else {
118
- out = `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
119
- }
542
+ function splitRef(value) {
543
+ const host = HOST_PORT.exec(value);
544
+ const isHost = host && (host[1].includes(".") || host[1] === "localhost" || value.includes(":", host[0].length));
545
+ const offset = isHost ? host[0].length : 0;
546
+ const i = value.indexOf(":", offset);
547
+ return i < 0 ? null : [value.slice(0, i), value.slice(i + 1)];
548
+ }
549
+ function assertValidTag(repo, tag) {
550
+ if (!VALID_TAG.test(tag)) {
551
+ 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 "-".`);
552
+ }
553
+ return `${repo}:${tag}`;
554
+ }
555
+ function encodeTagValue(value) {
556
+ assertAscii("placeholder value", value);
557
+ return value.replace(INVALID_TAG_CHARS, "-");
558
+ }
559
+ function encodeRepoValue(value) {
560
+ assertAscii("placeholder value", value);
561
+ if (/[^A-Za-z0-9._:/-]/u.test(value))
562
+ throw new CeError(`ce: ${JSON.stringify(value)} cannot go in an image name`);
563
+ return value;
564
+ }
565
+ function expandImage(template, vars) {
566
+ const image = expandValue(template, vars, encodeRepoValue, "image name");
567
+ if (!/^(?:[^/:]+:\d+\/)?[^:]*$/u.test(image)) {
568
+ throw new CeError(`ce: image name ${JSON.stringify(image)} from ${JSON.stringify(template)} has a ":" that is not a registry port`);
569
+ }
570
+ return image;
571
+ }
572
+ function expandPrefix(prefix, vars, what) {
573
+ return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what) : prefix;
574
+ }
575
+ function literalRef(repo, tag) {
576
+ if (tag.includes("/")) {
577
+ const [first, rest] = splitOnce(tag, "/");
578
+ return `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
120
579
  }
121
- const m = RENOVATE_RE.exec(out);
122
- return m ? `${m[1]}/renovate:${m[2]}` : out;
580
+ return `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
123
581
  }
124
582
  function computeSafePrefix(prefix) {
125
583
  assertAscii("tag prefix", prefix);
126
584
  const p = sanitizeChars(prefix, INVALID_TAG_CHARS);
127
585
  return p && !SEPARATORS.includes(p.at(-1)) ? `${p}-` : p;
128
586
  }
129
- function rewriteTagValue(value, tagPrefix) {
587
+ function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
588
+ assertAscii("tag", value);
130
589
  const v = stripProto(value);
131
- if (!v.includes(":")) {
132
- assertAscii("tag", v);
133
- return [v];
590
+ if (!v)
591
+ throw new CeError("ce: empty -t/--tag value");
592
+ const [repoTemplate, tagList] = splitRef(v) ?? [v, undefined];
593
+ if (hasPlaceholders(repoTemplate))
594
+ checkValue(repoTemplate, vars, "image name");
595
+ if (hasPlaceholders(tagPrefix))
596
+ checkValue(tagPrefix, vars, "tag prefix");
597
+ let repo;
598
+ const expandRepo = () => repo ??= hasPlaceholders(repoTemplate) ? expandImage(repoTemplate, vars) : repoTemplate;
599
+ if (tagList === undefined)
600
+ return [expandRepo()];
601
+ const tags = tagList.split(/[,\s]+/u).filter(Boolean);
602
+ if (!tags.length)
603
+ throw new CeError(`ce: no tag after the ":" in ${JSON.stringify(value)}`);
604
+ let safePrefix;
605
+ const refs = [];
606
+ for (const tag of tags) {
607
+ const expanded = hasPlaceholders(tag) ? expand(tag, vars, encodeTagValue) : tag;
608
+ if (expanded === null)
609
+ continue;
610
+ safePrefix ??= computeSafePrefix(expandPrefix(tagPrefix, vars, "tag prefix"));
611
+ if (hasPlaceholders(tag)) {
612
+ refs.push(assertValidTag(expandRepo(), `${safePrefix}${sanitizeChars(expanded, INVALID_TAG_CHARS)}`));
613
+ } else {
614
+ const ref = literalRef(expandRepo(), `${safePrefix}${tag}`);
615
+ const i = ref.lastIndexOf(":");
616
+ refs.push(assertValidTag(ref.slice(0, i), ref.slice(i + 1)));
617
+ }
134
618
  }
135
- const [repo, tags] = splitOnce(v, ":");
136
- const safePrefix = computeSafePrefix(tagPrefix);
137
- return tags.split(/[,\s]+/u).filter(Boolean).map((tag) => sanitizeTag(`${repo}:${safePrefix}${tag}`)).filter(Boolean);
619
+ return refs;
138
620
  }
621
+ function csvFields(value) {
622
+ value = value.replace(/\r?\n$/u, "");
623
+ if (!value)
624
+ return null;
625
+ const fields = [];
626
+ let i = 0;
627
+ for (;; ) {
628
+ if (value[i] !== '"') {
629
+ const end = value.indexOf(",", i);
630
+ const field = end < 0 ? value.slice(i) : value.slice(i, end);
631
+ if (field.includes('"'))
632
+ return null;
633
+ fields.push(field);
634
+ if (end < 0)
635
+ return fields;
636
+ i = end + 1;
637
+ continue;
638
+ }
639
+ let field = "";
640
+ for (i++;; i++) {
641
+ const quote = value.indexOf('"', i);
642
+ if (quote < 0)
643
+ return null;
644
+ field += value.slice(i, quote);
645
+ i = quote + 1;
646
+ if (value[i] !== '"')
647
+ break;
648
+ field += '"';
649
+ }
650
+ fields.push(field);
651
+ if (i === value.length)
652
+ return fields;
653
+ if (value[i] !== ",")
654
+ return null;
655
+ i++;
656
+ }
657
+ }
658
+ function csvJoin(fields) {
659
+ return fields.map((field) => /^[ \t]|[",\r\n]/u.test(field) ? `"${field.replaceAll('"', '""')}"` : field).join(",");
660
+ }
661
+ var fieldKey = (field) => field.split("=", 1)[0].toLowerCase();
662
+ function ghaFields(value) {
663
+ const fields = csvFields(value);
664
+ const type = fields?.findLast((field) => fieldKey(field) === "type");
665
+ return type?.slice("type=".length) === "gha" ? fields : null;
666
+ }
667
+ var scopeIndex = (fields) => fields.findLastIndex((field) => fieldKey(field) === "scope");
139
668
  function cacheScopeName(value) {
140
- const m = GHA_RE.exec(value);
141
- if (!m)
669
+ const fields = ghaFields(value);
670
+ if (!fields)
142
671
  return value;
143
- const scope = m[2];
144
- if (scope === undefined)
672
+ const i = scopeIndex(fields);
673
+ if (i < 0)
145
674
  return "";
675
+ const scope = fields[i].slice("scope=".length);
146
676
  assertAscii("cache scope", scope);
147
677
  return sanitizeChars(scope, INVALID_SCOPE_CHARS);
148
678
  }
149
679
  function normalizeCacheScope(value, cachePrefix) {
150
- const m = GHA_RE.exec(value);
151
- if (!m)
680
+ const fields = ghaFields(value);
681
+ if (!fields)
152
682
  return value;
153
683
  if (!cachePrefix)
154
684
  return value;
155
685
  assertAscii("cache prefix", cachePrefix);
156
686
  const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
157
- let scope = splitOnce(cacheScopeName(value), ",")[0];
687
+ let scope = cacheScopeName(value);
158
688
  if (!scope) {
159
689
  scope = prefix.replace(/[-_.]+$/u, "");
160
690
  } else if (prefix) {
161
691
  scope = SEPARATORS.includes(prefix.at(-1)) ? `${prefix}${scope}` : `${prefix}-${scope}`;
162
692
  }
163
- return m[1] ? `type=gha,mode=max,scope=${scope}` : `type=gha,scope=${scope}`;
693
+ const i = scopeIndex(fields);
694
+ if (i < 0)
695
+ fields.push(`scope=${scope}`);
696
+ else
697
+ fields[i] = `scope=${scope}`;
698
+ return csvJoin(fields);
164
699
  }
165
700
 
166
701
  // src/build-args.ts
@@ -178,9 +713,10 @@ var SUMMARIZED = {
178
713
  "--file": "dockerfile",
179
714
  "--target": "target",
180
715
  "--platform": "platform",
181
- "--build-arg": "buildArgs"
716
+ "--build-arg": "buildArgs",
717
+ "--label": "labels"
182
718
  };
183
- function parseBuildArgs(argv, tagPrefix, cachePrefix) {
719
+ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
184
720
  const args = [];
185
721
  const rawCacheFrom = [];
186
722
  const rawCacheTo = [];
@@ -189,21 +725,25 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
189
725
  cacheFrom: [],
190
726
  cacheTo: [],
191
727
  buildArgs: [],
728
+ labels: [],
192
729
  dockerfile: "",
193
730
  target: "",
194
731
  platform: "",
195
732
  context: ""
196
733
  };
734
+ let templated = false;
735
+ let rest = [];
197
736
  const addTags = (value, flag) => {
198
- const refs = rewriteTagValue(value, tagPrefix);
737
+ templated ||= hasPlaceholders(value);
738
+ const refs = rewriteTagValue(value, tagPrefix, vars);
199
739
  for (const ref of refs)
200
740
  args.push(`${flag}${ref}`);
201
741
  summary.tags.push(...refs);
202
742
  };
203
743
  const summarize = (flag, value) => {
204
744
  const field = SUMMARIZED[flag];
205
- if (field === "buildArgs")
206
- summary.buildArgs.push(value);
745
+ if (field === "buildArgs" || field === "labels")
746
+ summary[field].push(value);
207
747
  else
208
748
  summary[field] = value;
209
749
  };
@@ -211,7 +751,12 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
211
751
  const arg = argv[i];
212
752
  const [name, attached] = arg.startsWith("--") && arg.includes("=") ? splitAt(arg, "=") : [arg, undefined];
213
753
  const next = () => consumeValue(argv.slice(++i), arg)[0];
214
- if (arg === "-t")
754
+ if (arg === "--") {
755
+ rest = argv.slice(i);
756
+ if (rest.length > 1)
757
+ summary.context = rest.at(-1);
758
+ break;
759
+ } else if (arg === "-t")
215
760
  addTags(next(), "-t");
216
761
  else if (arg === "--tag")
217
762
  addTags(next(), "--tag=");
@@ -246,6 +791,15 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
246
791
  summary.context = arg;
247
792
  }
248
793
  }
794
+ if (templated && !summary.tags.length) {
795
+ throw new CeError("ce: the guards in the -t templates left out every tag, so the image would have none");
796
+ }
797
+ const ownKeys = new Set(summary.labels.map((label) => label.split("=", 1)[0]));
798
+ const added = Object.entries(labels).filter(([key]) => !ownKeys.has(key)).map(([key, value]) => `${key}=${value}`);
799
+ args.unshift(...added.map((label) => `--label=${label}`));
800
+ summary.labels.unshift(...added);
801
+ if (rawCacheFrom.length || rawCacheTo.length)
802
+ cachePrefix = expandPrefix(cachePrefix, vars, "cache prefix");
249
803
  const toNames = new Set(rawCacheTo.map(cacheScopeName));
250
804
  for (const c of rawCacheFrom) {
251
805
  const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
@@ -257,6 +811,7 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
257
811
  args.push(`--cache-to=${rewritten}`);
258
812
  summary.cacheTo.push(rewritten);
259
813
  }
814
+ args.push(...rest);
260
815
  return { args, summary };
261
816
  }
262
817
  function splitAt(value, sep) {
@@ -264,9 +819,10 @@ function splitAt(value, sep) {
264
819
  return [value.slice(0, i), value.slice(i + 1)];
265
820
  }
266
821
  function printBuildSummary(engine, args, summary) {
267
- const subcommand = args.find((arg) => !arg.startsWith("-") && arg !== "buildx");
268
- if (subcommand !== "build" && subcommand !== "bud")
822
+ const command = findBuildCommand(engine, args);
823
+ if (!command || command.tagsOnly)
269
824
  return;
825
+ const subcommand = command.name;
270
826
  const out = ["[ce] build summary:", ` engine: ${engine}`, ` subcommand: ${subcommand}`];
271
827
  const list = (title, items) => {
272
828
  if (items.length)
@@ -282,6 +838,7 @@ function printBuildSummary(engine, args, summary) {
282
838
  if (summary.platform)
283
839
  out.push(` platform: ${summary.platform}`);
284
840
  list("build args", summary.buildArgs);
841
+ list("labels", summary.labels);
285
842
  if (summary.context)
286
843
  out.push(` context: ${summary.context}`);
287
844
  io.stderr(`${out.join(`
@@ -290,91 +847,6 @@ function printBuildSummary(engine, args, summary) {
290
847
  `);
291
848
  }
292
849
 
293
- // src/json.ts
294
- import { randomUUID } from "node:crypto";
295
- import fs2 from "node:fs";
296
- import path2 from "node:path";
297
- function isObject(value) {
298
- return typeof value === "object" && value !== null && !Array.isArray(value);
299
- }
300
- function readJsonFile(file, fallback) {
301
- try {
302
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs2.readFileSync(file)));
303
- } catch (exc) {
304
- if (isErrno(exc, "ENOENT"))
305
- return fallback;
306
- throw new CeError(`cannot read JSON file ${JSON.stringify(file)}: ${errorMessage(exc)}`);
307
- }
308
- }
309
- function dumpJson(payload) {
310
- return JSON.stringify(payload, (_key, value) => isObject(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : value);
311
- }
312
- function writePrivateJson(file, payload) {
313
- const directory = path2.dirname(file);
314
- fs2.mkdirSync(directory, { recursive: true, mode: 448 });
315
- const temporary = path2.join(directory, `.ce-forgejo-${randomUUID().slice(0, 8)}`);
316
- const fd = fs2.openSync(temporary, "wx", 384);
317
- try {
318
- try {
319
- fs2.fchmodSync(fd, 384);
320
- fs2.writeSync(fd, `${dumpJson(payload)}
321
- `);
322
- fs2.fsyncSync(fd);
323
- } finally {
324
- fs2.closeSync(fd);
325
- }
326
- fs2.renameSync(temporary, file);
327
- } finally {
328
- fs2.rmSync(temporary, { force: true });
329
- }
330
- }
331
-
332
- // src/url.ts
333
- function sameAuthority(a, b) {
334
- return a[0] === b[0] && a[1] === b[1];
335
- }
336
- var SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//iu;
337
- function parseHttpUrl(value) {
338
- const raw = value.trim();
339
- if (!raw)
340
- throw new CeError("empty URL or host");
341
- let url;
342
- try {
343
- url = new URL(SCHEME_RE.test(raw) ? raw : `https://${raw}`);
344
- } catch {
345
- throw new CeError(`invalid URL or host ${JSON.stringify(value)}`);
346
- }
347
- if (url.protocol !== "http:" && url.protocol !== "https:") {
348
- throw new CeError(`unsupported URL scheme ${JSON.stringify(url.protocol.slice(0, -1))}`);
349
- }
350
- if (!url.hostname)
351
- throw new CeError(`missing hostname in ${JSON.stringify(value)}`);
352
- return url;
353
- }
354
- function urlAuthority(value) {
355
- const url = parseHttpUrl(value);
356
- const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : 443;
357
- return [url.hostname.replace(/^\[(.*)\]$/u, "$1"), port];
358
- }
359
- function assertAbsoluteHttpUrl(value, error) {
360
- try {
361
- if (!SCHEME_RE.test(value.trim()))
362
- throw new CeError(error);
363
- parseHttpUrl(value);
364
- } catch {
365
- throw new CeError(error);
366
- }
367
- }
368
- function registryServer(value) {
369
- const url = parseHttpUrl(value);
370
- const authority = value.trim().replace(SCHEME_RE, "").split(/[/\\?#]/u, 1)[0].split("@").at(-1);
371
- const port = url.port || /:(\d+)$/u.exec(authority)?.[1];
372
- return port ? `${url.hostname}:${Number(port)}` : url.hostname;
373
- }
374
- function registryKey(value) {
375
- return registryServer(value).toLowerCase();
376
- }
377
-
378
850
  // src/oidc.ts
379
851
  var REFRESH_WINDOW_S = 5 * 60;
380
852
  var REQUEST_TIMEOUT_MS = 30000;
@@ -689,8 +1161,25 @@ async function waitForEngine(engine) {
689
1161
  logError(`no engine available: ${engine}`);
690
1162
  return false;
691
1163
  }
692
- async function runEngine(engine, argv, tagPrefix, cachePrefix) {
693
- const { args, summary } = parseBuildArgs(argv, tagPrefix, cachePrefix);
1164
+ function envFlag(name) {
1165
+ const value = (process.env[name] ?? "").toLowerCase();
1166
+ if (["", "0", "false", "no"].includes(value))
1167
+ return false;
1168
+ if (["1", "true", "yes"].includes(value))
1169
+ return true;
1170
+ throw new CeError(`ce: ${name} must be true or false, not ${JSON.stringify(process.env[name])}`);
1171
+ }
1172
+ async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1173
+ const command = findBuildCommand(engine, argv);
1174
+ let args = argv;
1175
+ let summary;
1176
+ if (command) {
1177
+ const vars = new CiMetadata;
1178
+ const labels = !command.tagsOnly && (ociLabels ?? envFlag("OCI_LABELS")) ? vars.labels() : {};
1179
+ const parsed = parseBuildArgs(argv.slice(command.start), tagPrefix, cachePrefix, { vars, labels });
1180
+ args = [...argv.slice(0, command.start), ...parsed.args];
1181
+ summary = parsed.summary;
1182
+ }
694
1183
  ensureHelperOnPath();
695
1184
  if (!system.which(engine)) {
696
1185
  logError(`Engine not found: ${engine}`);
@@ -698,7 +1187,8 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix) {
698
1187
  }
699
1188
  if (!await waitForEngine(engine))
700
1189
  return 1;
701
- printBuildSummary(engine, args, summary);
1190
+ if (summary)
1191
+ printBuildSummary(engine, args, summary);
702
1192
  if (engine === "docker")
703
1193
  return system.exec("docker", ["docker", ...args]);
704
1194
  if (system.which("gosu")) {
@@ -719,14 +1209,18 @@ function registryGroups() {
719
1209
  const suffixed = Object.keys(process.env).filter((name) => name.startsWith(prefix) && name.length > prefix.length).sort().map((name) => name.slice(prefix.length));
720
1210
  return [...process.env.REGISTRY_HOST ? [""] : [], ...suffixed];
721
1211
  }
1212
+ function unsetOrEmpty(name) {
1213
+ return `${name} is ${process.env[name] === undefined ? "unset" : "empty"}`;
1214
+ }
722
1215
  function oidcConfig() {
723
1216
  const audience = process.env.AUD ?? "";
724
1217
  if (!audience)
725
1218
  return null;
726
1219
  const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
727
1220
  const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
728
- if (!(requestUrl && requestToken)) {
729
- return "Forgejo OIDC login config is incomplete: AUD requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN";
1221
+ const missing = ["ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"].filter((name) => !process.env[name]);
1222
+ if (missing.length) {
1223
+ return `Forgejo OIDC login config is incomplete: AUD is set, but ${missing.map(unsetOrEmpty).join("; ")}`;
730
1224
  }
731
1225
  try {
732
1226
  assertAbsoluteHttpUrl(requestUrl, "must be an absolute HTTP(S) URL");
@@ -773,36 +1267,46 @@ async function login() {
773
1267
  let failed = false;
774
1268
  for (const group of groups) {
775
1269
  const suffix = group ? `__${group}` : "";
776
- const host = process.env[`REGISTRY_HOST${suffix}`] ?? "";
777
- const user = process.env[`REGISTRY_USER${suffix}`] ?? "";
778
- const password = process.env[`REGISTRY_PASSWORD${suffix}`] ?? "";
1270
+ const hostName = `REGISTRY_HOST${suffix}`;
1271
+ const userName = `REGISTRY_USER${suffix}`;
1272
+ const passwordName = `REGISTRY_PASSWORD${suffix}`;
1273
+ const host = process.env[hostName] ?? "";
1274
+ const user = process.env[userName] ?? "";
1275
+ const password = process.env[passwordName] ?? "";
779
1276
  const label = group || "default";
780
- const incomplete = `Login config for '${label}' is incomplete: host/user/password not all set`;
781
- if (!(host && user)) {
782
- logError(incomplete);
783
- failed = true;
784
- continue;
785
- }
786
- for (const rawRegistry of host.split(/\s+/u).filter(Boolean)) {
787
- let authority;
788
- let registry;
1277
+ const hosts = host.split(/\s+/u).filter(Boolean);
1278
+ const registries = [];
1279
+ for (const rawRegistry of hosts) {
789
1280
  try {
790
- authority = urlAuthority(rawRegistry);
791
- registry = registryServer(rawRegistry);
1281
+ const authority = urlAuthority(rawRegistry);
1282
+ const registry = registryServer(rawRegistry);
1283
+ registries.push({ registry, useOidc: oidc !== null && sameAuthority(authority, oidc.authority) });
792
1284
  } catch (exc) {
793
1285
  if (!(exc instanceof CeError))
794
1286
  throw exc;
795
- logError(`Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`);
796
- failed = true;
797
- continue;
798
- }
799
- const useOidc = oidc !== null && sameAuthority(authority, oidc.authority);
800
- if (!useOidc && !password) {
801
- logError(incomplete);
1287
+ logError(rawRegistry.includes("@") ? `Invalid registry host for '${label}' in ${hostName}, not shown as it has an "@" and may hold a password` : `Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`);
802
1288
  failed = true;
803
- continue;
804
1289
  }
805
- targets.push({ registry, label, user, password, useOidc });
1290
+ }
1291
+ const missing = [];
1292
+ if (!host)
1293
+ missing.push(unsetOrEmpty(hostName));
1294
+ else if (!hosts.length)
1295
+ missing.push(`${hostName} has no registry in it`);
1296
+ if (!user)
1297
+ missing.push(unsetOrEmpty(userName));
1298
+ const needPassword = registries.filter((r) => !r.useOidc).map((r) => r.registry);
1299
+ if (!password && needPassword.length)
1300
+ missing.push(`${unsetOrEmpty(passwordName)}, which ${needPassword.join(", ")} needs`);
1301
+ if (missing.length) {
1302
+ logError(`Login config for '${label}' is incomplete: ${missing.join("; ")}`);
1303
+ failed = true;
1304
+ }
1305
+ if (!user)
1306
+ continue;
1307
+ for (const { registry, useOidc } of registries) {
1308
+ if (useOidc || password)
1309
+ targets.push({ registry, label, user, password, useOidc });
806
1310
  }
807
1311
  }
808
1312
  let jwt = null;
@@ -848,7 +1352,7 @@ async function login() {
848
1352
 
849
1353
  // src/cli.ts
850
1354
  function helpText() {
851
- return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [command|engine] [args]
1355
+ return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
852
1356
 
853
1357
  Options:
854
1358
  --tag-prefix=<prefix>
@@ -857,12 +1361,36 @@ Options:
857
1361
  does not already end with "-", "_", or ".".
858
1362
  Also available via TAG_PREFIX environment variable.
859
1363
  --cache-prefix=<prefix>
860
- Prepend <prefix> to every gha cache scope passed via
861
- --cache-from/--cache-to. Defaults to the tag prefix
1364
+ Prepend <prefix> to the gha cache scopes in --cache-to,
1365
+ and in --cache-from where they match one; other
1366
+ --cache-from scopes stay shared. Defaults to the tag prefix
862
1367
  (TAG_PREFIX); set to "" to keep cache scopes shared.
863
1368
  Also available via CACHE_PREFIX environment variable.
864
1369
  --cache=<spec> Shorthand for passing <spec> to both --cache-from and
865
1370
  --cache-to. Supports the same forms as --cache-from/--cache-to.
1371
+ --oci-labels Add org.opencontainers.image.* labels (source, revision,
1372
+ version, title, description) from the Forgejo Actions
1373
+ run to builds. A --label with the same key wins.
1374
+ Also available via OCI_LABELS=true.
1375
+
1376
+ Placeholders, in build tags and in the tag and cache prefixes:
1377
+ {name} The value of name; an error if it has none in this run.
1378
+ {?name} Expands to nothing; leaves the tag out unless name has
1379
+ a value. Not allowed in prefixes.
1380
+ {image} Registry host and repository, lowercased
1381
+ {ref} Branch name, pr-<number>, or tag name
1382
+ {branch} {pr} {tag}
1383
+ Set only when building that kind of ref
1384
+ {default} Set only when building the default branch, named by
1385
+ the event file or else DEFAULT_BRANCH
1386
+ {revision} {sha} The commit, in full and as 7 characters
1387
+ {version} {major} {minor} {patch}
1388
+ From a SemVer tag such as v1.2.3 (v optional)
1389
+ {stable} Set only for a SemVer tag without a pre-release
1390
+ {env.NAME} The environment variable NAME
1391
+ Values are made safe for a tag ("ci/x" becomes "ci-x"). Quote templates
1392
+ in single quotes. Example:
1393
+ -t '{image}:{ref},{?default}latest'
866
1394
 
867
1395
  Commands:
868
1396
  login Log in to all available engines
@@ -900,6 +1428,7 @@ Environment variables (used by login):
900
1428
  `;
901
1429
  }
902
1430
  async function main(argv) {
1431
+ let ociLabels;
903
1432
  let tagPrefix = process.env.TAG_PREFIX ?? "";
904
1433
  let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
905
1434
  for (;; ) {
@@ -912,6 +1441,8 @@ async function main(argv) {
912
1441
  [cachePrefix, argv] = consumeValue(argv.slice(1), a);
913
1442
  else if (a?.startsWith("--cache-prefix="))
914
1443
  [cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
1444
+ else if (a === "--oci-labels")
1445
+ [ociLabels, argv] = [true, argv.slice(1)];
915
1446
  else
916
1447
  break;
917
1448
  }
@@ -925,7 +1456,7 @@ async function main(argv) {
925
1456
  if (command === "credential-helper")
926
1457
  return credentialHelper(rest);
927
1458
  if (isEngine(command))
928
- return runEngine(command, rest, tagPrefix, cachePrefix);
1459
+ return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
929
1460
  io.stderr(helpText());
930
1461
  return 1;
931
1462
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@izykitten/ce",
3
- "version": "0.0.2",
3
+ "version": "0.0.3-dev.1",
4
4
  "description": "Container engine wrapper: tag/cache rewriting for docker, podman and buildah, registry login, and a Forgejo OIDC Docker credential helper",
5
5
  "type": "module",
6
- "license": "UNLICENSED",
6
+ "license": "BSD-3-Clause",
7
7
  "bin": {
8
8
  "ce": "bin/ce",
9
9
  "docker-credential-ce-forgejo": "bin/docker-credential-ce-forgejo"
@@ -30,4 +30,4 @@
30
30
  "devDependencies": {
31
31
  "@types/bun": "1.4.3"
32
32
  }
33
- }
33
+ }