@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.
- package/LICENSE +11 -0
- package/README.md +50 -1
- package/dist/ce.js +652 -142
- 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
|
|
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/
|
|
16
|
-
import {
|
|
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
|
|
22
|
-
stderr: (text) => void
|
|
23
|
-
stdin: () =>
|
|
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(
|
|
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
|
-
|
|
42
|
-
if (
|
|
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
|
|
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(
|
|
450
|
+
const parts = (process.env.PATH ?? "").split(path2.delimiter);
|
|
79
451
|
if (!parts.includes(dir))
|
|
80
|
-
process.env.PATH = [dir, ...parts].join(
|
|
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
|
|
87
|
-
var
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
141
|
-
if (!
|
|
663
|
+
const fields = ghaFields(value);
|
|
664
|
+
if (!fields)
|
|
142
665
|
return value;
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
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
|
|
151
|
-
if (!
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 === "
|
|
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
|
|
268
|
-
if (
|
|
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
|
-
|
|
693
|
-
const
|
|
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
|
-
|
|
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
|
|
861
|
-
--cache-from
|
|
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.
|
|
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": "
|
|
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
|
+
}
|