feat(release): automate versioned releases on main
This commit is contained in:
112
scripts/release/ci.mjs
Normal file
112
scripts/release/ci.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createPlan, git, readConfig, stableTag } from './lib.mjs';
|
||||
import { Gitea } from './gitea.mjs';
|
||||
import { acquireLock } from './lock.mjs';
|
||||
import { publishRelease } from './publish.mjs';
|
||||
|
||||
const config = readConfig();
|
||||
const outputDir = '.release';
|
||||
let unlock;
|
||||
|
||||
function docker(args, capture = false) {
|
||||
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 16 * 1024 * 1024 });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Docker ${args.slice(0, 3).join(' ')} fehlgeschlagen${capture ? `: ${result.stderr}` : '.'}`);
|
||||
return result.stdout?.trim();
|
||||
}
|
||||
|
||||
function imageDigest(reference, optional = false) {
|
||||
try {
|
||||
return JSON.parse(docker(['buildx', 'imagetools', 'inspect', reference, '--format', '{{json .Manifest}}'], true)).digest;
|
||||
} catch (error) {
|
||||
if (optional && /manifest unknown|not found|no such manifest/i.test(error.message)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function promote(image, tags, immutable) {
|
||||
for (const tag of tags) {
|
||||
const reference = `${image.repository}:${tag}`;
|
||||
if (immutable) {
|
||||
const existing = imageDigest(reference, true);
|
||||
if (existing === image.digest) continue;
|
||||
if (existing) throw new Error(`Image ${reference} existiert mit anderem Digest; Überschreiben verweigert.`);
|
||||
}
|
||||
docker(['buildx', 'imagetools', 'create', '--prefer-index=false', '--tag', reference, `${image.repository}@${image.digest}`]);
|
||||
if (imageDigest(reference) !== image.digest) throw new Error(`Digest-Prüfung für ${reference} fehlgeschlagen.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildImages(plan, version, reuseTag) {
|
||||
const stagingTag = `build-${randomUUID()}`;
|
||||
const images = [];
|
||||
for (const component of config.images) {
|
||||
const repository = `${config.registry}/${component.name}`;
|
||||
const existing = reuseTag ? imageDigest(`${repository}:${reuseTag}`, true) : null;
|
||||
if (existing) {
|
||||
images.push({ name: component.name, repository, digest: existing });
|
||||
continue;
|
||||
}
|
||||
const metadata = `${outputDir}/${component.name}.json`;
|
||||
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
|
||||
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
|
||||
'--file', component.dockerfile, '--metadata-file', metadata, '--tag', `${repository}:${stagingTag}`,
|
||||
'--build-arg', `FEDEO_VERSION=${version}`, '--build-arg', `FEDEO_REVISION=${plan.sha}`,
|
||||
'--label', `org.opencontainers.image.version=${version}`, '--label', `org.opencontainers.image.revision=${plan.sha}`,
|
||||
'--label', `org.opencontainers.image.source=${config.repositoryUrl}`, component.context]);
|
||||
const digest = JSON.parse(readFileSync(metadata, 'utf8'))['containerimage.digest'];
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(digest || '')) throw new Error(`Fehlender Digest für ${component.name}.`);
|
||||
images.push({ name: component.name, repository, digest });
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
try {
|
||||
const ref = process.env.FEDEO_REF;
|
||||
if (process.env.CI !== 'true' || !['push', 'workflow_dispatch'].includes(process.env.FEDEO_EVENT_NAME) ||
|
||||
![`refs/heads/${config.branch}`, 'refs/heads/dev'].includes(ref)) {
|
||||
throw new Error('Veröffentlichung nur im Gitea-Workflow auf dev/main. Lokal: npm run release:preview.');
|
||||
}
|
||||
const sha = git(['rev-parse', 'HEAD']);
|
||||
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.');
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (!token) throw new Error('CI_TOKEN oder RELEASE_TOKEN als Gitea-Secret konfigurieren (siehe docs/releases.md).');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const api = new Gitea(config.repositoryUrl, token);
|
||||
const stable = ref === `refs/heads/${config.branch}`;
|
||||
unlock = await acquireLock(stable ? {} : { ref: 'refs/heads/automation/dev-build-lock' });
|
||||
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
|
||||
|
||||
if (stable) {
|
||||
const releases = await api.releases();
|
||||
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha);
|
||||
if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
|
||||
console.log('Ein neuerer main-Commit liegt vor. Dessen Workflow übernimmt die ausstehenden Änderungen.');
|
||||
} else {
|
||||
const plan = await createPlan();
|
||||
writeFileSync(`${outputDir}/plan.json`, JSON.stringify(plan, null, 2));
|
||||
if (!plan.version) console.log('Keine releasewirksamen Änderungen seit dem letzten Tag.');
|
||||
else {
|
||||
const result = await publishRelease({ plan, config, api, build: version => buildImages(plan, version), promote });
|
||||
writeFileSync(`${outputDir}/release-manifest.json`, JSON.stringify(result.manifest, null, 2));
|
||||
console.log(`FEDEO ${plan.version} veröffentlicht: ${config.repositoryUrl}/releases/tag/${plan.tag}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const plan = await createPlan();
|
||||
const images = await buildImages(plan, plan.developmentVersion, `dev-sha-${sha}`);
|
||||
for (const image of images) await promote(image, [`dev-sha-${sha}`], true);
|
||||
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
|
||||
if (remoteDev === sha) for (const image of images) await promote(image, ['dev'], false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (unlock) {
|
||||
try { unlock(); }
|
||||
catch (error) { console.error(`Release-Sperre konnte nicht entfernt werden: ${error.message}`); process.exitCode = 1; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user