@izykitten/ce 0.0.3-dev.2 → 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 +15 -4
  2. package/dist/ce.js +96 -29
  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,7 +75,7 @@ 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. 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 with `{sha}` or `{revision}` cannot be worked out for it, and is an error. This only picks what is read: whether it hits depends on what the default branch last wrote.
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
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`.
70
81
 
@@ -80,7 +91,7 @@ A release on a SemVer tag, with the version aliases only for stable releases:
80
91
 
81
92
  ### Checking a template
82
93
 
83
- `--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:
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:
84
95
 
85
96
  ```sh
86
97
  GITHUB_REF=refs/tags/v1.2.3 GITHUB_SERVER_URL=https://code.example GITHUB_REPOSITORY=org/app \
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);
@@ -395,7 +395,7 @@ function onDefaultBranch(vars, branch) {
395
395
  if (UNKNOWN_ON_DEFAULT_BRANCH.has(name)) {
396
396
  throw new CeError(`ce: the default branch's cache scope cannot use {${name}}: this run does not know its commit`);
397
397
  }
398
- return name === "image" || name.startsWith("env.") ? vars.get(name) : undefined;
398
+ return name === "image" || name.startsWith("env.") || name.startsWith("arg.") ? vars.get(name) : undefined;
399
399
  }
400
400
  };
401
401
  }
@@ -494,12 +494,26 @@ function parse(template, vars) {
494
494
  text(template.slice(last));
495
495
  return parts;
496
496
  }
497
- function expand(template, vars, encode) {
498
- const parts = parse(template, vars);
497
+ function guardsPass(parts, vars) {
498
+ let unknown;
499
499
  for (const part of parts) {
500
- if ("name" in part && part.guard && vars.get(part.name) === undefined)
501
- 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
+ }
502
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;
503
517
  let out = "";
504
518
  for (const part of parts) {
505
519
  if ("text" in part)
@@ -514,16 +528,21 @@ function expand(template, vars, encode) {
514
528
  }
515
529
  return out;
516
530
  }
517
- function checkValue(template, vars, what) {
518
- if (parse(template, vars).some((part) => ("guard" in part) && part.guard)) {
519
- 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`);
520
535
  }
521
536
  }
522
- function expandValue(template, vars, encode, what) {
523
- checkValue(template, vars, what);
524
- for (const part of parse(template, vars)) {
525
- if ("name" in part && vars.get(part.name) === undefined) {
526
- 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}`);
527
546
  }
528
547
  }
529
548
  return expand(template, vars, encode);
@@ -585,7 +604,7 @@ function expandImage(template, vars) {
585
604
  return image;
586
605
  }
587
606
  function expandPrefix(prefix, vars, what) {
588
- return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what) : prefix;
607
+ return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what, true) : prefix;
589
608
  }
590
609
  function literalRef(repo, tag) {
591
610
  if (tag.includes("/")) {
@@ -608,7 +627,7 @@ function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
608
627
  if (hasPlaceholders(repoTemplate))
609
628
  checkValue(repoTemplate, vars, "image name");
610
629
  if (hasPlaceholders(tagPrefix))
611
- checkValue(tagPrefix, vars, "tag prefix");
630
+ checkValue(tagPrefix, vars, "tag prefix", true);
612
631
  let repo;
613
632
  const expandRepo = () => repo ??= hasPlaceholders(repoTemplate) ? expandImage(repoTemplate, vars) : repoTemplate;
614
633
  if (tagList === undefined)
@@ -738,8 +757,27 @@ var SUMMARIZED = {
738
757
  "--build-arg": "buildArgs",
739
758
  "--label": "labels"
740
759
  };
741
- function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {}, defaultBranch } = {}) {
742
- 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 = [];
743
781
  const rawCacheFrom = [];
744
782
  const rawCacheTo = [];
745
783
  const summary = {
@@ -754,16 +792,23 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
754
792
  context: ""
755
793
  };
756
794
  let templated = false;
795
+ const buildArgs = new Map;
796
+ let argFile = false;
757
797
  let rest = [];
758
798
  const addTags = (value, flag) => {
759
799
  templated ||= hasPlaceholders(value);
760
- const refs = rewriteTagValue(value, tagPrefix, vars);
761
- for (const ref of refs)
762
- args.push(`${flag}${ref}`);
763
- summary.tags.push(...refs);
800
+ parts.push({ tags: value, flag });
764
801
  };
765
802
  const summarize = (flag, value) => {
766
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
+ }
767
812
  if (field === "buildArgs" || field === "labels")
768
813
  summary[field].push(value);
769
814
  else
@@ -798,21 +843,34 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
798
843
  const flag = name;
799
844
  if (attached === undefined) {
800
845
  const v = next();
801
- args.push(arg, v);
846
+ parts.push(arg, v);
802
847
  summarize(flag, v);
803
848
  } else {
804
- args.push(arg);
849
+ parts.push(arg);
805
850
  summarize(flag, attached);
806
851
  }
852
+ } else if (name === "--build-arg-file") {
853
+ argFile = true;
854
+ parts.push(arg);
855
+ if (attached === undefined)
856
+ parts.push(next());
807
857
  } else if (arg.startsWith("-f") && arg.length > 2) {
808
- args.push(arg);
858
+ parts.push(arg);
809
859
  summary.dockerfile = arg.slice(2);
810
860
  } else {
811
- args.push(arg);
861
+ parts.push(arg);
812
862
  if (!arg.startsWith("-"))
813
863
  summary.context = arg;
814
864
  }
