@izykitten/ce 0.0.3-dev.1 → 0.0.3-dev.3

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 (3) hide show
  1. package/README.md +28 -8
  2. package/dist/ce.js +181 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,12 +43,23 @@ Tags and the `TAG_PREFIX`/`CACHE_PREFIX` values can use placeholders, filled fro
43
43
  | `{version}`, `{major}`, `{minor}`, `{patch}` | from a SemVer tag such as `v1.2.3` or `1.2.3-rc.1` |
44
44
  | `{stable}` | set only for a SemVer tag without a pre-release |
45
45
  | `{env.NAME}` | the environment variable `NAME`, e.g. a value from an earlier step |
46
+ | `{arg.NAME}` | the build arg `NAME` from this command's `--build-arg`, the value the build gets |
46
47
 
47
48
  `{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
 
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
+ 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.
50
51
 
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
+ Prefixes can use guards too. A guard that does not pass leaves the prefix empty, since a prefix cannot be left out, and the prefix's other values are then not needed. `CACHE_PREFIX='{?pr}pr-{pr}'` gives each pull request a cache of its own (`pr-3`) and leaves every other build sharing the unprefixed one; with `CACHE_FROM_DEFAULT`, a PR also reads that shared one, as the prefix is empty on the default branch. The image name cannot use guards.
53
+
54
+ `{arg.NAME}` takes the value from `--build-arg NAME=value`, or from the environment for `--build-arg NAME`, as the engine does, so a tag can name a value worked out on the command line:
55
+
56
+ ```sh
57
+ ce docker build --build-arg VERSION="$(cat VERSION)" -t '{image}:{arg.VERSION}' .
58
+ ```
59
+
60
+ The last `--build-arg NAME` wins. One whose variable is not set is passed over by Docker, which keeps an earlier value, while podman and buildah drop the value for the Containerfile's default. Where the build would use the Containerfile's default, which ce does not read, `{arg.NAME}` is an error, guarded or not, unless another guard beside it does not pass. An empty value (`--build-arg NAME=`) is no value. With podman's `--build-arg-file`, which ce does not read either, `{arg.NAME}` needs a `--build-arg NAME` of its own.
61
+
62
+ 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}` and `{arg.NAME}` work everywhere. A guard that does not pass decides on its own, though: a tag with one is left out, and a prefix with one is empty, even if another guard in it could not be worked out, so `{?default}{?env.X}` outside a run with `X` empty is simply left out. 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
63
 
53
64
  Put templates in single quotes: `?` is a glob character in zsh.
54
65
 
@@ -64,13 +75,11 @@ A branch, PR and default-branch build:
64
75
  -t '{image}:{ref},{?default}latest,{?default}caddy-{env.CADDY_VERSION}' .
