Swith to v0.20.3
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-03 10:31:09 +02:00
parent 11a2c140ce
commit d1d4b3f44e
9 changed files with 581 additions and 8 deletions
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
import path from 'node:path';
import { readFile, access } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function usage() {
return `Usage: node scripts/publish-release.mjs [options]
Uploads prepared release artifacts to a Gitea release.
Options:
--api-base URL Gitea API base (default: https://git.add-ideas.de/api/v1)
--owner OWNER Gitea repository owner (default: lotobo)
--repo REPO Gitea repository (default: toolbox-portal)
--tag TAG Release tag, e.g. v0.20.3 (default: from --lock-file)
--target HASH Target commit SHA for release creation
--name NAME Release name
--body BODY Optional release body
--archive PATH Release ZIP path
--checksum PATH SHA256 file path
--lock-file PATH Release lock file (default: release/toolbox.lock.json)
--token TOKEN Gitea API token; defaults to GITEA_TOKEN env
--dry-run Print actions without contacting Gitea
--help Show this help
`;
}
function parseArguments(argv) {
if (argv.includes('--help')) {
console.log(usage());
process.exit(0);
}
const args = new Map();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith('--')) throw new Error(`Unknown argument ${arg}`);
if (arg === '--dry-run') {
args.set(arg, true);
continue;
}
const value = argv[index + 1];
if (value === undefined) throw new Error(`Missing value for ${arg}`);
args.set(arg, value);
index += 1;
}
return {
apiBase: args.get('--api-base') ?? 'https://git.add-ideas.de/api/v1',
owner: args.get('--owner') ?? 'lotobo',
repo: args.get('--repo') ?? 'toolbox-portal',
tag: args.get('--tag'),
target: args.get('--target'),
name: args.get('--name') ?? 'add-ideas Toolbox release',
body: args.get('--body'),
archive: args.get('--archive'),
checksum: args.get('--checksum'),
lockFile: args.get('--lock-file') ?? 'release/toolbox.lock.json',
token: args.get('--token') ?? process.env.GITEA_TOKEN,
dryRun: args.get('--dry-run') === true,
};
}
async function readTokenFromEnvFile() {
const xdgConfig = process.env.XDG_CONFIG_HOME;
const homeConfig = process.env.HOME
? `${process.env.HOME}/.config/gitea/gitea.env`
: null;
const candidates = [];
if (xdgConfig) candidates.push(`${xdgConfig}/gitea/gitea.env`);
if (homeConfig) candidates.push(homeConfig);
for (const candidate of candidates) {
try {
await access(candidate);
} catch {
continue;
}
const fileContent = await readFile(candidate, 'utf8');
const tokenLine = fileContent
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('GITEA_TOKEN='));
if (!tokenLine) continue;
const token = tokenLine.split('=', 2)[1]?.trim();
if (token) return token;
}
return null;
}
async function giteaRequest(url, options, { allowNotFound = false } = {}) {
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
...(options.headers ?? {}),
},
});
if (allowNotFound && response.status === 404) {
return { notFound: true, status: 404 };
}
const text = await response.text();
const payload = text ? (() => {
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
})() : null;
if (!response.ok) {
const detail = payload && typeof payload === 'object' && payload.error
? payload.error
: payload?.raw ?? text ?? '';
throw new Error(`Gitea API request failed (${response.status} ${response.statusText}): ${detail}`);
}
return payload;
}
function sha256Digest(content) {
return createHash('sha256').update(content).digest('hex');
}
function parseChecksumFile(raw) {
const [first] = String(raw).trim().split(/\s+/);
return first?.toLowerCase() ?? '';
}
function parseReleaseVersionFromLock(lockJson) {
return lockJson.releaseVersion;
}
async function run() {
const options = parseArguments(process.argv.slice(2));
const lock = await readFile(options.lockFile, 'utf8');
const lockJson = JSON.parse(lock);
if (!SEMVER.test(lockJson.releaseVersion)) {
throw new Error(`Invalid releaseVersion in lock: ${lockJson.releaseVersion}`);
}
const releaseVersion = parseReleaseVersionFromLock(lockJson);
const releaseTag = options.tag ?? `v${releaseVersion}`;
const dryRun = options.dryRun === true;
let token = options.token;
if (!token && !dryRun) token = await readTokenFromEnvFile();
if (!token && !dryRun) {
throw new Error('GITEA_TOKEN is required. Set env or provide --token.');
}
if (!token && dryRun) token = 'dry-run-token';
if (!options.archive) {
throw new Error('Missing --archive');
}
if (!options.checksum) {
throw new Error('Missing --checksum');
}
const archive = path.resolve(options.archive);
const checksumFile = path.resolve(options.checksum);
const [archiveData, checksumRaw] = await Promise.all([
readFile(archive),
readFile(checksumFile, 'utf8'),
]);
const expectedChecksum = parseChecksumFile(checksumRaw);
const computedChecksum = sha256Digest(archiveData);
if (!expectedChecksum) {
throw new Error(`Checksum file is empty: ${checksumFile}`);
}
if (!/^[a-f0-9]{64}$/i.test(expectedChecksum)) {
throw new Error(`Invalid checksum format in ${checksumFile}`);
}
if (expectedChecksum.toLowerCase() !== computedChecksum.toLowerCase()) {
throw new Error(
`Checksum mismatch for ${options.archive}. computed=${computedChecksum} file=${expectedChecksum}`
);
}
if (dryRun) {
console.log(`DRY-RUN: would ensure or create release ${releaseTag} on ${options.owner}/${options.repo}.`);
console.log(`DRY-RUN: would upload assets ${path.basename(archive)} and ${path.basename(checksumFile)}.`);
return;
}
const apiBase = options.apiBase.replace(/\/+$/, '');
const releasePrefix = `${apiBase}/repos/${options.owner}/${options.repo}`;
const auth = `token ${token}`;
const headers = { Authorization: auth };
const tagUrl = `${releasePrefix}/releases/tags/${encodeURIComponent(releaseTag)}`;
const tagLookup = await giteaRequest(
tagUrl,
{ method: 'GET', headers },
{ allowNotFound: true }
);
let release;
if (tagLookup?.notFound) {
const body = {
tag_name: releaseTag,
target_commitish: options.target || 'main',
name: options.name,
body:
options.body
|| `Automated release of add-ideas toolbox ${releaseTag} from lock release ${releaseVersion}.`,
draft: false,
prerelease: false,
};
if (dryRun) {
console.log(`DRY-RUN: would create release ${releaseTag} on ${options.owner}/${options.repo}`);
release = { id: '<dry-run>', assets: [] };
} else {
release = await giteaRequest(`${releasePrefix}/releases`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
console.log(`Created release ${releaseTag} (${release.id})`);
}
} else {
release = tagLookup;
if (dryRun) {
console.log(`DRY-RUN: would reuse existing release ${releaseTag} (${release?.id ?? 'unknown'})`);
} else {
console.log(`Using existing release ${releaseTag} (${release.id})`);
}
}
if (dryRun) {
console.log('DRY-RUN: would upload assets:', options.archive, options.checksum);
return;
}
const existingAssets = release.assets ?? [];
const candidateAssets = [
{ path: archive, name: path.basename(archive) },
{ path: checksumFile, name: path.basename(checksumFile) },
];
for (const asset of candidateAssets) {
const duplicate = existingAssets.find((entry) => entry.name === asset.name);
if (duplicate) {
console.log(`Deleting existing asset ${asset.name} from release ${releaseTag}`);
await giteaRequest(
`${releasePrefix}/releases/assets/${duplicate.id}`,
{ method: 'DELETE', headers },
{ allowNotFound: true }
);
}
const payload = await readFile(asset.path);
const form = new FormData();
form.set('attachment', new Blob([payload]), asset.name);
console.log(`Uploading ${asset.name}`);
await giteaRequest(
`${releasePrefix}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`,
{
method: 'POST',
headers,
body: form,
}
);
}
console.log(`Release ${releaseTag} published on ${options.owner}/${options.repo}`);
}
run().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});