@izykitten/ce 0.0.2 → 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.
Files changed (4) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +50 -1
  3. package/dist/ce.js +652 -142
  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,58 @@ 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 |
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, or with its event file missing `repository.default_branch`, `{?default}` fails like `{default}` would. Only `{env.NAME}` works everywhere.
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
+ A release on a SemVer tag, with the version aliases only for stable releases:
68
+
69
+ ```sh
70
+ -t '{image}:{ref},{?version}{version},{?stable}{major}.{minor},{?stable}{major},sha-{sha}'
71
+ ```
72
+
73
+ `{stable}` does not mean newest: rebuilding an older release moves its aliases back to it.
74
+
75
+ ### OCI labels
76
+
77
+ 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.
78
+
30
79
  ## The credential helper
31
80
 
32
81
  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,274 @@ 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.#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";
19
391
  import { setTimeout } from "node:timers/promises";
20
392
  var io = {
21
- stdout: (text) => void fs.writeSync(1, text),
22
- stderr: (text) => void fs.writeSync(2, text),
23
- stdin: () => fs.readFileSync(0, "utf8"),
393
+ stdout: (text) => void fs2.writeSync(1, text),
394
+ stderr: (text) => void fs2.writeSync(2, text),
395
+ stdin: () => fs2.readFileSync(0, "utf8"),
24
396
  argv0: () => process.argv[1] ?? "ce"
25
397
  };
26
398
  function log(line) {
@@ -35,11 +407,11 @@ function childEnv() {
35
407
  return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
36
408
  }
37
409
  function which(command) {
38
- const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, command));
410
+ const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean).map((dir) => path2.join(dir, command));
39
411
  for (const candidate of candidates) {
40
412
  try {
41
- fs.accessSync(candidate, fs.constants.X_OK);
42
- if (fs.statSync(candidate).isFile())
413
+ fs2.accessSync(candidate, fs2.constants.X_OK);
414
+ if (fs2.statSync(candidate).isFile())
43
415
  return candidate;
44
416
  } catch {}
45
417
  }
@@ -71,20 +443,76 @@ var system = {
71
443
  }
72
444
  };
73
445
  function binDir() {
74
- return fs.realpathSync(path.join(import.meta.dirname, "..", "bin"));
446
+ return fs2.realpathSync(path2.join(import.meta.dirname, "..", "bin"));
75
447
  }
76
448
  function ensureHelperOnPath() {
77
449
  const dir = binDir();
78
- const parts = (process.env.PATH ?? "").split(path.delimiter);
450
+ const parts = (process.env.PATH ?? "").split(path2.delimiter);
79
451
  if (!parts.includes(dir))
80
- process.env.PATH = [dir, ...parts].join(path.delimiter);
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);
81
509
  }
82
510
 
83
511
  // src/tags.ts
84
512
  var INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
85
513
  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;
514
+ var VALID_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u;
515
+ var HOST_PORT = /^([^/:]+):(?:\d|\{[^{}]*\})+\//u;
88
516
  var SEPARATORS = "-_.";
89
517
  function sanitizeChars(value, charRegex) {
90
518
  return value.replace(charRegex, "_");
@@ -105,62 +533,163 @@ function splitOnce(value, sep) {
105
533
  const i = value.indexOf(sep);
106
534
  return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
107
535
  }
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
- }
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 "-".`);
120
546
  }
121
- const m = RENOVATE_RE.exec(out);
122
- return m ? `${m[1]}/renovate:${m[2]}` : out;
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)}`;
123
575
  }
124
576
  function computeSafePrefix(prefix) {
125
577
  assertAscii("tag prefix", prefix);
126
578
  const p = sanitizeChars(prefix, INVALID_TAG_CHARS);
127
579
  return p && !SEPARATORS.includes(p.at(-1)) ? `${p}-` : p;
128
580
  }
129
- function rewriteTagValue(value, tagPrefix) {
581
+ function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
582
+ assertAscii("tag", value);
130
583
  const v = stripProto(value);
131
- if (!v.includes(":")) {
132
- assertAscii("tag", v);
133
- return [v];
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++;
134
650
  }
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);
138
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");
139
662
  function cacheScopeName(value) {
140
- const m = GHA_RE.exec(value);
141
- if (!m)
663
+ const fields = ghaFields(value);
664
+ if (!fields)
142
665
  return value;
143
- const scope = m[2];
144
- if (scope === undefined)
666
+ const i = scopeIndex(fields);
667
+ if (i < 0)
145
668
  return "";
669
+ const scope = fields[i].slice("scope=".length);
146
670
  assertAscii("cache scope", scope);
147
671
  return sanitizeChars(scope, INVALID_SCOPE_CHARS);
148
672
  }