65
76
  ```
66
77
 
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:
78
+ With `CACHE_PREFIX='{ref}'`, each branch and pull request gets a gha cache of its own, so a new one starts cold. With `CACHE_FROM_DEFAULT=true`, or `--cache-from-default` before the engine, each gha `--cache-to` is also read from its scope on the default branch: ce expands the cache prefix as it would be there, so PR 3 reads `pr-3` and `main` and writes only `pr-3`, and with `scope=js` it reads `main-js`. The default branch comes from the event file or `DEFAULT_BRANCH`, as for `{default}`. A prefix that needs `{sha}` or `{revision}` there cannot be worked out for it, and is an error; one whose guards leave it empty there, like `{?pr}{sha}`, does not need them. This only picks what is read: whether it hits depends on what the default branch last wrote.
68
79
 
69
- ```sh
70
- --cache type=gha,mode=max --cache-from type=gha,scope=main
71
- ```
80
+ The same can be written by hand, since ce only prefixes a `--cache-from` whose scope matches a `--cache-to` and passes any other on unchanged: `--cache type=gha,mode=max --cache-from type=gha,scope=main`.
72
81
 
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.
82
+ The `--cache-from` that `--cache` makes leaves out a gha `mode=`, which only applies to export, and a `--cache-from` given twice, as the default branch's own scope is on the default branch, is passed once.
74
83
 
75
84
  A release on a SemVer tag, with the version aliases only for stable releases:
76
85
 
@@ -80,6 +89,15 @@ A release on a SemVer tag, with the version aliases only for stable releases:
80
89
 
81
90
  `{stable}` does not mean newest: rebuilding an older release moves its aliases back to it.
82
91
 
92
+ ### Checking a template
93
+
94
+ `--dry-run` before the engine, or `CE_DRY_RUN=true`, prints the build summary and the engine command ce would run, and exits 0 without running it or waiting for the engine. A template that fails still exits 2, so it also works as a check before pushing a workflow. It checks the templates, not the engine: if the engine is not on `PATH`, it warns and still exits 0, so it works on a machine without one:
95
+
96
+ ```sh
97
+ GITHUB_REF=refs/tags/v1.2.3 GITHUB_SERVER_URL=https://code.example GITHUB_REPOSITORY=org/app \
98
+ ce --dry-run docker buildx build -t '{image}:{ref},{?stable}{major}.{minor}' .
99
+ ```
100
+
83
101
  ### OCI labels
84
102
 
85
103
  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.
@@ -92,7 +110,7 @@ The package installs `docker-credential-ce-forgejo` next to `ce`. `ce` puts that
92
110
 
93
111
  ## Compatibility
94
112
 
95
- These stay the same from release to release, and [test/contract.test.ts](test/contract.test.ts) checks them by running the built package under Node and Bun:
113
+ These behave the same under Node and Bun, and [test/contract.test.ts](test/contract.test.ts) checks them by running the built package under both. A release that changes one says so here:
96
114
 
97
115
  - commands, flags and the environment variables `login` reads;
98
116
  - the arguments the engine receives after tag and cache rewriting, and its exit code, signals and PID (`ce` replaces itself with the engine);
@@ -102,6 +120,8 @@ These stay the same from release to release, and [test/contract.test.ts](test/co
102
120
 
103
121
  The wording of messages is not part of that and may change.
104
122
 
123
+ In 0.0.3, the `--cache-from` that `--cache` makes no longer has a gha `mode=`, which only applies to export, and a `--cache-from` given twice is passed once.
124
+
105
125
  Compared with the Python version:
106
126
 
107
127
  - The state file is locked with `ce-forgejo-credentials.json.pidlock`, holding the holder's PID and hostname, rather than `flock`, which Node does not have. A lock whose holder died is broken right away if the PID is gone on the same host, otherwise after two minutes. It does not exclude a Python `ce` using the same Docker config at the same moment.
package/dist/ce.js CHANGED
@@ -244,7 +244,7 @@ class CiMetadata {
244
244
  get(name) {
245
245
  const envName = ENV_VARIABLE.exec(name)?.[1];
246
246
  if (envName)
247
- return nonEmpty(this.#env[envName]);
247
+ return Object.hasOwn(this.#env, envName) ? nonEmpty(this.#env[envName]) : undefined;
248
248
  if (!this.#cache.has(name))
249
249
  this.#cache.set(name, this.#resolve(name));
250
250
  return this.#cache.get(name);
@@ -298,7 +298,7 @@ class CiMetadata {
298
298
  const ref = this.#parsedRef();
299
299
  if (ref.kind !== "branch")
300
300
  return;
301
- return ref.name === this.#defaultBranch() ? ref.name : undefined;
301
+ return ref.name === this.defaultBranch() ? ref.name : undefined;
302
302
  }
303
303
  case "revision":
304
304
  return this.#require("SHA", `{${name}}`);
@@ -359,7 +359,7 @@ class CiMetadata {
359
359
  const ref = this.#parsedRef();
360
360
  return ref.kind === "tag" ? SEMVER.exec(ref.name) : null;
361
361
  }
362
- #defaultBranch() {
362
+ defaultBranch() {
363
363
  const repository = this.#ciEnv("EVENT_PATH") ? this.#payload().repository : undefined;
364
364
  const fromEvent = isObject(repository) ? repository.default_branch : undefined;
365
365
  const branch = typeof fromEvent === "string" && fromEvent || nonEmpty(this.#env.DEFAULT_BRANCH);
@@ -384,6 +384,21 @@ class CiMetadata {
384
384
  return payload;
385
385
  }
386
386
  }
387
+ var ON_DEFAULT_BRANCH = new Set(["ref", "branch", "default"]);
388
+ var UNKNOWN_ON_DEFAULT_BRANCH = new Set(["revision", "sha"]);
389
+ function onDefaultBranch(vars, branch) {
390
+ return {
391
+ known: (name) => vars.known(name),
392
+ get(name) {
393
+ if (ON_DEFAULT_BRANCH.has(name))
394
+ return branch;
395
+ if (UNKNOWN_ON_DEFAULT_BRANCH.has(name)) {
396
+ throw new CeError(`ce: the default branch's cache scope cannot use {${name}}: this run does not know its commit`);
397
+ }
398
+ return name === "image" || name.startsWith("env.") || name.startsWith("arg.") ? vars.get(name) : undefined;
399
+ }
400
+ };
401
+ }
387
402
 