815
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
+ });
816
874
  if (templated && !summary.tags.length) {
817
875
  throw new CeError("ce: the guards in the -t templates left out every tag, so the image would have none");
818
876
  }
@@ -1212,6 +1270,7 @@ function engineCommand(engine, args) {
1212
1270
  }
1213
1271
  async function runEngine(engine, argv, tagPrefix, cachePrefix, options = {}) {
1214
1272
  const dryRun = options.dryRun ?? envFlag("CE_DRY_RUN");
1273
+ ensureHelperOnPath();
1215
1274
  const command = findBuildCommand(engine, argv);
1216
1275
  let args = argv;
1217
1276
  let summary;
@@ -1220,6 +1279,7 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix, options = {}) {
1220
1279
  const labels = !command.tagsOnly && (options.ociLabels ?? envFlag("OCI_LABELS")) ? vars.labels() : {};
1221
1280
  const fromDefault = !command.tagsOnly && (options.cacheFromDefault ?? envFlag("CACHE_FROM_DEFAULT"));
1222
1281
  const parsed = parseBuildArgs(argv.slice(command.start), tagPrefix, cachePrefix, {
1282
+ engine,
1223
1283
  vars,
1224
1284
  labels,
1225
1285
  defaultBranch: fromDefault ? () => vars.defaultBranch() : undefined
@@ -1233,11 +1293,12 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix, options = {}) {
1233
1293
  return 1;
1234
1294
  if (summary)
1235
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`);
1236
1298
  io.stdout(`${run.map(shellQuote).join(" ")}
1237
1299
  `);
1238
1300
  return 0;
1239
1301
  }
1240
- ensureHelperOnPath();
1241
1302
  if (!system.which(engine)) {
1242
1303
  logError(`Engine not found: ${engine}`);
1243
1304
  return 1;
@@ -1430,13 +1491,14 @@ Options:
1430
1491
  run to builds. A --label with the same key wins.
1431
1492
  Also available via OCI_LABELS=true.
1432
1493
  --dry-run Print the build summary and the engine command, and
1433
- exit without running it. Template errors still exit 2.
1494
+ exit without running it. Template errors still exit 2;
1495
+ a missing engine is only a warning.
1434
1496
  Also available via CE_DRY_RUN=true.
1435
1497
 
1436
1498
  Placeholders, in build tags and in the tag and cache prefixes:
1437
1499
  {name} The value of name; an error if it has none in this run.
1438
1500
  {?name} Expands to nothing; leaves the tag out unless name has
1439
- a value. Not allowed in prefixes.
1501
+ a value. In a prefix, leaves the prefix empty instead.
1440
1502
  {image} Registry host and repository, lowercased
1441
1503
  {ref} Branch name, pr-<number>, or tag name
1442
1504
  {branch} {pr} {tag}
@@ -1448,6 +1510,11 @@ Placeholders, in build tags and in the tag and cache prefixes:
1448
1510
  From a SemVer tag such as v1.2.3 (v optional)
1449
1511
  {stable} Set only for a SemVer tag without a pre-release
1450
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.
1451
1518
  Values are made safe for a tag ("ci/x" becomes "ci-x"). Quote templates
1452
1519
  in single quotes. Example:
1453
1520
  -t '{image}:{ref},{?default}latest'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izykitten/ce",
3
- "version": "0.0.3-dev.2",
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",