149
673
  function normalizeCacheScope(value, cachePrefix) {
150
- const m = GHA_RE.exec(value);
151
- if (!m)
674
+ const fields = ghaFields(value);
675
+ if (!fields)
152
676
  return value;
153
677
  if (!cachePrefix)
154
678
  return value;
155
679
  assertAscii("cache prefix", cachePrefix);
156
680
  const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
157
- let scope = splitOnce(cacheScopeName(value), ",")[0];
681
+ let scope = cacheScopeName(value);
158
682
  if (!scope) {
159
683
  scope = prefix.replace(/[-_.]+$/u, "");
160
684
  } else if (prefix) {
161
685
  scope = SEPARATORS.includes(prefix.at(-1)) ? `${prefix}${scope}` : `${prefix}-${scope}`;
162
686
  }
163
- return m[1] ? `type=gha,mode=max,scope=${scope}` : `type=gha,scope=${scope}`;
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);
164
693
  }
165
694
 
166
695
  // src/build-args.ts
@@ -178,9 +707,10 @@ var SUMMARIZED = {
178
707
  "--file": "dockerfile",
179
708
  "--target": "target",
180
709
  "--platform": "platform",
181
- "--build-arg": "buildArgs"
710
+ "--build-arg": "buildArgs",
711
+ "--label": "labels"
182
712
  };
183
- function parseBuildArgs(argv, tagPrefix, cachePrefix) {
713
+ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
184
714
  const args = [];
185
715
  const rawCacheFrom = [];
186
716
  const rawCacheTo = [];
@@ -189,21 +719,25 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
189
719
  cacheFrom: [],
190
720
  cacheTo: [],
191
721
  buildArgs: [],
722
+ labels: [],
192
723
  dockerfile: "",
193
724
  target: "",
194
725
  platform: "",
195
726
  context: ""
196
727
  };
728
+ let templated = false;
729
+ let rest = [];
197
730
  const addTags = (value, flag) => {
198
- const refs = rewriteTagValue(value, tagPrefix);
731
+ templated ||= hasPlaceholders(value);
732
+ const refs = rewriteTagValue(value, tagPrefix, vars);
199
733
  for (const ref of refs)
200
734
  args.push(`${flag}${ref}`);
201
735
  summary.tags.push(...refs);
202
736
  };
203
737
  const summarize = (flag, value) => {
204
738
  const field = SUMMARIZED[flag];
205
- if (field === "buildArgs")
206
- summary.buildArgs.push(value);
739
+ if (field === "buildArgs" || field === "labels")
740
+ summary[field].push(value);
207
741
  else
208
742
  summary[field] = value;
209
743
  };
@@ -211,7 +745,12 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
211
745
  const arg = argv[i];
212
746
  const [name, attached] = arg.startsWith("--") && arg.includes("=") ? splitAt(arg, "=") : [arg, undefined];
213
747
  const next = () => consumeValue(argv.slice(++i), arg)[0];
214
- if (arg === "-t")
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")
215
754
  addTags(next(), "-t");
216
755
  else if (arg === "--tag")
217
756
  addTags(next(), "--tag=");
@@ -246,6 +785,15 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
246
785
  summary.context = arg;
247
786
  }
248
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");
249
797
  const toNames = new Set(rawCacheTo.map(cacheScopeName));
250
798
  for (const c of rawCacheFrom) {
251
799
  const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
@@ -257,6 +805,7 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
257
805
  args.push(`--cache-to=${rewritten}`);
258
806
  summary.cacheTo.push(rewritten);
259
807
  }
808
+ args.push(...rest);
260
809
  return { args, summary };
261
810
  }
