// ═══════════════════════════════════════════════════════════════════════════════ // release-bump.mjs: zet een nieuwe image-tag met digest in de compose van een app // en verhoogt `version` in zijn manifest. // // Draaien: node tools/release-bump.mjs // Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0... // // WAAROM DIT BESTAAT. De release-pijplijn in de AssetApp-repo bouwt, duwt en leest // de digest terug, maar werkt deze repo bewust NIET bij: een token dat daar staat en // hier mag schrijven, kan alles naar de store duwen die umbreld anoniem kloont. Wat // overbleef was overtypwerk, en juist daar zit de stille fout: een OUDE digest onder // een NIEUWE tag levert de oude image, zonder melding. Dit script vervangt de hele // `image:`-regel, dus die combinatie kan niet meer ontstaan. // // Lokaal draaien en zelf de diff lezen is het ontwerp en geen tussenoplossing. Het // script pusht niets. // // DE TWEE NUMMERS ZIJN NIET HETZELFDE. De tag is de versie van de IMAGE, `version` // in het manifest die van de APP, en die tweede moet bij élke wijziging omhoog, ook // als alleen de compose verandert, anders rolt umbrelOS niets uit. Daarom kiest dit // script hem expliciet en drukt af wat hij koos. // ═══════════════════════════════════════════════════════════════════════════════ import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; const HOST = 'sc.kamenier-hamer.nl'; // ── De twee herschrijvingen: puur, want daar gaan de tests over ──────────────── const DIGEST_VORM = /^sha256:[0-9a-f]{64}$/; const VERSIE_VORM = /^\d+\.\d+\.\d+$/; // Eén `image:`-regel van déze host. Een regel van een andere host blijft staan: een // app mag een image van buiten gebruiken en die hoort hier niet mee te verschuiven. function imageRegels(tekst) { const regels = tekst.split(/\r?\n/); const treffers = []; regels.forEach((regel, i) => { const m = regel.match(new RegExp(`^(\\s*image:\\s*)(${HOST}/[^\\s:@]+)(?::[^\\s@]+)?(?:@sha256:[0-9a-f]+)?\\s*$`)); if (m) treffers.push({ i, inspring: m[1], naam: m[2] }); }); return treffers; } // Vervangt de HELE regel en plakt er niets bij. Dat is de kern: een oude digest kan // zo niet blijven staan. Meerdere containers uit hetzelfde image gaan samen mee, want // twee versies naast elkaar in één compose is nooit de bedoeling. export function herschrijfImage(tekst, versie, digest) { if (!VERSIE_VORM.test(versie)) throw new Error(`versie heeft niet de vorm x.y.z: ${versie}`); if (!DIGEST_VORM.test(digest)) throw new Error(`digest heeft niet de vorm sha256: plus 64 hex: ${digest}`); const treffers = imageRegels(tekst); if (treffers.length === 0) throw new Error(`geen image-regel van ${HOST} gevonden`); const namen = [...new Set(treffers.map((t) => t.naam))]; if (namen.length > 1) throw new Error(`meerdere eigen images in één compose: ${namen.join(', ')}`); const regels = tekst.split(/\r?\n/); for (const t of treffers) regels[t.i] = `${t.inspring}${t.naam}:${versie}@${digest}`; return { tekst: regels.join('\n'), naam: namen[0], aantal: treffers.length }; } export function huidigeAppVersie(manifest) { const m = manifest.match(/^version:\s*"?(\d+\.\d+\.\d+)"?\s*$/m); if (!m) throw new Error('geen `version:` met de vorm x.y.z in het manifest'); return m[1]; } // Hoger is hoger, per deel en niet als tekst: "0.10.0" hoort boven "0.9.0" te staan. export function isHoger(a, b) { const [a1, a2, a3] = a.split('.').map(Number); const [b1, b2, b3] = b.split('.').map(Number); return a1 !== b1 ? a1 > b1 : a2 !== b2 ? a2 > b2 : a3 > b3; } // De app-versie is niet de image-versie. Ligt de image erboven, dan is gelijktrekken // het duidelijkst; ligt hij eronder of gelijk, dan is alleen de patch omhoog genoeg om // umbrelOS te laten uitrollen. Beide gevallen worden afgedrukt, want dit is precies het // soort keuze dat je een maand later niet meer terugziet. export function kiesAppVersie(huidig, imageVersie) { if (isHoger(imageVersie, huidig)) return imageVersie; const [groot, klein, patch] = huidig.split('.').map(Number); return `${groot}.${klein}.${patch + 1}`; } export function herschrijfVersie(manifest, nieuw) { if (!VERSIE_VORM.test(nieuw)) throw new Error(`versie heeft niet de vorm x.y.z: ${nieuw}`); const regels = manifest.split(/\r?\n/); const i = regels.findIndex((r) => /^version:\s*"?\d+\.\d+\.\d+"?\s*$/.test(r)); if (i === -1) throw new Error('geen `version:` met de vorm x.y.z in het manifest'); regels[i] = `version: "${nieuw}"`; return regels.join('\n'); } // ── De controle bij het register ────────────────────────────────────────────── // Hoort die digest wel bij die tag? Dat is de enige vraag die dit script zelf niet // kan beantwoorden, en het is de vraag waar de stille fout zit. Anoniem, want zo // haalt umbreld hem straks ook: faalt dit met een 401, dan staat het pakket op privé // en zou de installatie op het apparaat struikelen. const MEDIA = [ 'application/vnd.oci.image.index.v1+json', 'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json', 'application/vnd.docker.distribution.manifest.v2+json', ].join(', '); export async function digestBijTag(naam, versie) { const pad = naam.slice(HOST.length + 1); const url = `https://${HOST}/v2/${pad}/manifests/${versie}`; let antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA } }); // Het register vraagt ook voor een openbaar pakket een token, en dat token haal je // zonder inloggegevens op. Slaagt dát niet, dan is het pakket niet openbaar. if (antwoord.status === 401) { const uitdaging = antwoord.headers.get('www-authenticate') || ''; const veld = (naam) => (uitdaging.match(new RegExp(`${naam}="([^"]+)"`)) || [])[1]; const realm = veld('realm'); if (!realm) throw new Error(`register vraagt om inloggegevens en zegt niet waar: ${uitdaging}`); const tokenUrl = new URL(realm); if (veld('service')) tokenUrl.searchParams.set('service', veld('service')); tokenUrl.searchParams.set('scope', veld('scope') || `repository:${pad}:pull`); const tokenAntwoord = await fetch(tokenUrl); if (!tokenAntwoord.ok) throw new Error(`anoniem token weigeren (${tokenAntwoord.status}): staat het pakket op privé?`); const { token } = await tokenAntwoord.json(); antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA, Authorization: `Bearer ${token}` } }); } if (antwoord.status === 404) throw new Error(`tag ${versie} bestaat niet in het register`); if (!antwoord.ok) throw new Error(`register antwoordt ${antwoord.status} op ${url}`); const digest = antwoord.headers.get('docker-content-digest'); if (!digest) throw new Error('register geeft geen docker-content-digest terug'); return digest; } // ── De aanroep ──────────────────────────────────────────────────────────────── // `process.exitCode` en NOOIT `process.exit()`: die tweede kapt af terwijl de fetch // naar het register nog open staat, en op Windows sterft Node dan in libuv met code 127 // in plaats van de code die je bedoelde. Zo eindigt een nette weigering als een crash. async function main(argv) { const vlaggen = argv.filter((a) => a.startsWith('--')); const [app, versie, digest] = argv.filter((a) => !a.startsWith('--')); if (!app || !versie || !digest) { console.error('Gebruik: node tools/release-bump.mjs [--offline]'); console.error('Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...'); process.exitCode = 2; return; } const composePad = join(app, 'docker-compose.yml'); const manifestPad = join(app, 'umbrel-app.yml'); const compose = readFileSync(composePad, 'utf8'); const manifest = readFileSync(manifestPad, 'utf8'); const { tekst: nieuweCompose, naam, aantal } = herschrijfImage(compose, versie, digest); if (!vlaggen.includes('--offline')) { const echt = await digestBijTag(naam, versie); if (echt !== digest) { console.error(`De digest hoort NIET bij tag ${versie}.`); console.error(` gegeven: ${digest}`); console.error(` register: ${echt}`); console.error('Er is niets gewijzigd. Dit is precies de verwisseling die stil de oude image oplevert.'); process.exitCode = 1; return; } } const huidig = huidigeAppVersie(manifest); const appVersie = kiesAppVersie(huidig, versie); const nieuwManifest = herschrijfVersie(manifest, appVersie); writeFileSync(composePad, nieuweCompose); writeFileSync(manifestPad, nieuwManifest); console.log(`${composePad}: ${naam}:${versie}@${digest}${aantal > 1 ? ` (${aantal} regels)` : ''}`); console.log(`${manifestPad}: version ${huidig} → ${appVersie}`); console.log(vlaggen.includes('--offline') ? 'Digest NIET gecontroleerd (--offline).' : 'Digest komt overeen met wat het register onder die tag heeft.'); console.log('Niets gepusht. Lees de diff en commit zelf.'); } // `pathToFileURL` en geen zelfgebouwde `file://`-string: op Windows begint die met een // schijfletter en dan komt er één schuine streep te weinig uit, waardoor dit bestand // als module wél laadt maar nooit iets doet. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(process.argv.slice(2)).catch((fout) => { console.error(String(fout.message || fout)); process.exitCode = 1; }); }