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

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 -6
  2. package/dist/ce.js +88 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,13 +64,11 @@ A branch, PR and default-branch build:
64
64
  -t '{image}:{ref},{?default}latest,{?default}caddy-{env.CADDY_VERSION}' .
65
65
  ```
66
66
 
67
- With `CACHE_PREFIX='{ref}'`, each branch and pull request gets a gha cache of its own, so a new one starts cold. To also read the default branch's cache, add it as a `--cache-from`. ce only prefixes a `--cache-from` whose scope matches a `--cache-to`, so this one is passed on unchanged and only read:
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.
68
68
 
69
- ```sh
70
- --cache type=gha,mode=max --cache-from type=gha,scope=main
71
- ```
69
+ 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
70
 
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.
71
+ 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
72
 
75
73
  A release on a SemVer tag, with the version aliases only for stable releases:
76
74
 
@@ -80,6 +78,15 @@ A release on a SemVer tag, with the version aliases only for stable releases:
80
78
 
81
79
  `{stable}` does not mean newest: rebuilding an older release moves its aliases back to it.
82
80
 
81
+ ### Checking a template
82
+
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:
84
+
85
+ ```sh
86
+ GITHUB_REF=refs/tags/v1.2.3 GITHUB_SERVER_URL=https://code.example GITHUB_REPOSITORY=org/app \
87
+ ce --dry-run docker buildx build -t '{image}:{ref},{?stable}{major}.{minor}' .
88
+ ```
89
+
83
90
  ### OCI labels
84
91
 
85
92
  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 +99,7 @@ The package installs `docker-credential-ce-forgejo` next to `ce`. `ce` puts that
92
99
 
93
100
  ## Compatibility
94
101
 
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:
102
+ 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
103
 
97
104
  - commands, flags and the environment variables `login` reads;
98
105
  - 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 +109,8 @@ These stay the same from release to release, and [test/contract.test.ts](test/co
102
109
 
103
110
  The wording of messages is not part of that and may change.
104
111
 
112
+ 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.
113
+
105
114
  Compared with the Python version:
106
115
 
107
116
  - 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
@@ -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.") ? vars.get(name) : undefined;
399
+ }
400
+ };
401
+ }
387
402
 
388
403
  // src/system.ts
389
404
  import { spawnSync } from "node:child_process";
@@ -664,6 +679,7 @@ function ghaFields(value) {
664
679
  const type = fields?.findLast((field) => fieldKey(field) === "type");
665
680
  return type?.slice("type=".length) === "gha" ? fields : null;
666
681
  }
682
+ var isGhaCache = (value) => ghaFields(value) !== null;
667
683
  var scopeIndex = (fields) => fields.findLastIndex((field) => fieldKey(field) === "scope");
668
684
  function cacheScopeName(value) {
669
685
  const fields = ghaFields(value);
@@ -697,6 +713,12 @@ function normalizeCacheScope(value, cachePrefix) {
697
713
  fields[i] = `scope=${scope}`;
698
714
  return csvJoin(fields);
699
715
  }
716
+ function withoutCacheMode(value) {
717
+ const fields = ghaFields(value);
718
+ if (!fields?.some((field) => fieldKey(field) === "mode"))
719
+ return value;
720
+ return csvJoin(fields.filter((field) => fieldKey(field) !== "mode"));
721
+ }
700
722
 
701
723
  // src/build-args.ts
702
724
  function consumeValue(argv, flag) {
@@ -716,7 +738,7 @@ var SUMMARIZED = {
716
738
  "--build-arg": "buildArgs",
717
739
  "--label": "labels"
718
740
  };
719
- function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
741
+ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {}, defaultBranch } = {}) {
720
742
  const args = [];
721
743
  const rawCacheFrom = [];
722
744
  const rawCacheTo = [];
@@ -770,7 +792,7 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
770
792
  rawCacheTo.push(attached ?? next());
771
793
  else if (name === "--cache") {
772
794
  const v = attached ?? next();
773
- rawCacheFrom.push(v);
795
+ rawCacheFrom.push(withoutCacheMode(v));
774
796
  rawCacheTo.push(v);
775
797
  } else if (Object.hasOwn(SUMMARIZED, name)) {
776
798
  const flag = name;
@@ -798,11 +820,17 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, l
798
820
  const added = Object.entries(labels).filter(([key]) => !ownKeys.has(key)).map(([key, value]) => `${key}=${value}`);
799
821
  args.unshift(...added.map((label) => `--label=${label}`));
800
822
  summary.labels.unshift(...added);
823
+ const prefixTemplate = cachePrefix;
801
824
  if (rawCacheFrom.length || rawCacheTo.length)
802
825
  cachePrefix = expandPrefix(cachePrefix, vars, "cache prefix");
803
826
  const toNames = new Set(rawCacheTo.map(cacheScopeName));
804
- for (const c of rawCacheFrom) {
805
- const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
827
+ const cacheFrom = rawCacheFrom.map((c) => toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c);
828
+ const ghaTo = rawCacheTo.filter(isGhaCache);
829
+ if (defaultBranch && ghaTo.length) {
830
+ const prefix = expandPrefix(prefixTemplate, onDefaultBranch(vars, defaultBranch()), "cache prefix");
831
+ cacheFrom.push(...ghaTo.map((c) => normalizeCacheScope(withoutCacheMode(c), prefix)));
832
+ }
833
+ for (const rewritten of new Set(cacheFrom)) {
806
834
  args.push(`--cache-from=${rewritten}`);
807
835
  summary.cacheFrom.push(rewritten);
808
836
  }
@@ -1169,17 +1197,46 @@ function envFlag(name) {
1169
1197
  return true;
1170
1198
  throw new CeError(`ce: ${name} must be true or false, not ${JSON.stringify(process.env[name])}`);
1171
1199
  }
1172
- async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1200
+ function shellQuote(arg) {
1201
+ return /^[A-Za-z0-9_@%+:,./-][A-Za-z0-9_@%+=:,./-]*$/u.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
1202
+ }
1203
+ function engineCommand(engine, args) {
1204
+ if (engine === "docker" || !system.which("gosu"))
1205
+ return [engine, ...args];
1206
+ const user = process.env.USER ?? "";
1207
+ if (!user) {
1208
+ logError("ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.");
1209
+ return null;
1210
+ }
1211
+ return ["gosu", user, engine, ...args];
1212
+ }
1213
+ async function runEngine(engine, argv, tagPrefix, cachePrefix, options = {}) {
1214
+ const dryRun = options.dryRun ?? envFlag("CE_DRY_RUN");
1173
1215
  const command = findBuildCommand(engine, argv);
1174
1216
  let args = argv;
1175
1217
  let summary;
1176
1218
  if (command) {
1177
1219
  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 });
1220
+ const labels = !command.tagsOnly && (options.ociLabels ?? envFlag("OCI_LABELS")) ? vars.labels() : {};
1221
+ const fromDefault = !command.tagsOnly && (options.cacheFromDefault ?? envFlag("CACHE_FROM_DEFAULT"));
1222
+ const parsed = parseBuildArgs(argv.slice(command.start), tagPrefix, cachePrefix, {
1223
+ vars,
1224
+ labels,
1225
+ defaultBranch: fromDefault ? () => vars.defaultBranch() : undefined
1226
+ });
1180
1227
  args = [...argv.slice(0, command.start), ...parsed.args];
1181
1228
  summary = parsed.summary;
1182
1229
  }
1230
+ if (dryRun) {
1231
+ const run = engineCommand(engine, args);
1232
+ if (!run)
1233
+ return 1;
1234
+ if (summary)
1235
+ printBuildSummary(engine, args, summary);
1236
+ io.stdout(`${run.map(shellQuote).join(" ")}
1237
+ `);
1238
+ return 0;
1239
+ }
1183
1240
  ensureHelperOnPath();
1184
1241
  if (!system.which(engine)) {
1185
1242
  logError(`Engine not found: ${engine}`);
@@ -1189,17 +1246,8 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
1189
1246
  return 1;
1190
1247
  if (summary)
1191
1248
  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]);
1249
+ const run = engineCommand(engine, args);
1250
+ return run ? system.exec(run[0], run) : 1;
1203
1251
  }
1204
1252
 
1205
1253
  // src/login.ts
@@ -1352,7 +1400,8 @@ async function login() {
1352
1400
 
1353
1401
  // src/cli.ts
1354
1402
  function helpText() {
1355
- return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
1403
+ return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels]
1404
+ [--cache-from-default] [--dry-run] [command|engine] [args]
1356
1405
 
1357
1406
  Options:
1358
1407
  --tag-prefix=<prefix>
@@ -1368,10 +1417,21 @@ Options:
1368
1417
  Also available via CACHE_PREFIX environment variable.
1369
1418
  --cache=<spec> Shorthand for passing <spec> to both --cache-from and
1370
1419
  --cache-to. Supports the same forms as --cache-from/--cache-to.
1420
+ A gha cache-from made this way leaves out mode=, which
1421
+ only applies to export. The same cache-from given twice
1422
+ is passed once.
1423
+ --cache-from-default
1424
+ Also read each gha --cache-to from its scope on the
1425
+ default branch, from the cache prefix as it is there,
1426
+ so a new branch or PR starts from its cache.
1427
+ Also available via CACHE_FROM_DEFAULT=true.
1371
1428
  --oci-labels Add org.opencontainers.image.* labels (source, revision,
1372
1429
  version, title, description) from the Forgejo Actions
1373
1430
  run to builds. A --label with the same key wins.
1374
1431
  Also available via OCI_LABELS=true.
1432
+ --dry-run Print the build summary and the engine command, and
1433
+ exit without running it. Template errors still exit 2.
1434
+ Also available via CE_DRY_RUN=true.
1375
1435
 
1376
1436
  Placeholders, in build tags and in the tag and cache prefixes:
1377
1437
  {name} The value of name; an error if it has none in this run.
@@ -1428,7 +1488,7 @@ Environment variables (used by login):
1428
1488
  `;
