Files
UmbrelApps/tools/release-bump.mjs
T

193 lines
9.9 KiB
JavaScript
Raw Permalink Normal View History

2026-10-09 10:40:25 +02:00
// ═══════════════════════════════════════════════════════════════════════════════
// 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 <app-id> <image-versie> <digest>
// 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 <app-id> <image-versie> <digest> [--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;
});
}