388
403
  // src/system.ts
389
404
  import { spawnSync } from "node:child_process";
@@ -479,12 +494,26 @@ function parse(template, vars) {
479
494
  text(template.slice(last));
480
495
  return parts;
481
496
  }
482
- function expand(template, vars, encode) {
483
- const parts = parse(template, vars);
497
+ function guardsPass(parts, vars) {
498
+ let unknown;
484
499
  for (const part of parts) {
485
- if ("name" in part && part.guard && vars.get(part.name) === undefined)
486
- return null;
500
+ if (!("name" in part) || !part.guard)
501
+ continue;
502
+ try {
503
+ if (vars.get(part.name) === undefined)
504
+ return false;
505
+ } catch (exc) {
506
+ unknown ??= exc;
507
+ }
487
508
  }
509
+ if (unknown !== undefined)
510
+ throw unknown;
511
+ return true;
512
+ }
513
+ function expand(template, vars, encode) {
514
+ const parts = parse(template, vars);
515
+ if (!guardsPass(parts, vars))
516
+ return null;
488
517
  let out = "";
489
518
  for (const part of parts) {
490
519
  if ("text" in part)
@@ -499,16 +528,21 @@ function expand(template, vars, encode) {
499
528
  }
500
529
  return out;
501
530
  }
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`);
531
+ function checkValue(template, vars, what, guards = false) {
532
+ const parts = parse(template, vars);
533
+ if (!guards && parts.some((part) => ("guard" in part) && part.guard)) {
534
+ throw new CeError(`ce: ${what} ${JSON.stringify(template)} cannot use {?guards}; they only apply to tags and prefixes`);
505
535
  }
506
536
  }
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)}`);
537
+ function expandValue(template, vars, encode, what, guards = false) {
538
+ checkValue(template, vars, what, guards);
539
+ const parts = parse(template, vars);
540
+ if (!guardsPass(parts, vars))
541
+ return "";
542
+ for (const part of parts) {
543
+ if ("name" in part && !part.guard && vars.get(part.name) === undefined) {
544
+ const hint = guards ? ` To leave the ${what} empty instead, guard it with {?${part.name}}.` : "";
545
+ throw new CeError(`ce: {${part.name}} has no value here, in the ${what} ${JSON.stringify(template)}.${hint}`);
512
546
  }
513
547
  }
514
548
  return expand(template, vars, encode);
@@ -570,7 +604,7 @@ function expandImage(template, vars) {
570
604
  return image;
571
605
  }
572
606
  function expandPrefix(prefix, vars, what) {
573
- return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what) : prefix;
607
+ return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what, true) : prefix;
574
608
  }