1429
1489
  }
1430
1490
  async function main(argv) {
1431
- let ociLabels;
1491
+ const options = {};
1432
1492
  let tagPrefix = process.env.TAG_PREFIX ?? "";
1433
1493
  let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
1434
1494
  for (;; ) {
@@ -1442,7 +1502,11 @@ async function main(argv) {
1442
1502
  else if (a?.startsWith("--cache-prefix="))
1443
1503
  [cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
1444
1504
  else if (a === "--oci-labels")
1445
- [ociLabels, argv] = [true, argv.slice(1)];
1505
+ [options.ociLabels, argv] = [true, argv.slice(1)];
1506
+ else if (a === "--cache-from-default")
1507
+ [options.cacheFromDefault, argv] = [true, argv.slice(1)];
1508
+ else if (a === "--dry-run")
1509
+ [options.dryRun, argv] = [true, argv.slice(1)];
1446
1510
  else
1447
1511
  break;
1448
1512
  }
@@ -1456,7 +1520,7 @@ async function main(argv) {
1456
1520
  if (command === "credential-helper")
1457
1521
  return credentialHelper(rest);
1458
1522
  if (isEngine(command))
1459
- return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
1523
+ return runEngine(command, rest, tagPrefix, cachePrefix, options);
1460
1524
  io.stderr(helpText());
1461
1525
  return 1;
1462
1526
  }
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.2",
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",