@cldmv/gh-broker 0.7.0 → 0.8.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/README.md +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/verbs/ghcr/config.ts
|
|
5
|
+
* @Date: 2026-10-31T10:57:30-07:00 (1793469450)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-11-01T00:40:55-07:00 (1793518855)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Config block for the GHCR verbs (`ghcr_push`, `ghcr_push_status`, `ghcr_tags`).
|
|
18
|
+
*
|
|
19
|
+
* GHCR is a separate credential and a separate policy from the GitHub API routing in
|
|
20
|
+
* `config.owners`: the registry does not accept fine-grained PATs, so it needs a classic
|
|
21
|
+
* PAT with `write:packages`, and that token must only ever reach `ghcr.io`. Keeping it in
|
|
22
|
+
* its own block (rather than a third ref on `owners.<o>`) keeps the existing owner
|
|
23
|
+
* credentials untouched and lets the installer treat the whole block as operator data, set with
|
|
24
|
+
* `--ghcr-*` flags or a `--config` file (see deploy/lib/config-merge.mjs).
|
|
25
|
+
*
|
|
26
|
+
* "ghcr": {
|
|
27
|
+
* "dropDir": "/srv/gh-broker/ghcr-drop",
|
|
28
|
+
* "protectReleaseTags": true,
|
|
29
|
+
* "owners": {
|
|
30
|
+
* "your-org": { "username": "your-login", "credential": "cred:your-org-packages", "images": ["your-image", "your-image-worker-*"] }
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Every key is checked strictly (unknown keys are refused) because this is a security
|
|
35
|
+
* policy: a typo such as `image` for `images` must fail loudly, not silently allow nothing
|
|
36
|
+
* or everything.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { isAbsolute, normalize } from "node:path";
|
|
40
|
+
|
|
41
|
+
export interface GhcrOwnerConfig {
|
|
42
|
+
/** GitHub login the classic PAT belongs to; sent as the Basic-auth user to ghcr.io/token. */
|
|
43
|
+
username: string;
|
|
44
|
+
/** "cred:<name>" ref for the classic PAT (write:packages). */
|
|
45
|
+
credential: string;
|
|
46
|
+
/** Image names under ghcr.io/<owner>/ that may be pushed or listed. `*` matches within one path segment. */
|
|
47
|
+
images: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GhcrConfig {
|
|
51
|
+
/** The only directory archives are read from. Absolute; the installer creates it. */
|
|
52
|
+
dropDir: string;
|
|
53
|
+
/** Refuse to overwrite an existing tag shaped like a release (`1.2.3` / `v1.2.3`). */
|
|
54
|
+
protectReleaseTags: boolean;
|
|
55
|
+
/** Largest archive accepted, in bytes. */
|
|
56
|
+
maxArchiveBytes: number;
|
|
57
|
+
owners: Record<string, GhcrOwnerConfig>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 ** 3;
|
|
61
|
+
const TOP_KEYS = new Set(["dropDir", "protectReleaseTags", "maxArchiveBytes", "owners"]);
|
|
62
|
+
const OWNER_KEYS = new Set(["username", "credential", "images"]);
|
|
63
|
+
/** Directories that must never be a drop dir: system state, other users' homes, secrets. */
|
|
64
|
+
const FORBIDDEN_ROOTS = ["/etc", "/proc", "/sys", "/dev", "/run", "/boot", "/root", "/home", "/tmp", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var/tmp"];
|
|
65
|
+
/** One image-name glob: lowercase OCI path components, `*` allowed, `/` separates segments. */
|
|
66
|
+
const IMAGE_GLOB = /^[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?(?:\/[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?)*$/;
|
|
67
|
+
const OWNER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,38})$/;
|
|
68
|
+
|
|
69
|
+
const fail = (msg: string): never => {
|
|
70
|
+
throw new Error(`config.ghcr${msg}`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function strictKeys(o: Record<string, unknown>, allowed: Set<string>, where: string): void {
|
|
74
|
+
const extra = Object.keys(o).filter((k) => !allowed.has(k));
|
|
75
|
+
if (extra.length) fail(`${where}: unknown key(s) ${extra.join(", ")} — expected ${[...allowed].join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
79
|
+
|
|
80
|
+
export function parseGhcrConfig(raw: unknown): GhcrConfig {
|
|
81
|
+
if (!isObject(raw)) return fail(": must be an object");
|
|
82
|
+
strictKeys(raw, TOP_KEYS, "");
|
|
83
|
+
|
|
84
|
+
const dropDir = raw.dropDir;
|
|
85
|
+
if (typeof dropDir !== "string" || !isAbsolute(dropDir)) fail('.dropDir: must be an absolute path, e.g. "/srv/gh-broker/ghcr-drop"');
|
|
86
|
+
const dir = normalize(dropDir as string).replace(/\/+$/, "");
|
|
87
|
+
if (!dir || dir !== (dropDir as string).replace(/\/+$/, "")) fail(".dropDir: must be a normalized path (no ., .. or doubled slashes)");
|
|
88
|
+
if (dir.split("/").filter(Boolean).length < 2) fail(".dropDir: must be at least two levels deep, e.g. /srv/gh-broker/ghcr-drop");
|
|
89
|
+
for (const root of FORBIDDEN_ROOTS) {
|
|
90
|
+
if (dir === root || dir.startsWith(`${root}/`)) fail(`.dropDir: may not be under ${root}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (raw.protectReleaseTags !== undefined && typeof raw.protectReleaseTags !== "boolean") fail(".protectReleaseTags: must be a boolean");
|
|
94
|
+
let maxArchiveBytes = DEFAULT_MAX_ARCHIVE_BYTES;
|
|
95
|
+
if (raw.maxArchiveBytes !== undefined) {
|
|
96
|
+
const m = raw.maxArchiveBytes;
|
|
97
|
+
if (typeof m !== "number" || !Number.isSafeInteger(m) || m <= 0) fail(".maxArchiveBytes: must be a positive integer");
|
|
98
|
+
maxArchiveBytes = m as number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isObject(raw.owners) || Object.keys(raw.owners).length === 0) fail(".owners: must be an object with at least one owner");
|
|
102
|
+
const owners: Record<string, GhcrOwnerConfig> = {};
|
|
103
|
+
for (const [name, v] of Object.entries(raw.owners as Record<string, unknown>)) {
|
|
104
|
+
const owner = name.toLowerCase();
|
|
105
|
+
if (!OWNER_NAME.test(owner)) fail(`.owners: "${name}" is not a valid GitHub owner name`);
|
|
106
|
+
if (!isObject(v)) fail(`.owners.${name}: must be an object`);
|
|
107
|
+
const o = v as Record<string, unknown>;
|
|
108
|
+
strictKeys(o, OWNER_KEYS, `.owners.${name}`);
|
|
109
|
+
if (typeof o.username !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(o.username)) fail(`.owners.${name}.username: must be the GitHub login that owns the PAT`);
|
|
110
|
+
if (typeof o.credential !== "string" || !/^cred:[A-Za-z0-9._-]+$/.test(o.credential) || /^cred:\.\.?$/.test(o.credential)) {
|
|
111
|
+
fail(`.owners.${name}.credential: must be a "cred:<name>" ref`);
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(o.images) || o.images.length === 0) fail(`.owners.${name}.images: must be a non-empty list of image names or globs`);
|
|
114
|
+
for (const img of o.images as unknown[]) {
|
|
115
|
+
if (typeof img !== "string" || img.length > 255 || !IMAGE_GLOB.test(img)) {
|
|
116
|
+
fail(`.owners.${name}.images: "${String(img)}" is not a valid image name or glob (lowercase, [a-z0-9._-], "/" between segments, "*" within a segment)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
owners[owner] = { username: o.username as string, credential: o.credential as string, images: [...(o.images as string[])] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { dropDir: dir, protectReleaseTags: raw.protectReleaseTags !== false, maxArchiveBytes, owners };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every "cred:<name>" ref the ghcr block references, for eager validation at startup. */
|
|
126
|
+
export function ghcrCredRefs(cfg: GhcrConfig | undefined): string[] {
|
|
127
|
+
return cfg ? Object.values(cfg.owners).map((o) => o.credential) : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Compile an image glob to an anchored regex: `*` matches `[a-z0-9._-]*` (never `/`). */
|
|
131
|
+
export function imageGlobToRegExp(glob: string): RegExp {
|
|
132
|
+
const body = glob
|
|
133
|
+
.split("*")
|
|
134
|
+
.map((part) => part.replace(/[.\\^$+?()[\]{}|-]/g, "\\$&"))
|
|
135
|
+
.join("[a-z0-9._-]*");
|
|
136
|
+
return new RegExp(`^${body}$`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when `image` (a name under the owner, e.g. "hoot-worker-a") is on the owner's allowlist. */
|
|
140
|
+
export function imageAllowed(owner: GhcrOwnerConfig, image: string): boolean {
|
|
141
|
+
return owner.images.some((g) => imageGlobToRegExp(g).test(image));
|
|
142
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/verbs/ghcr/jobs.ts
|
|
5
|
+
* @Date: 2026-10-31T11:08:29-07:00 (1793470109)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T11:13:29-07:00 (1793470409)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-memory queue for GHCR push jobs.
|
|
18
|
+
*
|
|
19
|
+
* Images are 1–3 GB, far longer than one MCP call should block, so `ghcr_push` enqueues a
|
|
20
|
+
* job and returns its id; `ghcr_push_status` reads it back. Jobs run one at a time (a
|
|
21
|
+
* push saturates the uplink; two at once only make both slower) in submission order.
|
|
22
|
+
*
|
|
23
|
+
* Bounded on purpose: at most `maxPending` queued-or-running jobs (each holds an open
|
|
24
|
+
* archive descriptor), and only the newest `keepFinished` finished jobs are remembered.
|
|
25
|
+
* State lives in memory only, so a broker restart forgets every job; a push that was
|
|
26
|
+
* running is simply not completed (the registry discards an unfinished upload, and no
|
|
27
|
+
* tag moves until the final manifest PUT).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import type { PushProgress, PushResult } from "./push.ts";
|
|
32
|
+
|
|
33
|
+
export type JobState = "queued" | "running" | "succeeded" | "failed";
|
|
34
|
+
|
|
35
|
+
export interface JobMeta {
|
|
36
|
+
owner: string;
|
|
37
|
+
/** e.g. "ghcr.io/cldmv/hoot" */
|
|
38
|
+
image: string;
|
|
39
|
+
tag: string;
|
|
40
|
+
archive: string;
|
|
41
|
+
archiveBytes: number;
|
|
42
|
+
format: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Job extends JobMeta {
|
|
46
|
+
job: string;
|
|
47
|
+
state: JobState;
|
|
48
|
+
progress?: PushProgress;
|
|
49
|
+
result?: PushResult;
|
|
50
|
+
error?: string;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
startedAt?: string;
|
|
53
|
+
finishedAt?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface JobHooks {
|
|
57
|
+
/** Does the work. Report progress through `progress`; resolve with the result or throw. */
|
|
58
|
+
run(progress: (p: PushProgress) => void): Promise<PushResult>;
|
|
59
|
+
/** Always called once the job ends (success, failure, or never started). */
|
|
60
|
+
finally?(job: Job): void;
|
|
61
|
+
/** Turns a thrown error into the message stored on the job (must already be secret-free). */
|
|
62
|
+
describeError?(err: unknown): string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class QueueFull extends Error {}
|
|
66
|
+
|
|
67
|
+
export class JobQueue {
|
|
68
|
+
private readonly jobs = new Map<string, Job>();
|
|
69
|
+
private readonly pending: Array<{ job: Job; hooks: JobHooks }> = [];
|
|
70
|
+
private running = false;
|
|
71
|
+
private readonly maxPending: number;
|
|
72
|
+
private readonly keepFinished: number;
|
|
73
|
+
|
|
74
|
+
constructor({ maxPending = 8, keepFinished = 50 }: { maxPending?: number; keepFinished?: number } = {}) {
|
|
75
|
+
this.maxPending = maxPending;
|
|
76
|
+
this.keepFinished = keepFinished;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Queued + running jobs. */
|
|
80
|
+
activeCount(): number {
|
|
81
|
+
return this.pending.length + (this.running ? 1 : 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
submit(meta: JobMeta, hooks: JobHooks): Job {
|
|
85
|
+
if (this.activeCount() >= this.maxPending) throw new QueueFull(`ghcr_push: ${this.activeCount()} pushes are already queued or running — wait for one to finish (ghcr_push_status)`);
|
|
86
|
+
const job: Job = { ...meta, job: randomUUID(), state: "queued", createdAt: new Date().toISOString() };
|
|
87
|
+
this.jobs.set(job.job, job);
|
|
88
|
+
this.pending.push({ job, hooks });
|
|
89
|
+
const snapshot = { ...job }; // as submitted, before pump() may flip it to running
|
|
90
|
+
void this.pump();
|
|
91
|
+
return snapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get(id: string): Job | undefined {
|
|
95
|
+
const j = this.jobs.get(id);
|
|
96
|
+
return j ? structuredClone(j) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
list(owner: string): Job[] {
|
|
100
|
+
return [...this.jobs.values()].filter((j) => j.owner === owner).map((j) => structuredClone(j));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolves once nothing is queued or running (for tests and orderly shutdown). */
|
|
104
|
+
async idle(): Promise<void> {
|
|
105
|
+
while (this.activeCount() > 0) await new Promise((r) => setTimeout(r, 5));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async pump(): Promise<void> {
|
|
109
|
+
if (this.running) return;
|
|
110
|
+
const next = this.pending.shift();
|
|
111
|
+
if (!next) return;
|
|
112
|
+
this.running = true;
|
|
113
|
+
const { job, hooks } = next;
|
|
114
|
+
job.state = "running";
|
|
115
|
+
job.startedAt = new Date().toISOString();
|
|
116
|
+
try {
|
|
117
|
+
job.result = await hooks.run((p) => {
|
|
118
|
+
job.progress = p;
|
|
119
|
+
});
|
|
120
|
+
job.state = "succeeded";
|
|
121
|
+
} catch (err) {
|
|
122
|
+
job.state = "failed";
|
|
123
|
+
job.error = hooks.describeError ? hooks.describeError(err) : "push failed";
|
|
124
|
+
} finally {
|
|
125
|
+
job.finishedAt = new Date().toISOString();
|
|
126
|
+
try {
|
|
127
|
+
hooks.finally?.(structuredClone(job));
|
|
128
|
+
} catch {
|
|
129
|
+
/* a cleanup/audit hook must not wedge the queue */
|
|
130
|
+
}
|
|
131
|
+
this.running = false;
|
|
132
|
+
this.prune();
|
|
133
|
+
void this.pump();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private prune(): void {
|
|
138
|
+
const finished = [...this.jobs.values()].filter((j) => j.state === "succeeded" || j.state === "failed");
|
|
139
|
+
for (const j of finished.slice(0, Math.max(0, finished.length - this.keepFinished))) this.jobs.delete(j.job);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/verbs/ghcr/push.ts
|
|
5
|
+
* @Date: 2026-10-31T11:00:50-07:00 (1793469650)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T11:13:29-07:00 (1793470409)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pushes one planned image (see archive.ts) to a registry repository.
|
|
18
|
+
*
|
|
19
|
+
* Phases:
|
|
20
|
+
* 1. preparing — for every layer the archive holds uncompressed, gzip it once just to
|
|
21
|
+
* learn the compressed digest and size. This costs a second pass of CPU, but it means
|
|
22
|
+
* the blob `HEAD` check works for those layers too (a re-push of an unchanged image
|
|
23
|
+
* uploads nothing), and nothing has to be buffered or written to disk: the broker's
|
|
24
|
+
* unit has no writable scratch space.
|
|
25
|
+
* 2. uploading — config then layers: `HEAD` each blob and skip it when present,
|
|
26
|
+
* otherwise stream it. Compressed-on-push layers are gzipped again with the same
|
|
27
|
+
* settings; zlib is deterministic, and the registry rejects the upload if the bytes
|
|
28
|
+
* don't match the digest computed in phase 1.
|
|
29
|
+
* 3. manifest — re-check a protected release tag (another push may have landed it while
|
|
30
|
+
* this one waited in the queue), then `PUT` the manifest under the tag.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createReadStream } from "node:fs";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { createGzip } from "node:zlib";
|
|
36
|
+
import { PassThrough, Writable, type Readable } from "node:stream";
|
|
37
|
+
import { pipeline } from "node:stream/promises";
|
|
38
|
+
import { MEDIA, type BlobPlan, type ImagePlan } from "./archive.ts";
|
|
39
|
+
import type { Registry } from "./registry.ts";
|
|
40
|
+
|
|
41
|
+
export const RELEASE_TAG = /^v?\d+\.\d+\.\d+$/;
|
|
42
|
+
const GZIP_LEVEL = 6;
|
|
43
|
+
|
|
44
|
+
export type Phase = "queued" | "preparing" | "uploading" | "manifest" | "done";
|
|
45
|
+
|
|
46
|
+
export interface PushProgress {
|
|
47
|
+
phase: Phase;
|
|
48
|
+
layersTotal: number;
|
|
49
|
+
blobsUploaded: number;
|
|
50
|
+
blobsSkipped: number;
|
|
51
|
+
/** Bytes of every blob the image references, as stored in the registry. Known once preparing ends. */
|
|
52
|
+
bytesTotal: number;
|
|
53
|
+
bytesSent: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PushResult {
|
|
57
|
+
digest: string;
|
|
58
|
+
mediaType: string;
|
|
59
|
+
/** Total size of config + layers in the registry. */
|
|
60
|
+
size: number;
|
|
61
|
+
blobsUploaded: number;
|
|
62
|
+
blobsSkipped: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Descriptor {
|
|
66
|
+
mediaType: string;
|
|
67
|
+
digest: string;
|
|
68
|
+
size: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PushRefused extends Error {}
|
|
72
|
+
|
|
73
|
+
function memberStream(fd: number, plan: BlobPlan): Readable {
|
|
74
|
+
if (plan.member.size === 0) {
|
|
75
|
+
const empty = new PassThrough();
|
|
76
|
+
empty.end();
|
|
77
|
+
return empty;
|
|
78
|
+
}
|
|
79
|
+
return createReadStream("", { fd, start: plan.member.offset, end: plan.member.offset + plan.member.size - 1, autoClose: false });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function gzipStream(fd: number, plan: BlobPlan): Readable {
|
|
83
|
+
const src = memberStream(fd, plan);
|
|
84
|
+
const gz = createGzip({ level: GZIP_LEVEL });
|
|
85
|
+
src.on("error", (e) => gz.destroy(e));
|
|
86
|
+
return src.pipe(gz);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function gzipDigest(fd: number, plan: BlobPlan): Promise<{ digest: string; size: number }> {
|
|
90
|
+
const hash = createHash("sha256");
|
|
91
|
+
let size = 0;
|
|
92
|
+
await pipeline(
|
|
93
|
+
gzipStream(fd, plan),
|
|
94
|
+
new Writable({
|
|
95
|
+
write(chunk: Buffer, _enc, cb) {
|
|
96
|
+
hash.update(chunk);
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
cb();
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
return { digest: `sha256:${hash.digest("hex")}`, size };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sha256 = (b: Buffer): string => `sha256:${createHash("sha256").update(b).digest("hex")}`;
|
|
106
|
+
|
|
107
|
+
export async function pushImage(
|
|
108
|
+
registry: Registry,
|
|
109
|
+
fd: number,
|
|
110
|
+
plan: ImagePlan,
|
|
111
|
+
tag: string,
|
|
112
|
+
opts: { protectReleaseTags: boolean; onProgress?: (p: PushProgress) => void }
|
|
113
|
+
): Promise<PushResult> {
|
|
114
|
+
const progress: PushProgress = { phase: "preparing", layersTotal: plan.layers.length, blobsUploaded: 0, blobsSkipped: 0, bytesTotal: 0, bytesSent: 0 };
|
|
115
|
+
const report = () => opts.onProgress?.({ ...progress });
|
|
116
|
+
report();
|
|
117
|
+
|
|
118
|
+
const layers: Descriptor[] = [];
|
|
119
|
+
for (const l of plan.layers) {
|
|
120
|
+
if (l.compress) {
|
|
121
|
+
const { digest, size } = await gzipDigest(fd, l);
|
|
122
|
+
layers.push({ mediaType: l.mediaType, digest, size });
|
|
123
|
+
} else {
|
|
124
|
+
layers.push({ mediaType: l.mediaType, digest: l.sourceDigest ?? (await rawDigest(fd, l)), size: l.sourceSize });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const config: Descriptor = { mediaType: plan.config.mediaType, digest: plan.config.digest, size: plan.config.sourceSize };
|
|
128
|
+
progress.bytesTotal = config.size + layers.reduce((n, d) => n + d.size, 0);
|
|
129
|
+
progress.phase = "uploading";
|
|
130
|
+
report();
|
|
131
|
+
|
|
132
|
+
const blobs: Array<{ desc: Descriptor; plan: BlobPlan }> = [{ desc: config, plan: plan.config }, ...layers.map((desc, i) => ({ desc, plan: plan.layers[i]! }))];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
for (const { desc, plan: p } of blobs) {
|
|
135
|
+
if (seen.has(desc.digest) || (await registry.blobExists(desc.digest))) {
|
|
136
|
+
seen.add(desc.digest);
|
|
137
|
+
progress.blobsSkipped++;
|
|
138
|
+
report();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
seen.add(desc.digest);
|
|
142
|
+
await registry.uploadBlob(desc.digest, desc.size, () => (p.compress ? gzipStream(fd, p) : memberStream(fd, p)), (n) => {
|
|
143
|
+
progress.bytesSent += n;
|
|
144
|
+
report();
|
|
145
|
+
});
|
|
146
|
+
progress.blobsUploaded++;
|
|
147
|
+
report();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
progress.phase = "manifest";
|
|
151
|
+
report();
|
|
152
|
+
let bytes: Buffer;
|
|
153
|
+
let mediaType: string;
|
|
154
|
+
if (plan.verbatimManifest) {
|
|
155
|
+
bytes = plan.verbatimManifest.bytes;
|
|
156
|
+
mediaType = plan.verbatimManifest.mediaType;
|
|
157
|
+
} else {
|
|
158
|
+
mediaType = MEDIA.ociManifest;
|
|
159
|
+
const cfg = { ...config, mediaType: config.mediaType === MEDIA.dockerConfig ? MEDIA.ociConfig : config.mediaType };
|
|
160
|
+
bytes = Buffer.from(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
schemaVersion: 2,
|
|
163
|
+
mediaType,
|
|
164
|
+
config: cfg,
|
|
165
|
+
layers: layers.map((l) => ({ ...l, mediaType: l.mediaType === MEDIA.dockerLayerGzip ? MEDIA.ociLayerGzip : l.mediaType })),
|
|
166
|
+
...(plan.annotations ? { annotations: plan.annotations } : {})
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (opts.protectReleaseTags && RELEASE_TAG.test(tag) && (await registry.manifestExists(tag))) {
|
|
171
|
+
throw new PushRefused(`ghcr_push: tag ${tag} already exists on ${registry.repository} and release tags are protected — push a new version instead`);
|
|
172
|
+
}
|
|
173
|
+
await registry.putManifest(tag, mediaType, bytes);
|
|
174
|
+
progress.phase = "done";
|
|
175
|
+
report();
|
|
176
|
+
return { digest: sha256(bytes), mediaType, size: progress.bytesTotal, blobsUploaded: progress.blobsUploaded, blobsSkipped: progress.blobsSkipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** sha256 of a member streamed from the archive (docker-archive layers that are already compressed). */
|
|
180
|
+
async function rawDigest(fd: number, plan: BlobPlan): Promise<string> {
|
|
181
|
+
const hash = createHash("sha256");
|
|
182
|
+
await pipeline(
|
|
183
|
+
memberStream(fd, plan),
|
|
184
|
+
new Writable({
|
|
185
|
+
write(chunk: Buffer, _enc, cb) {
|
|
186
|
+
hash.update(chunk);
|
|
187
|
+
cb();
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
return `sha256:${hash.digest("hex")}`;
|
|
192
|
+
}
|