575
609
  function literalRef(repo, tag) {
576
610
  if (tag.includes("/")) {
@@ -593,7 +627,7 @@ function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
593
627
  if (hasPlaceholders(repoTemplate))
594
628
  checkValue(repoTemplate, vars, "image name");
595
629
  if (hasPlaceholders(tagPrefix))
596
- checkValue(tagPrefix, vars, "tag prefix");
630
+ checkValue(tagPrefix, vars, "tag prefix", true);
597
631
  let repo;
598
632
  const expandRepo = () => repo ??= hasPlaceholders(repoTemplate) ? expandImage(repoTemplate, vars) : repoTemplate;
599
633
  if (tagList === undefined)
@@ -664,6 +698,7 @@ function ghaFields(value) {
664
698
  const type = fields?.findLast((field) => fieldKey(field) === "type");
665
699
  return type?.slice("type=".length) === "gha" ? fields : null;
666
700
  }
701
+ var isGhaCache = (value) => ghaFields(value) !== null;
667
702
  var scopeIndex = (fields) => fields.findLastIndex((field) => fieldKey(field) === "scope");
668
703
  function cacheScopeName(value) {
669
704
  const fields = ghaFields(value);
@@ -697,6 +732,12 @@ function normalizeCacheScope(value, cachePrefix) {
697
732
  fields[i] = `scope=${scope}`;
698
733
  return csvJoin(fields);
699
734
  }
735
+ function withoutCacheMode(value) {
736
+ const fields = ghaFields(value);
737
+ if (!fields?.some((field) => fieldKey(field) === "mode"))
738
+ return value;
739
+ return csvJoin(fields.filter((field) => fieldKey(field) !== "mode"));
740
+ }
700
741
 
701
742
  // src/build-args.ts
702
743
  function consumeValue(argv, flag) {
@@ -716,8 +757,27 @@ var SUMMARIZED = {
716
757
  "--build-arg": "buildArgs",
717
758
  "--label": "labels"
718
759
  };
719
- function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
720
- const args = [];
760
+ var ARG_VARIABLE = /^arg\.([A-Za-z_][A-Za-z0-9_]*)$/u;
761
+ function withBuildArgs(vars, values, argFile) {
762
+ return {
763
+ known: (name) => ARG_VARIABLE.test(name) || vars.known(name),
764
+ get(name) {
765
+ const arg = ARG_VARIABLE.exec(name)?.[1];
766
+ if (arg === undefined)
767
+ return vars.get(name);
768
+ const value = values.get(arg);
769
+ if (value === undefined && argFile) {
770
+ throw new CeError(`ce: {${name}} needs --build-arg ${arg} on this command, as ce does not read --build-arg-file`);
771
+ }
772
+ if (value == null) {
773
+ throw new CeError(`ce: {${name}} needs --build-arg ${arg}=<value> on this command, or --build-arg ${arg} with ${arg} set; ` + "ce does not read defaults from the Containerfile");
774
+ }
775
+ return value || undefined;
776
+ }
777
+ };
778
+ }
779
+ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {}, defaultBranch, env = process.env, engine = "docker" } = {}) {
780
+ const parts = [];
721
781
  const rawCacheFrom = [];
722
782
  const rawCacheTo = [];
723
783
  const summary = {
@@ -732,16 +792,23 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
732
792
  context: ""
733
793
  };
734
794
  let templated = false;
795
+ const buildArgs = new Map;
796
+ let argFile = false;
735
797
  let rest = [];
736
798
  const addTags = (value, flag) => {
737
799
  templated ||= hasPlaceholders(value);
738
- const refs = rewriteTagValue(value, tagPrefix, vars);
739
- for (const ref of refs)
740
- args.push(`${flag}${ref}`);
741
- summary.tags.push(...refs);
800
+ parts.push({ tags: value, flag });
742
801
  };
743
802
  const summarize = (flag, value) => {
744
803
  const field = SUMMARIZED[flag];
804
+ if (field === "buildArgs") {
805
+ if (value.includes("="))
806
+ buildArgs.set(...splitAt(value, "="));
807
+ else if (Object.hasOwn(env, value))
808
+ buildArgs.set(value, env[value]);
809
+ else if (engine !== "docker")
810
+ buildArgs.set(value, null);
811
+ }
745
812
  if (field === "buildArgs" || field === "labels")
746
813
  summary[field].push(value);
747
814
  else
@@ -770,27 +837,40 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
770
837
  rawCacheTo.push(attached ?? next());
771
838
  else if (name === "--cache") {
772
839
  const v = attached ?? next();
773
- rawCacheFrom.push(v);
840
+ rawCacheFrom.push(withoutCacheMode(v));
774
841
  rawCacheTo.push(v);
775
842
  } else if (Object.hasOwn(SUMMARIZED, name)) {
776
843
  const flag = name;
777
844
  if (attached === undefined) {
778
845
  const v = next();
779
- args.push(arg, v);
846
+ parts.push(arg, v);
780
847
  summarize(flag, v);
781
848
  } else {
782
- args.push(arg);
849
+ parts.push(arg);
783
850
  summarize(flag, attached);
784
851
  }
852
+ } else if (name === "--build-arg-file") {
853
+ argFile = true;
854
+ parts.push(arg);
855
+ if (attached === undefined)
856
+ parts.push(next());
785
857
  } else if (arg.startsWith("-f") && arg.length > 2) {
786
- args.push(arg);
858
+ parts.push(arg);
787
859
  summary.dockerfile = arg.slice(2);
788
860
  } else {
789
- args.push(arg);
861
+ parts.push(arg);
790
862
  if (!arg.startsWith("-"))
791
863
  summary.context = arg;
792
864
  }
793
865
  }
866
+ vars = withBuildArgs(vars, buildArgs, argFile);
867
+ const args = parts.flatMap((part) => {
868
+ if (typeof part === "string")
869
+ return [part];
870
+ const refs = rewriteTagValue(part.tags, tagPrefix, vars);
871
+ summary.tags.push(...refs);
872
+ return refs.map((ref) => `${part.flag}${ref}`);
873
+ });
794
874
  if (templated && !summary.tags.length) {
795
875
  throw new CeError("ce: the guards in the -t templates left out every tag, so the image would have none");
796
876
  }
@@ -798,11 +878,17 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
798
878
  const added = Object.entries(labels).filter(([key]) => !ownKeys.has(key)).map(([key, value]) => `${key}=${value}`);
799
879
  args.unshift(...added.map((label) => `--label=${label}`));
800
880
  summary.labels.unshift(...added);
881
+ const prefixTemplate = cachePrefix;
801
882
  if (rawCacheFrom.length || rawCacheTo.length)
802
883
  cachePrefix = expandPrefix(cachePrefix, vars, "cache prefix");
803
884
  const toNames = new Set(rawCacheTo.map(cacheScopeName));
804
- for (const c of rawCacheFrom) {
805
- const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
885
+ const cacheFrom = rawCacheFrom.map((c) => toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c);
886
+ const ghaTo = rawCacheTo.filter(isGhaCache);
887
+ if (defaultBranch && ghaTo.length) {
888
+ const prefix = expandPrefix(prefixTemplate, onDefaultBranch(vars, defaultBranch()), "cache prefix");
889
+ cacheFrom.push(...ghaTo.map((c) => normalizeCacheScope(withoutCacheMode(c), prefix)));
890
+ }
891
+ for (const rewritten of new Set(cacheFrom)) {
806
892
  args.push(`--cache-from=${rewritten}`);
807
893
  summary.cacheFrom.push(rewritten);
808
894
  }
@@ -1169,18 +1255,50 @@ function envFlag(name) {
1169
1255
  return true;
1170
1256
  throw new CeError(`ce: ${name} must be true or false, not ${JSON.stringify(process.env[name])}`);
1171
1257
  }
1172
- async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1258
+ function shellQuote(arg) {
1259
+ return /^[A-Za-z0-9_@%+:,./-][A-Za-z0-9_@%+=:,./-]*$/u.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
1260
+ }
1261
+ function engineCommand(engine, args) {
1262
+ if (engine === "docker" || !system.which("gosu"))
1263
+ return [engine, ...args];
1264
+ const user = process.env.USER ?? "";
1265
+ if (!user) {
1266
+ logError("ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.");
1267
+ return null;
1268
+ }
1269
+ return ["gosu", user, engine, ...args];
1270
+ }
1271
+ async function runEngine(engine, argv, tagPrefix, cachePrefix, options = {}) {
1272
+ const dryRun = options.dryRun ?? envFlag("CE_DRY_RUN");
1273
+ ensureHelperOnPath();
1173
1274
  const command = findBuildCommand(engine, argv);
1174
1275
  let args = argv;
1175
1276
  let summary;
1176
1277
  if (command) {
1177
1278
  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 });
1279
+ const labels = !command.tagsOnly && (options.ociLabels ?? envFlag("OCI_LABELS")) ? vars.labels() : {};
1280
+ const fromDefault = !command.tagsOnly && (options.cacheFromDefault ?? envFlag("CACHE_FROM_DEFAULT"));
1281
+ const parsed = parseBuildArgs(argv.slice(command.start), tagPrefix, cachePrefix, {
1282
+ engine,
1283
+ vars,
1284
+ labels,
1285
+ defaultBranch: fromDefault ? () => vars.defaultBranch() : undefined
1286
+ });
1180
1287
  args = [...argv.slice(0, command.start), ...parsed.args];
1181
1288
  summary = parsed.summary;
1182
1289
  }
1183
- ensureHelperOnPath();
1290
+ if (dryRun) {
1291
+ const run = engineCommand(engine, args);
1292
+ if (!run)
1293
+ return 1;
1294
+ if (summary)
1295
+ printBuildSummary(engine, args, summary);
1296
+ if (!system.which(engine))
1297
+ logError(`ce: warning: ${engine} is not on PATH, so running this here would fail`);
1298
+ io.stdout(`${run.map(shellQuote).join(" ")}
1299
+ `);
1300
+ return 0;
1301
+ }
1184
1302
  if (!system.which(engine)) {
1185
1303
  logError(`Engine not found: ${engine}`);
1186
1304
  return 1;
@@ -1189,17 +1307,8 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1189
1307
  return 1;
1190
1308
  if (summary)
1191
1309
  printBuildSummary(engine, args, summary);
1192
- if (engine === "docker")
1193
- return system.exec("docker", ["docker", ...args]);
1194
- if (system.which("gosu")) {
1195
- const user = process.env.USER ?? "";
1196
- if (!user) {
1197
- logError("ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.");
1198
- return 1;
1199
- }
1200
- return system.exec("gosu", ["gosu", user, engine, ...args]);
1201
- }
1202
- return system.exec(engine, [engine, ...args]);
1310
+ const run = engineCommand(engine, args);
1311
+ return run ? system.exec(run[0], run) : 1;
1203
1312
  }
1204
1313
 
1205
1314
  // src/login.ts
@@ -1352,7 +1461,8 @@ async function login() {
1352
1461
 
1353
1462
  // src/cli.ts
1354
1463
  function helpText() {
1355
- return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
1464
+ return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels]
1465
+ [--cache-from-default] [--dry-run] [command|engine] [args]
1356
1466
 
1357
1467
  Options:
1358
1468
  --tag-prefix=<prefix>
@@ -1368,15 +1478,27 @@ Options:
1368
1478
  Also available via CACHE_PREFIX environment variable.
1369
1479
  --cache=<spec> Shorthand for passing <spec> to both --cache-from and
1370
1480
  --cache-to. Supports the same forms as --cache-from/--cache-to.
1481
+ A gha cache-from made this way leaves out mode=, which
1482
+ only applies to export. The same cache-from given twice
1483
+ is passed once.
1484
+ --cache-from-default
1485
+ Also read each gha --cache-to from its scope on the
1486
+ default branch, from the cache prefix as it is there,
1487
+ so a new branch or PR starts from its cache.
1488
+ Also available via CACHE_FROM_DEFAULT=true.
1371
1489
  --oci-labels Add org.opencontainers.image.* labels (source, revision,
1372
1490
  version, title, description) from the Forgejo Actions
1373
1491
  run to builds. A --label with the same key wins.
1374
1492
  Also available via OCI_LABELS=true.
1493
+ --dry-run Print the build summary and the engine command, and
1494
+ exit without running it. Template errors still exit 2;
1495
+ a missing engine is only a warning.
1496
+ Also available via CE_DRY_RUN=true.
1375
1497
 
1376
1498
  Placeholders, in build tags and in the tag and cache prefixes:
1377
1499
  {name} The value of name; an error if it has none in this run.
1378
1500
  {?name} Expands to nothing; leaves the tag out unless name has
1379
- a value. Not allowed in prefixes.
1501
+ a value. In a prefix, leaves the prefix empty instead.
1380
1502
  {image} Registry host and repository, lowercased
1381
1503
  {ref} Branch name, pr-<number>, or tag name
1382
1504
  {branch} {pr} {tag}
@@ -1388,6 +1510,11 @@ Placeholders, in build tags and in the tag and cache prefixes:
1388
1510
  From a SemVer tag such as v1.2.3 (v optional)
1389
1511
  {stable} Set only for a SemVer tag without a pre-release
1390
1512
  {env.NAME} The environment variable NAME
1513
+ {arg.NAME} The build arg NAME from this command's --build-arg;
1514
+ an error without one, even guarded, as ce does not
1515
+ read Containerfiles
1516
+ A guard that does not pass leaves its tag out or its prefix empty even if
1517
+ another guard there cannot be worked out.
1391
1518
  Values are made safe for a tag ("ci/x" becomes "ci-x"). Quote templates
1392
1519
  in single quotes. Example:
1393
1520
  -t '{image}:{ref},{?default}latest'
@@ -1428,7 +1555,7 @@ Environment variables (used by login):
1428
1555
  `;
1429
1556
  }
1430
1557
  async function main(argv) {
1431
- let ociLabels;
1558
+ const options = {};
1432
1559
  let tagPrefix = process.env.TAG_PREFIX ?? "";
1433
1560
  let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
1434
1561
  for (;; ) {
@@ -1442,7 +1569,11 @@ async function main(argv) {
1442
1569
  else if (a?.startsWith("--cache-prefix="))
1443
1570
  [cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
1444
1571
  else if (a === "--oci-labels")
1445
- [ociLabels, argv] = [true, argv.slice(1)];
1572
+ [options.ociLabels, argv] = [true, argv.slice(1)];
1573
+ else if (a === "--cache-from-default")
1574
+ [options.cacheFromDefault, argv] = [true, argv.slice(1)];
1575
+ else if (a === "--dry-run")
1576
+ [options.dryRun, argv] = [true, argv.slice(1)];
1446
1577
  else
1447
1578
  break;
1448
1579
  }
@@ -1456,7 +1587,7 @@ async function main(argv) {
1456
1587
  if (command === "credential-helper")
1457
1588
  return credentialHelper(rest);
1458
1589
  if (isEngine(command))
1459
- return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
1590
+ return runEngine(command, rest, tagPrefix, cachePrefix, options);
1460
1591
  io.stderr(helpText());
1461
1592
  return 1;
1462
1593
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izykitten/ce",
3
- "version": "0.0.3-dev.1",
3
+ "version": "0.0.3-dev.3",
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
6
  "license": "BSD-3-Clause",