@izykitten/ce 0.0.2 → 0.0.3-dev.1
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 +58 -1
- package/dist/ce.js +698 -167
- 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,66 @@ 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 or tag |
|
|
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, `{?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
|
+
|
|
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
|
+
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:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
--cache type=gha,mode=max --cache-from type=gha,scope=main
|
|
71
|
+
```
|
|
72
|
+
|
|
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.
|
|
74
|
+
|
|
75
|
+
A release on a SemVer tag, with the version aliases only for stable releases:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
-t '{image}:{ref},{?version}{version},{?stable}{major}.{minor},{?stable}{major},sha-{sha}'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`{stable}` does not mean newest: rebuilding an older release moves its aliases back to it.
|
|
82
|
+
|
|
83
|
+
### OCI labels
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
30
87
|
## The credential helper
|
|
31
88
|
|
|
32
89
|
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,275 @@ 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.#ciEnv("EVENT_PATH") ? this.#payload().repository : undefined;
|
|
364
|
+
const fromEvent = isObject(repository) ? repository.default_branch : undefined;
|
|
365
|
+
const branch = typeof fromEvent === "string" && fromEvent || nonEmpty(this.#env.DEFAULT_BRANCH);
|
|
366
|
+
if (!branch) {
|
|
367
|
+
throw new CeError("ce: {default} needs repository.default_branch in the event file or DEFAULT_BRANCH, and neither is set");
|
|
368
|
+
}
|
|
369
|
+
return branch;
|
|
370
|
+
}
|
|
371
|
+
#payload() {
|
|
372
|
+
if (this.#event)
|
|
373
|
+
return this.#event;
|
|
374
|
+
const file = this.#require("EVENT_PATH", "reading the event file");
|
|
375
|
+
let payload;
|
|
376
|
+
try {
|
|
377
|
+
payload = readJsonFile(file, undefined);
|
|
378
|
+
} catch (exc) {
|
|
379
|
+
throw new CeError(`ce: ${errorMessage(exc)}`);
|
|
380
|
+
}
|
|
381
|
+
if (!isObject(payload))
|
|
382
|
+
throw new CeError(`ce: the event file ${JSON.stringify(file)} is missing or not a JSON object`);
|
|
383
|
+
this.#event = payload;
|
|
384
|
+
return payload;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/system.ts
|
|
389
|
+
import { spawnSync } from "node:child_process";
|
|
390
|
+
import fs2 from "node:fs";
|
|
391
|
+
import path2 from "node:path";
|
|
19
392
|
import { setTimeout } from "node:timers/promises";
|
|
20
393
|
var io = {
|
|
21
|
-
stdout: (text) => void
|
|
22
|
-
stderr: (text) => void
|
|
23
|
-
stdin: () =>
|
|
394
|
+
stdout: (text) => void fs2.writeSync(1, text),
|
|
395
|
+
stderr: (text) => void fs2.writeSync(2, text),
|
|
396
|
+
stdin: () => fs2.readFileSync(0, "utf8"),
|
|
24
397
|
argv0: () => process.argv[1] ?? "ce"
|
|
25
398
|
};
|
|
26
399
|
function log(line) {
|
|
@@ -35,11 +408,11 @@ function childEnv() {
|
|
|
35
408
|
return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
|
|
36
409
|
}
|
|
37
410
|
function which(command) {
|
|
38
|
-
const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(
|
|
411
|
+
const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean).map((dir) => path2.join(dir, command));
|
|
39
412
|
for (const candidate of candidates) {
|
|
40
413
|
try {
|
|
41
|
-
|
|
42
|
-
if (
|
|
414
|
+
fs2.accessSync(candidate, fs2.constants.X_OK);
|
|
415
|
+
if (fs2.statSync(candidate).isFile())
|
|
43
416
|
return candidate;
|
|
44
417
|
} catch {}
|
|
45
418
|
}
|
|
@@ -71,20 +444,81 @@ var system = {
|
|
|
71
444
|
}
|
|
72
445
|
};
|
|
73
446
|
function binDir() {
|
|
74
|
-
return
|
|
447
|
+
return fs2.realpathSync(path2.join(import.meta.dirname, "..", "bin"));
|
|
75
448
|
}
|
|
76
449
|
function ensureHelperOnPath() {
|
|
77
450
|
const dir = binDir();
|
|
78
|
-
const parts = (process.env.PATH ?? "").split(
|
|
451
|
+
const parts = (process.env.PATH ?? "").split(path2.delimiter);
|
|
79
452
|
if (!parts.includes(dir))
|
|
80
|
-
process.env.PATH = [dir, ...parts].join(
|
|
453
|
+
process.env.PATH = [dir, ...parts].join(path2.delimiter);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/template.ts
|
|
457
|
+
var PLACEHOLDER = /\{([^{}]*)\}/gu;
|
|
458
|
+
function hasPlaceholders(value) {
|
|
459
|
+
return value.includes("{") || value.includes("}");
|
|
460
|
+
}
|
|
461
|
+
function parse(template, vars) {
|
|
462
|
+
const parts = [];
|
|
463
|
+
const text = (value) => {
|
|
464
|
+
if (hasPlaceholders(value))
|
|
465
|
+
throw new CeError(`ce: unmatched brace in ${JSON.stringify(template)}`);
|
|
466
|
+
if (value)
|
|
467
|
+
parts.push({ text: value });
|
|
468
|
+
};
|
|
469
|
+
let last = 0;
|
|
470
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
471
|
+
text(template.slice(last, match.index));
|
|
472
|
+
last = match.index + match[0].length;
|
|
473
|
+
const guard = match[1].startsWith("?");
|
|
474
|
+
const name = guard ? match[1].slice(1) : match[1];
|
|
475
|
+
if (!vars.known(name))
|
|
476
|
+
throw new CeError(`ce: unknown placeholder ${match[0]} in ${JSON.stringify(template)}`);
|
|
477
|
+
parts.push({ name, guard });
|
|
478
|
+
}
|
|
479
|
+
text(template.slice(last));
|
|
480
|
+
return parts;
|
|
481
|
+
}
|
|
482
|
+
function expand(template, vars, encode) {
|
|
483
|
+
const parts = parse(template, vars);
|
|
484
|
+
for (const part of parts) {
|
|
485
|
+
if ("name" in part && part.guard && vars.get(part.name) === undefined)
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
let out = "";
|
|
489
|
+
for (const part of parts) {
|
|
490
|
+
if ("text" in part)
|
|
491
|
+
out += part.text;
|
|
492
|
+
else if (!part.guard) {
|
|
493
|
+
const value = vars.get(part.name);
|
|
494
|
+
if (value === undefined) {
|
|
495
|
+
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}}.`);
|
|
496
|
+
}
|
|
497
|
+
out += encode(value);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
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`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
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)}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return expand(template, vars, encode);
|
|
81
515
|
}
|
|
82
516
|
|
|
83
517
|
// src/tags.ts
|
|
84
518
|
var INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
|
|
85
519
|
var INVALID_SCOPE_CHARS = /[^A-Za-z0-9_.:/\-]/gu;
|
|
86
|
-
var
|
|
87
|
-
var
|
|
520
|
+
var VALID_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u;
|
|
521
|
+
var HOST_PORT = /^([^/:]+):(?:\d|\{[^{}]*\})+\//u;
|
|
88
522
|
var SEPARATORS = "-_.";
|
|
89
523
|
function sanitizeChars(value, charRegex) {
|
|
90
524
|
return value.replace(charRegex, "_");
|
|
@@ -105,62 +539,163 @@ function splitOnce(value, sep) {
|
|
|
105
539
|
const i = value.indexOf(sep);
|
|
106
540
|
return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
|
|
107
541
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
542
|
+
function splitRef(value) {
|
|
543
|
+
const host = HOST_PORT.exec(value);
|
|
544
|
+
const isHost = host && (host[1].includes(".") || host[1] === "localhost" || value.includes(":", host[0].length));
|
|
545
|
+
const offset = isHost ? host[0].length : 0;
|
|
546
|
+
const i = value.indexOf(":", offset);
|
|
547
|
+
return i < 0 ? null : [value.slice(0, i), value.slice(i + 1)];
|
|
548
|
+
}
|
|
549
|
+
function assertValidTag(repo, tag) {
|
|
550
|
+
if (!VALID_TAG.test(tag)) {
|
|
551
|
+
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 "-".`);
|
|
552
|
+
}
|
|
553
|
+
return `${repo}:${tag}`;
|
|
554
|
+
}
|
|
555
|
+
function encodeTagValue(value) {
|
|
556
|
+
assertAscii("placeholder value", value);
|
|
557
|
+
return value.replace(INVALID_TAG_CHARS, "-");
|
|
558
|
+
}
|
|
559
|
+
function encodeRepoValue(value) {
|
|
560
|
+
assertAscii("placeholder value", value);
|
|
561
|
+
if (/[^A-Za-z0-9._:/-]/u.test(value))
|
|
562
|
+
throw new CeError(`ce: ${JSON.stringify(value)} cannot go in an image name`);
|
|
563
|
+
return value;
|
|
564
|
+
}
|
|
565
|
+
function expandImage(template, vars) {
|
|
566
|
+
const image = expandValue(template, vars, encodeRepoValue, "image name");
|
|
567
|
+
if (!/^(?:[^/:]+:\d+\/)?[^:]*$/u.test(image)) {
|
|
568
|
+
throw new CeError(`ce: image name ${JSON.stringify(image)} from ${JSON.stringify(template)} has a ":" that is not a registry port`);
|
|
569
|
+
}
|
|
570
|
+
return image;
|
|
571
|
+
}
|
|
572
|
+
function expandPrefix(prefix, vars, what) {
|
|
573
|
+
return hasPlaceholders(prefix) ? expandValue(prefix, vars, encodeTagValue, what) : prefix;
|
|
574
|
+
}
|
|
575
|
+
function literalRef(repo, tag) {
|
|
576
|
+
if (tag.includes("/")) {
|
|
577
|
+
const [first, rest] = splitOnce(tag, "/");
|
|
578
|
+
return `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
|
|
120
579
|
}
|
|
121
|
-
|
|
122
|
-
return m ? `${m[1]}/renovate:${m[2]}` : out;
|
|
580
|
+
return `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
|
|
123
581
|
}
|
|
124
582
|
function computeSafePrefix(prefix) {
|
|
125
583
|
assertAscii("tag prefix", prefix);
|
|
126
584
|
const p = sanitizeChars(prefix, INVALID_TAG_CHARS);
|
|
127
585
|
return p && !SEPARATORS.includes(p.at(-1)) ? `${p}-` : p;
|
|
128
586
|
}
|
|
129
|
-
function rewriteTagValue(value, tagPrefix) {
|
|
587
|
+
function rewriteTagValue(value, tagPrefix, vars = new CiMetadata) {
|
|
588
|
+
assertAscii("tag", value);
|
|
130
589
|
const v = stripProto(value);
|
|
131
|
-
if (!v
|
|
132
|
-
|
|
133
|
-
|
|
590
|
+
if (!v)
|
|
591
|
+
throw new CeError("ce: empty -t/--tag value");
|
|
592
|
+
const [repoTemplate, tagList] = splitRef(v) ?? [v, undefined];
|
|
593
|
+
if (hasPlaceholders(repoTemplate))
|
|
594
|
+
checkValue(repoTemplate, vars, "image name");
|
|
595
|
+
if (hasPlaceholders(tagPrefix))
|
|
596
|
+
checkValue(tagPrefix, vars, "tag prefix");
|
|
597
|
+
let repo;
|
|
598
|
+
const expandRepo = () => repo ??= hasPlaceholders(repoTemplate) ? expandImage(repoTemplate, vars) : repoTemplate;
|
|
599
|
+
if (tagList === undefined)
|
|
600
|
+
return [expandRepo()];
|
|
601
|
+
const tags = tagList.split(/[,\s]+/u).filter(Boolean);
|
|
602
|
+
if (!tags.length)
|
|
603
|
+
throw new CeError(`ce: no tag after the ":" in ${JSON.stringify(value)}`);
|
|
604
|
+
let safePrefix;
|
|
605
|
+
const refs = [];
|
|
606
|
+
for (const tag of tags) {
|
|
607
|
+
const expanded = hasPlaceholders(tag) ? expand(tag, vars, encodeTagValue) : tag;
|
|
608
|
+
if (expanded === null)
|
|
609
|
+
continue;
|
|
610
|
+
safePrefix ??= computeSafePrefix(expandPrefix(tagPrefix, vars, "tag prefix"));
|
|
611
|
+
if (hasPlaceholders(tag)) {
|
|
612
|
+
refs.push(assertValidTag(expandRepo(), `${safePrefix}${sanitizeChars(expanded, INVALID_TAG_CHARS)}`));
|
|
613
|
+
} else {
|
|
614
|
+
const ref = literalRef(expandRepo(), `${safePrefix}${tag}`);
|
|
615
|
+
const i = ref.lastIndexOf(":");
|
|
616
|
+
refs.push(assertValidTag(ref.slice(0, i), ref.slice(i + 1)));
|
|
617
|
+
}
|
|
134
618
|
}
|
|
135
|
-
|
|
136
|
-
const safePrefix = computeSafePrefix(tagPrefix);
|
|
137
|
-
return tags.split(/[,\s]+/u).filter(Boolean).map((tag) => sanitizeTag(`${repo}:${safePrefix}${tag}`)).filter(Boolean);
|
|
619
|
+
return refs;
|
|
138
620
|
}
|
|
621
|
+
function csvFields(value) {
|
|
622
|
+
value = value.replace(/\r?\n$/u, "");
|
|
623
|
+
if (!value)
|
|
624
|
+
return null;
|
|
625
|
+
const fields = [];
|
|
626
|
+
let i = 0;
|
|
627
|
+
for (;; ) {
|
|
628
|
+
if (value[i] !== '"') {
|
|
629
|
+
const end = value.indexOf(",", i);
|
|
630
|
+
const field = end < 0 ? value.slice(i) : value.slice(i, end);
|
|
631
|
+
if (field.includes('"'))
|
|
632
|
+
return null;
|
|
633
|
+
fields.push(field);
|
|
634
|
+
if (end < 0)
|
|
635
|
+
return fields;
|
|
636
|
+
i = end + 1;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
let field = "";
|
|
640
|
+
for (i++;; i++) {
|
|
641
|
+
const quote = value.indexOf('"', i);
|
|
642
|
+
if (quote < 0)
|
|
643
|
+
return null;
|
|
644
|
+
field += value.slice(i, quote);
|
|
645
|
+
i = quote + 1;
|
|
646
|
+
if (value[i] !== '"')
|
|
647
|
+
break;
|
|
648
|
+
field += '"';
|
|
649
|
+
}
|
|
650
|
+
fields.push(field);
|
|
651
|
+
if (i === value.length)
|
|
652
|
+
return fields;
|
|
653
|
+
if (value[i] !== ",")
|
|
654
|
+
return null;
|
|
655
|
+
i++;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function csvJoin(fields) {
|
|
659
|
+
return fields.map((field) => /^[ \t]|[",\r\n]/u.test(field) ? `"${field.replaceAll('"', '""')}"` : field).join(",");
|
|
660
|
+
}
|
|
661
|
+
var fieldKey = (field) => field.split("=", 1)[0].toLowerCase();
|
|
662
|
+
function ghaFields(value) {
|
|
663
|
+
const fields = csvFields(value);
|
|
664
|
+
const type = fields?.findLast((field) => fieldKey(field) === "type");
|
|
665
|
+
return type?.slice("type=".length) === "gha" ? fields : null;
|
|
666
|
+
}
|
|
667
|
+
var scopeIndex = (fields) => fields.findLastIndex((field) => fieldKey(field) === "scope");
|
|
139
668
|
function cacheScopeName(value) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
669
|
+
const fields = ghaFields(value);
|
|
670
|
+
if (!fields)
|
|
142
671
|
return value;
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
672
|
+
const i = scopeIndex(fields);
|
|
673
|
+
if (i < 0)
|
|
145
674
|
return "";
|
|
675
|
+
const scope = fields[i].slice("scope=".length);
|
|
146
676
|
assertAscii("cache scope", scope);
|
|
147
677
|
return sanitizeChars(scope, INVALID_SCOPE_CHARS);
|
|
148
678
|
}
|
|
149
679
|
function normalizeCacheScope(value, cachePrefix) {
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
680
|
+
const fields = ghaFields(value);
|
|
681
|
+
if (!fields)
|
|
152
682
|
return value;
|
|
153
683
|
if (!cachePrefix)
|
|
154
684
|
return value;
|
|
155
685
|
assertAscii("cache prefix", cachePrefix);
|
|
156
686
|
const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
|
|
157
|
-
let scope =
|
|
687
|
+
let scope = cacheScopeName(value);
|
|
158
688
|
if (!scope) {
|
|
159
689
|
scope = prefix.replace(/[-_.]+$/u, "");
|
|
160
690
|
} else if (prefix) {
|
|
161
691
|
scope = SEPARATORS.includes(prefix.at(-1)) ? `${prefix}${scope}` : `${prefix}-${scope}`;
|
|
162
692
|
}
|
|
163
|
-
|
|
693
|
+
const i = scopeIndex(fields);
|
|
694
|
+
if (i < 0)
|
|
695
|
+
fields.push(`scope=${scope}`);
|
|
696
|
+
else
|
|
697
|
+
fields[i] = `scope=${scope}`;
|
|
698
|
+
return csvJoin(fields);
|
|
164
699
|
}
|
|
165
700
|
|
|
166
701
|
// src/build-args.ts
|
|
@@ -178,9 +713,10 @@ var SUMMARIZED = {
|
|
|
178
713
|
"--file": "dockerfile",
|
|
179
714
|
"--target": "target",
|
|
180
715
|
"--platform": "platform",
|
|
181
|
-
"--build-arg": "buildArgs"
|
|
716
|
+
"--build-arg": "buildArgs",
|
|
717
|
+
"--label": "labels"
|
|
182
718
|
};
|
|
183
|
-
function parseBuildArgs(argv, tagPrefix, cachePrefix) {
|
|
719
|
+
function parseBuildArgs(argv, tagPrefix, cachePrefix, { vars = new CiMetadata, labels = {} } = {}) {
|
|
184
720
|
const args = [];
|
|
185
721
|
const rawCacheFrom = [];
|
|
186
722
|
const rawCacheTo = [];
|
|
@@ -189,21 +725,25 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
|
|
|
189
725
|
cacheFrom: [],
|
|
190
726
|
cacheTo: [],
|
|
191
727
|
buildArgs: [],
|
|
728
|
+
labels: [],
|
|
192
729
|
dockerfile: "",
|
|
193
730
|
target: "",
|
|
194
731
|
platform: "",
|
|
195
732
|
context: ""
|
|
196
733
|
};
|
|
734
|
+
let templated = false;
|
|
735
|
+
let rest = [];
|
|
197
736
|
const addTags = (value, flag) => {
|
|
198
|
-
|
|
737
|
+
templated ||= hasPlaceholders(value);
|
|
738
|
+
const refs = rewriteTagValue(value, tagPrefix, vars);
|
|
199
739
|
for (const ref of refs)
|
|
200
740
|
args.push(`${flag}${ref}`);
|
|
201
741
|
summary.tags.push(...refs);
|
|
202
742
|
};
|
|
203
743
|
const summarize = (flag, value) => {
|
|
204
744
|
const field = SUMMARIZED[flag];
|
|
205
|
-
if (field === "buildArgs")
|
|
206
|
-
summary.
|
|
745
|
+
if (field === "buildArgs" || field === "labels")
|
|
746
|
+
summary[field].push(value);
|
|
207
747
|
else
|
|
208
748
|
summary[field] = value;
|
|
209
749
|
};
|
|
@@ -211,7 +751,12 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
|
|
|
211
751
|
const arg = argv[i];
|
|
212
752
|
const [name, attached] = arg.startsWith("--") && arg.includes("=") ? splitAt(arg, "=") : [arg, undefined];
|
|
213
753
|
const next = () => consumeValue(argv.slice(++i), arg)[0];
|
|
214
|
-
if (arg === "
|
|
754
|
+
if (arg === "--") {
|
|
755
|
+
rest = argv.slice(i);
|
|
756
|
+
if (rest.length > 1)
|
|
757
|
+
summary.context = rest.at(-1);
|
|
758
|
+
break;
|
|
759
|
+
} else if (arg === "-t")
|
|
215
760
|
addTags(next(), "-t");
|
|
216
761
|
else if (arg === "--tag")
|
|
217
762
|
addTags(next(), "--tag=");
|
|
@@ -246,6 +791,15 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
|
|
|
246
791
|
summary.context = arg;
|
|
247
792
|
}
|
|
248
793
|
}
|
|
794
|
+
if (templated && !summary.tags.length) {
|
|
795
|
+
throw new CeError("ce: the guards in the -t templates left out every tag, so the image would have none");
|
|
796
|
+
}
|
|
797
|
+
const ownKeys = new Set(summary.labels.map((label) => label.split("=", 1)[0]));
|
|
798
|
+
const added = Object.entries(labels).filter(([key]) => !ownKeys.has(key)).map(([key, value]) => `${key}=${value}`);
|
|
799
|
+
args.unshift(...added.map((label) => `--label=${label}`));
|
|
800
|
+
summary.labels.unshift(...added);
|
|
801
|
+
if (rawCacheFrom.length || rawCacheTo.length)
|
|
802
|
+
cachePrefix = expandPrefix(cachePrefix, vars, "cache prefix");
|
|
249
803
|
const toNames = new Set(rawCacheTo.map(cacheScopeName));
|
|
250
804
|
for (const c of rawCacheFrom) {
|
|
251
805
|
const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
|
|
@@ -257,6 +811,7 @@ function parseBuildArgs(argv, tagPrefix, cachePrefix) {
|
|
|
257
811
|
args.push(`--cache-to=${rewritten}`);
|
|
258
812
|
summary.cacheTo.push(rewritten);
|
|
259
813
|
}
|
|
814
|
+
args.push(...rest);
|
|
260
815
|
return { args, summary };
|
|
261
816
|
}
|
|
262
817
|
function splitAt(value, sep) {
|
|
@@ -264,9 +819,10 @@ function splitAt(value, sep) {
|
|
|
264
819
|
return [value.slice(0, i), value.slice(i + 1)];
|
|
265
820
|
}
|
|
266
821
|
function printBuildSummary(engine, args, summary) {
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
822
|
+
const command = findBuildCommand(engine, args);
|
|
823
|
+
if (!command || command.tagsOnly)
|
|
269
824
|
return;
|
|
825
|
+
const subcommand = command.name;
|
|
270
826
|
const out = ["[ce] build summary:", ` engine: ${engine}`, ` subcommand: ${subcommand}`];
|
|
271
827
|
const list = (title, items) => {
|
|
272
828
|
if (items.length)
|
|
@@ -282,6 +838,7 @@ function printBuildSummary(engine, args, summary) {
|
|
|
282
838
|
if (summary.platform)
|
|
283
839
|
out.push(` platform: ${summary.platform}`);
|
|
284
840
|
list("build args", summary.buildArgs);
|
|
841
|
+
list("labels", summary.labels);
|
|
285
842
|
if (summary.context)
|
|
286
843
|
out.push(` context: ${summary.context}`);
|
|
287
844
|
io.stderr(`${out.join(`
|
|
@@ -290,91 +847,6 @@ function printBuildSummary(engine, args, summary) {
|
|
|
290
847
|
`);
|
|
291
848
|
}
|
|
292
849
|
|
|
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
850
|
// src/oidc.ts
|
|
379
851
|
var REFRESH_WINDOW_S = 5 * 60;
|
|
380
852
|
var REQUEST_TIMEOUT_MS = 30000;
|
|
@@ -689,8 +1161,25 @@ async function waitForEngine(engine) {
|
|
|
689
1161
|
logError(`no engine available: ${engine}`);
|
|
690
1162
|
return false;
|
|
691
1163
|
}
|
|
692
|
-
|
|
693
|
-
const
|
|
1164
|
+
function envFlag(name) {
|
|
1165
|
+
const value = (process.env[name] ?? "").toLowerCase();
|
|
1166
|
+
if (["", "0", "false", "no"].includes(value))
|
|
1167
|
+
return false;
|
|
1168
|
+
if (["1", "true", "yes"].includes(value))
|
|
1169
|
+
return true;
|
|
1170
|
+
throw new CeError(`ce: ${name} must be true or false, not ${JSON.stringify(process.env[name])}`);
|
|
1171
|
+
}
|
|
1172
|
+
async function runEngine(engine, argv, tagPrefix, cachePrefix, ociLabels) {
|
|
1173
|
+
const command = findBuildCommand(engine, argv);
|
|
1174
|
+
let args = argv;
|
|
1175
|
+
let summary;
|
|
1176
|
+
if (command) {
|
|
1177
|
+
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 });
|
|
1180
|
+
args = [...argv.slice(0, command.start), ...parsed.args];
|
|
1181
|
+
summary = parsed.summary;
|
|
1182
|
+
}
|
|
694
1183
|
ensureHelperOnPath();
|
|
695
1184
|
if (!system.which(engine)) {
|
|
696
1185
|
logError(`Engine not found: ${engine}`);
|
|
@@ -698,7 +1187,8 @@ async function runEngine(engine, argv, tagPrefix, cachePrefix) {
|
|
|
698
1187
|
}
|
|
699
1188
|
if (!await waitForEngine(engine))
|
|
700
1189
|
return 1;
|
|
701
|
-
|
|
1190
|
+
if (summary)
|
|
1191
|
+
printBuildSummary(engine, args, summary);
|
|
702
1192
|
if (engine === "docker")
|
|
703
1193
|
return system.exec("docker", ["docker", ...args]);
|
|
704
1194
|
if (system.which("gosu")) {
|
|
@@ -719,14 +1209,18 @@ function registryGroups() {
|
|
|
719
1209
|
const suffixed = Object.keys(process.env).filter((name) => name.startsWith(prefix) && name.length > prefix.length).sort().map((name) => name.slice(prefix.length));
|
|
720
1210
|
return [...process.env.REGISTRY_HOST ? [""] : [], ...suffixed];
|
|
721
1211
|
}
|
|
1212
|
+
function unsetOrEmpty(name) {
|
|
1213
|
+
return `${name} is ${process.env[name] === undefined ? "unset" : "empty"}`;
|
|
1214
|
+
}
|
|
722
1215
|
function oidcConfig() {
|
|
723
1216
|
const audience = process.env.AUD ?? "";
|
|
724
1217
|
if (!audience)
|
|
725
1218
|
return null;
|
|
726
1219
|
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
|
|
727
1220
|
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
|
|
728
|
-
|
|
729
|
-
|
|
1221
|
+
const missing = ["ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"].filter((name) => !process.env[name]);
|
|
1222
|
+
if (missing.length) {
|
|
1223
|
+
return `Forgejo OIDC login config is incomplete: AUD is set, but ${missing.map(unsetOrEmpty).join("; ")}`;
|
|
730
1224
|
}
|
|
731
1225
|
try {
|
|
732
1226
|
assertAbsoluteHttpUrl(requestUrl, "must be an absolute HTTP(S) URL");
|
|
@@ -773,36 +1267,46 @@ async function login() {
|
|
|
773
1267
|
let failed = false;
|
|
774
1268
|
for (const group of groups) {
|
|
775
1269
|
const suffix = group ? `__${group}` : "";
|
|
776
|
-
const
|
|
777
|
-
const
|
|
778
|
-
const
|
|
1270
|
+
const hostName = `REGISTRY_HOST${suffix}`;
|
|
1271
|
+
const userName = `REGISTRY_USER${suffix}`;
|
|
1272
|
+
const passwordName = `REGISTRY_PASSWORD${suffix}`;
|
|
1273
|
+
const host = process.env[hostName] ?? "";
|
|
1274
|
+
const user = process.env[userName] ?? "";
|
|
1275
|
+
const password = process.env[passwordName] ?? "";
|
|
779
1276
|
const label = group || "default";
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
failed = true;
|
|
784
|
-
continue;
|
|
785
|
-
}
|
|
786
|
-
for (const rawRegistry of host.split(/\s+/u).filter(Boolean)) {
|
|
787
|
-
let authority;
|
|
788
|
-
let registry;
|
|
1277
|
+
const hosts = host.split(/\s+/u).filter(Boolean);
|
|
1278
|
+
const registries = [];
|
|
1279
|
+
for (const rawRegistry of hosts) {
|
|
789
1280
|
try {
|
|
790
|
-
authority = urlAuthority(rawRegistry);
|
|
791
|
-
registry = registryServer(rawRegistry);
|
|
1281
|
+
const authority = urlAuthority(rawRegistry);
|
|
1282
|
+
const registry = registryServer(rawRegistry);
|
|
1283
|
+
registries.push({ registry, useOidc: oidc !== null && sameAuthority(authority, oidc.authority) });
|
|
792
1284
|
} catch (exc) {
|
|
793
1285
|
if (!(exc instanceof CeError))
|
|
794
1286
|
throw exc;
|
|
795
|
-
logError(`Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`);
|
|
796
|
-
failed = true;
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
const useOidc = oidc !== null && sameAuthority(authority, oidc.authority);
|
|
800
|
-
if (!useOidc && !password) {
|
|
801
|
-
logError(incomplete);
|
|
1287
|
+
logError(rawRegistry.includes("@") ? `Invalid registry host for '${label}' in ${hostName}, not shown as it has an "@" and may hold a password` : `Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`);
|
|
802
1288
|
failed = true;
|
|
803
|
-
continue;
|
|
804
1289
|
}
|
|
805
|
-
|
|
1290
|
+
}
|
|
1291
|
+
const missing = [];
|
|
1292
|
+
if (!host)
|
|
1293
|
+
missing.push(unsetOrEmpty(hostName));
|
|
1294
|
+
else if (!hosts.length)
|
|
1295
|
+
missing.push(`${hostName} has no registry in it`);
|
|
1296
|
+
if (!user)
|
|
1297
|
+
missing.push(unsetOrEmpty(userName));
|
|
1298
|
+
const needPassword = registries.filter((r) => !r.useOidc).map((r) => r.registry);
|
|
1299
|
+
if (!password && needPassword.length)
|
|
1300
|
+
missing.push(`${unsetOrEmpty(passwordName)}, which ${needPassword.join(", ")} needs`);
|
|
1301
|
+
if (missing.length) {
|
|
1302
|
+
logError(`Login config for '${label}' is incomplete: ${missing.join("; ")}`);
|
|
1303
|
+
failed = true;
|
|
1304
|
+
}
|
|
1305
|
+
if (!user)
|
|
1306
|
+
continue;
|
|
1307
|
+
for (const { registry, useOidc } of registries) {
|
|
1308
|
+
if (useOidc || password)
|
|
1309
|
+
targets.push({ registry, label, user, password, useOidc });
|
|
806
1310
|
}
|
|
807
1311
|
}
|
|
808
1312
|
let jwt = null;
|
|
@@ -848,7 +1352,7 @@ async function login() {
|
|
|
848
1352
|
|
|
849
1353
|
// src/cli.ts
|
|
850
1354
|
function helpText() {
|
|
851
|
-
return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [command|engine] [args]
|
|
1355
|
+
return `Usage: ${io.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [--oci-labels] [command|engine] [args]
|
|
852
1356
|
|
|
853
1357
|
Options:
|
|
854
1358
|
--tag-prefix=<prefix>
|
|
@@ -857,12 +1361,36 @@ Options:
|
|
|
857
1361
|
does not already end with "-", "_", or ".".
|
|
858
1362
|
Also available via TAG_PREFIX environment variable.
|
|
859
1363
|
--cache-prefix=<prefix>
|
|
860
|
-
Prepend <prefix> to
|
|
861
|
-
--cache-from
|
|
1364
|
+
Prepend <prefix> to the gha cache scopes in --cache-to,
|
|
1365
|
+
and in --cache-from where they match one; other
|
|
1366
|
+
--cache-from scopes stay shared. Defaults to the tag prefix
|
|
862
1367
|
(TAG_PREFIX); set to "" to keep cache scopes shared.
|
|
863
1368
|
Also available via CACHE_PREFIX environment variable.
|
|
864
1369
|
--cache=<spec> Shorthand for passing <spec> to both --cache-from and
|
|
865
1370
|
--cache-to. Supports the same forms as --cache-from/--cache-to.
|
|
1371
|
+
--oci-labels Add org.opencontainers.image.* labels (source, revision,
|
|
1372
|
+
version, title, description) from the Forgejo Actions
|
|
1373
|
+
run to builds. A --label with the same key wins.
|
|
1374
|
+
Also available via OCI_LABELS=true.
|
|
1375
|
+
|
|
1376
|
+
Placeholders, in build tags and in the tag and cache prefixes:
|
|
1377
|
+
{name} The value of name; an error if it has none in this run.
|
|
1378
|
+
{?name} Expands to nothing; leaves the tag out unless name has
|
|
1379
|
+
a value. Not allowed in prefixes.
|
|
1380
|
+
{image} Registry host and repository, lowercased
|
|
1381
|
+
{ref} Branch name, pr-<number>, or tag name
|
|
1382
|
+
{branch} {pr} {tag}
|
|
1383
|
+
Set only when building that kind of ref
|
|
1384
|
+
{default} Set only when building the default branch, named by
|
|
1385
|
+
the event file or else DEFAULT_BRANCH
|
|
1386
|
+
{revision} {sha} The commit, in full and as 7 characters
|
|
1387
|
+
{version} {major} {minor} {patch}
|
|
1388
|
+
From a SemVer tag such as v1.2.3 (v optional)
|
|
1389
|
+
{stable} Set only for a SemVer tag without a pre-release
|
|
1390
|
+
{env.NAME} The environment variable NAME
|
|
1391
|
+
Values are made safe for a tag ("ci/x" becomes "ci-x"). Quote templates
|
|
1392
|
+
in single quotes. Example:
|
|
1393
|
+
-t '{image}:{ref},{?default}latest'
|
|
866
1394
|
|
|
867
1395
|
Commands:
|
|
868
1396
|
login Log in to all available engines
|
|
@@ -900,6 +1428,7 @@ Environment variables (used by login):
|
|
|
900
1428
|
`;
|
|
901
1429
|
}
|
|
902
1430
|
async function main(argv) {
|
|
1431
|
+
let ociLabels;
|
|
903
1432
|
let tagPrefix = process.env.TAG_PREFIX ?? "";
|
|
904
1433
|
let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
|
|
905
1434
|
for (;; ) {
|
|
@@ -912,6 +1441,8 @@ async function main(argv) {
|
|
|
912
1441
|
[cachePrefix, argv] = consumeValue(argv.slice(1), a);
|
|
913
1442
|
else if (a?.startsWith("--cache-prefix="))
|
|
914
1443
|
[cachePrefix, argv] = [a.slice("--cache-prefix=".length), argv.slice(1)];
|
|
1444
|
+
else if (a === "--oci-labels")
|
|
1445
|
+
[ociLabels, argv] = [true, argv.slice(1)];
|
|
915
1446
|
else
|
|
916
1447
|
break;
|
|
917
1448
|
}
|
|
@@ -925,7 +1456,7 @@ async function main(argv) {
|
|
|
925
1456
|
if (command === "credential-helper")
|
|
926
1457
|
return credentialHelper(rest);
|
|
927
1458
|
if (isEngine(command))
|
|
928
|
-
return runEngine(command, rest, tagPrefix, cachePrefix);
|
|
1459
|
+
return runEngine(command, rest, tagPrefix, cachePrefix, ociLabels);
|
|
929
1460
|
io.stderr(helpText());
|
|
930
1461
|
return 1;
|
|
931
1462
|
}
|
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.1",
|
|
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
|
+
}
|