262
811
  function splitAt(value, sep) {
@@ -264,9 +813,10 @@ function splitAt(value, sep) {
264
813
  return [value.slice(0, i), value.slice(i + 1)];
265
814
  }
266
815
  function printBuildSummary(engine, args, summary) {
267
- const subcommand = args.find((arg) => !arg.startsWith("-") && arg !== "buildx");
268
- if (subcommand !== "build" && subcommand !== "bud")
816
+ const command = findBuildCommand(engine, args);
817
+ if (!command || command.tagsOnly)
269
818
  return;
819
+ const subcommand = command.name;
270
820
  const out = ["[ce] build summary:", ` engine: ${engine}`, ` subcommand: ${subcommand}`];
271
821
  const list = (title, items) => {
272
822
  if (items.length)
@@ -282,6 +832,7 @@ function printBuildSummary(engine, args, summary) {
282
832
  if (summary.platform)
283
833
  out.push(` platform: ${summary.platform}`);
284
834
  list("build args", summary.buildArgs);
835
+ list("labels", summary.labels);
285
836
  if (summary.context)
286
837
  out.push(` context: ${summary.context}`);
287
838
  io.stderr(`${out.join(`
@@ -290,91 +841,6 @@ function printBuildSummary(engine, args, summary) {
290
841
  `);
291
842
  }
292
843
 
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
844
  // src/oidc.ts
379
845
  var REFRESH_WINDOW_S = 5 * 60;
380
846
  var REQUEST_TIMEOUT_MS = 30000;
@@ -689,8 +1155,25 @@ async function waitForEngine(engine) {
689
1155
  logError(`no engine available: ${engine}`);
690
1156
  return false;
691
1157
  }
692
- async function runEngine(engine, argv, tagPrefix, cachePrefix) {
693
- const { args, summary } = parseBuildArgs(argv, tagPrefix, cachePrefix);
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
+ }
694
1177
  ensureHelperOnPath();
695
1178
  if (!system.which(engine)) {
696
1179
  logError(`Engine not found: ${engine}`);
@@ -698,7 +1181,8 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix) {
698
1181
  }
699
1182
  if (!await waitForEngine(engine))
700
1183
  return 1;
701
- printBuildSummary(engine, args, summary);
1184
+ if (summary)
1185
+ printBuildSummary(engine, args, summary);
702
1186
  if (engine === "docker")
703
1187
  return system.exec("docker", ["docker", ...args]);
704
1188
  if (system.which("gosu")) {
@@ -848,7 +1332,7 @@ async function login() {
848
1332
 
849
1333
  // src/cli.ts
850
1334
  function helpText() {
851
- return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [command|engine] [args]
1335
+ return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
852
1336
 
853
1337
  Options:
854
1338
  --tag-prefix=<prefix>
@@ -857,12 +1341,35 @@ Options:
857
1341
  does not already end with "-", "_", or ".".
858
1342
  Also available via TAG_PREFIX environment variable.
859
1343
  --cache-prefix=<prefix>
860
- Prepend <prefix> to every gha cache scope passed via
861
- --cache-from/--cache-to. Defaults to the tag 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
862
1347
  (TAG_PREFIX); set to "" to keep cache scopes shared.
863
1348
  Also available via CACHE_PREFIX environment variable.
864
1349
  --cache=<spec> Shorthand for passing <spec> to both --cache-from and
865
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'
866
1373
 
867
1374
  Commands:
868
1375
  login Log in to all available engines
@@ -900,6 +1407,7 @@ Environment variables (used by login):
900
1407
  `;
901
1408
  }
902
1409
  async function main(argv) {
1410
+ let ociLabels;
903
1411
  let tagPrefix = process.env.TAG_PREFIX ?? "";
904
1412
  let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
905
1413
  for (;; ) {
@@ -912,6 +1420,8 @@ async function main(argv) {
912
1420
  [cachePrefix, argv] = consumeValue(argv.slice(1), a);
913
1421
  else if (a?.startsWith("--cache-prefix="))
914
1422
  [cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
1423
+ else if (a === "--oci-labels")
1424
+ [ociLabels, argv] = [true, argv.slice(1)];
915
1425
  else
916
1426
  break;
917
1427
  }
@@ -925,7 +1435,7 @@ async function main(argv) {
925
1435
  if (command === "credential-helper")
926
1436
  return credentialHelper(rest);
927
1437
  if (isEngine(command))
928
- return runEngine(command, rest, tagPrefix, cachePrefix);
1438
+ return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
929
1439
  io.stderr(helpText());
930
1440
  return 1;
931
1441
  }
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.0",
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
+ }