Files
skipper/scripts/release.js
2026-04-05 15:28:04 +02:00

205 lines
5.1 KiB
JavaScript

const fs = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
const rootDir = path.resolve(__dirname, '..');
const artifactsDir = path.join(rootDir, 'artifacts', 'releases');
const registry = process.env.IMAGE_REGISTRY || 'registry.internal.budgethost.io';
const namespace = process.env.IMAGE_NAMESPACE || 'skipper';
const channel = process.env.RELEASE_CHANNEL || 'stable';
function getAction() {
const action = process.argv[2] || 'print';
if (!['print', 'plan', 'build', 'publish'].includes(action)) {
throw new Error(`Unsupported action: ${action}`);
}
return action;
}
function timestampUtc() {
return new Date().toISOString();
}
function compactTimestamp(value) {
return value.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
}
function sanitizeTagPart(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown';
}
function spawnCapture(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: rootDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
return;
}
reject(new Error(stderr.trim() || `${command} ${args.join(' ')} failed with exit code ${code}`));
});
});
}
function spawnInherited(command, args, env) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: rootDir,
stdio: 'inherit',
env: {
...process.env,
...env,
},
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}`));
});
});
}
async function detectGitSha() {
try {
const result = await spawnCapture('git', ['rev-parse', '--short', 'HEAD']);
return result.stdout || null;
} catch (error) {
return null;
}
}
async function buildReleaseMetadata() {
const createdAt = timestampUtc();
const gitSha = sanitizeTagPart(process.env.RELEASE_GIT_SHA || (await detectGitSha()) || 'nogit');
const versionTag = sanitizeTagPart(
process.env.RELEASE_VERSION_TAG || `${channel}-${gitSha}-${compactTimestamp(createdAt)}`
);
const images = [
{
id: 'skipper-api',
repository: 'skipper-api',
image: `${registry}/${namespace}/skipper-api:${versionTag}`,
latest_image: `${registry}/${namespace}/skipper-api:latest`,
},
{
id: 'skippy-agent',
repository: 'skippy-agent',
image: `${registry}/${namespace}/skippy-agent:${versionTag}`,
latest_image: `${registry}/${namespace}/skippy-agent:latest`,
},
];
return {
schema_version: 'v1',
created_at: createdAt,
registry,
namespace,
channel,
git_sha: gitSha,
version_tag: versionTag,
images,
compose: {
files: ['docker-compose.yml', 'docker-compose.registry.yml'],
image_tag: versionTag,
},
};
}
async function writeManifest(metadata) {
await fs.mkdir(artifactsDir, { recursive: true });
const versionPath = path.join(artifactsDir, `${metadata.version_tag}.json`);
const latestPath = path.join(artifactsDir, 'latest.json');
const payload = JSON.stringify(metadata, null, 2);
await fs.writeFile(versionPath, payload);
await fs.writeFile(latestPath, payload);
return {
versionPath,
latestPath,
};
}
async function runImagesRelease(mode, metadata) {
const scriptName = mode === 'build' ? 'images:build' : 'images:release';
await spawnInherited('npm', ['run', scriptName], {
IMAGE_REGISTRY: metadata.registry,
IMAGE_NAMESPACE: metadata.namespace,
IMAGE_TAG: metadata.version_tag,
});
}
function printMetadata(metadata, manifestPaths) {
console.log(`channel: ${metadata.channel}`);
console.log(`git_sha: ${metadata.git_sha}`);
console.log(`version_tag: ${metadata.version_tag}`);
for (const image of metadata.images) {
console.log(`${image.id}: ${image.image}`);
console.log(`${image.id}: ${image.latest_image}`);
}
if (manifestPaths) {
console.log(`manifest: ${manifestPaths.versionPath}`);
console.log(`manifest_latest: ${manifestPaths.latestPath}`);
}
}
async function main() {
const action = getAction();
const metadata = await buildReleaseMetadata();
if (action === 'print') {
printMetadata(metadata);
return;
}
const manifestPaths = await writeManifest(metadata);
if (action === 'plan') {
printMetadata(metadata, manifestPaths);
return;
}
if (process.env.RUN_SMOKE_TEST === '1') {
await spawnInherited('npm', ['run', 'smoke:test']);
}
await runImagesRelease(action, metadata);
printMetadata(metadata, manifestPaths);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});