#!/usr/bin/env node // Push an image built by tools/build-image.mjs to a Docker Registry v2 endpoint // (this deployment uses the Gitea container registry). // // Usage: // node tools/push-image.mjs --summary dist/image-summary.json \ // --registry https://gitea.milesward.dev --repo mward4/vshell \ // --user mward4 --token // // Credentials may also come from GITEA_USER / GITEA_TOKEN. import { readFile } from 'node:fs/promises'; import path from 'node:path'; const args = {}; for (let i = 2; i < process.argv.length; i += 2) { if (!process.argv[i].startsWith('--')) throw new Error(`unexpected argument ${process.argv[i]}`); args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1]; } const SUMMARY = path.resolve(args.summary || 'dist/image-summary.json'); const REGISTRY = (args.registry || 'https://gitea.milesward.dev').replace(/\/+$/, ''); const REPO = args.repo || 'mward4/vshell'; const USER = args.user || process.env.GITEA_USER || 'mward4'; const TOKEN = args.token || process.env.GITEA_TOKEN; if (!TOKEN) throw new Error('missing --token / GITEA_TOKEN'); const AUTH = { Authorization: 'Basic ' + Buffer.from(`${USER}:${TOKEN}`).toString('base64') }; const summary = JSON.parse(await readFile(SUMMARY, 'utf8')); const manifestBuf = await readFile(summary.manifest.blob); const manifest = JSON.parse(manifestBuf); // Every blob the manifest references must exist in the target repository. const blobs = [ { digest: summary.base.configDigest, file: summary.base.configBlob }, { digest: summary.base.digest, file: summary.base.blob }, { digest: summary.config.digest, file: summary.config.blob }, { digest: summary.layer.digest, file: summary.layer.blob }, ]; for (const b of blobs) { const data = await readFile(b.file); const head = await fetch(`${REGISTRY}/v2/${REPO}/blobs/${b.digest}`, { method: 'HEAD', headers: AUTH }); if (head.status === 200) { console.log(`exists ${b.digest} (${(data.length / 1048576).toFixed(1)} MiB)`); continue; } const start = await fetch(`${REGISTRY}/v2/${REPO}/blobs/uploads/`, { method: 'POST', headers: AUTH }); if (start.status !== 202) throw new Error(`upload start failed: ${start.status} ${await start.text()}`); const location = new URL(start.headers.get('location'), REGISTRY); location.searchParams.set('digest', b.digest); const put = await fetch(location, { method: 'PUT', headers: { ...AUTH, 'Content-Type': 'application/octet-stream', 'Content-Length': String(data.length) }, body: data, }); if (put.status !== 201) throw new Error(`blob upload failed: ${put.status} ${await put.text()}`); console.log(`uploaded ${b.digest} (${(data.length / 1048576).toFixed(1)} MiB)`); } for (const tag of summary.tags) { const ref = tag.includes(':') ? tag.split(':')[1] : tag; const put = await fetch(`${REGISTRY}/v2/${REPO}/manifests/${ref}`, { method: 'PUT', headers: { ...AUTH, 'Content-Type': manifest.mediaType, 'Content-Length': String(manifestBuf.length) }, body: manifestBuf, }); if (!put.ok) throw new Error(`manifest push failed: ${put.status} ${await put.text()}`); console.log(`pushed ${REPO}:${ref} -> ${put.headers.get('docker-content-digest')}`); } // Read it back so there is independent evidence of what landed. for (const tag of summary.tags) { const ref = tag.includes(':') ? tag.split(':')[1] : tag; const check = await fetch(`${REGISTRY}/v2/${REPO}/manifests/${ref}`, { headers: { ...AUTH, Accept: manifest.mediaType } }); if (!check.ok) throw new Error(`pull-back failed for ${ref}: ${check.status}`); const got = check.headers.get('docker-content-digest'); const body = await check.json(); const ok = got === summary.manifest.digest && JSON.stringify(body) === JSON.stringify(manifest); console.log(`verified ${REPO}:${ref} -> ${got} ${ok ? 'OK' : 'MISMATCH (expected ' + summary.manifest.digest + ')'}`); if (!ok) process.exitCode = 1; }