Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
336 lines
9.5 KiB
JavaScript
Executable file
336 lines
9.5 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const https = require("https");
|
|
const path = require("path");
|
|
const cp = require("child_process");
|
|
|
|
const repo = path.resolve(__dirname, "..");
|
|
const releaseRoot = path.resolve(
|
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
|
path.join(repo, "target/public-release-dryrun")
|
|
);
|
|
const manifestPath = path.join(releaseRoot, "public-release-manifest.json");
|
|
const reportPath = path.join(
|
|
repo,
|
|
"target/acceptance/public-release-dryrun-forgejo-release.json"
|
|
);
|
|
const forgejoUrl = (
|
|
process.env.DISASMER_FORGEJO_URL || "https://git.michelpaulissen.com"
|
|
).replace(/\/+$/, "");
|
|
const token = process.env.DISASMER_FORGEJO_TOKEN;
|
|
let owner = process.env.DISASMER_PUBLIC_REPO_OWNER;
|
|
let repoName = process.env.DISASMER_PUBLIC_REPO_NAME;
|
|
|
|
function requireEnv(name, value) {
|
|
if (!value) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
}
|
|
|
|
function commandOutput(command, args) {
|
|
try {
|
|
return cp
|
|
.execFileSync(command, args, {
|
|
cwd: repo,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
})
|
|
.trim();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function expectedSourceCommit() {
|
|
return (
|
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
function parseForgejoRepoIdentity(remote) {
|
|
if (!remote) {
|
|
return null;
|
|
}
|
|
|
|
let pathname = remote;
|
|
try {
|
|
pathname = new URL(remote).pathname;
|
|
} catch (_) {
|
|
const scpLike = /^[^@/]+@[^:]+:(.+)$/.exec(remote);
|
|
if (scpLike) {
|
|
pathname = scpLike[1];
|
|
}
|
|
}
|
|
|
|
const parts = pathname
|
|
.replace(/^\/+/, "")
|
|
.replace(/\.git$/, "")
|
|
.split("/")
|
|
.filter(Boolean);
|
|
if (parts.length < 2) {
|
|
return null;
|
|
}
|
|
return {
|
|
owner: parts[parts.length - 2],
|
|
repoName: parts[parts.length - 1],
|
|
};
|
|
}
|
|
|
|
function resolveRepoIdentity(manifest) {
|
|
if (owner && repoName) {
|
|
return;
|
|
}
|
|
|
|
const inferred = parseForgejoRepoIdentity(
|
|
manifest.public_repo_url ||
|
|
manifest.public_repo_remote ||
|
|
process.env.DISASMER_PUBLIC_REPO_REMOTE
|
|
);
|
|
owner = owner || (inferred && inferred.owner);
|
|
repoName = repoName || (inferred && inferred.repoName);
|
|
if (!owner || !repoName) {
|
|
throw new Error(
|
|
"DISASMER_PUBLIC_REPO_OWNER and DISASMER_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL"
|
|
);
|
|
}
|
|
}
|
|
|
|
function apiPath(pathname) {
|
|
return `/api/v1${pathname}`;
|
|
}
|
|
|
|
function request(method, pathname, { body, headers = {} } = {}) {
|
|
const url = new URL(apiPath(pathname), forgejoUrl);
|
|
const payload =
|
|
body === undefined
|
|
? null
|
|
: Buffer.isBuffer(body)
|
|
? body
|
|
: Buffer.from(JSON.stringify(body));
|
|
const requestHeaders = {
|
|
Accept: "application/json",
|
|
Authorization: `token ${token}`,
|
|
...headers,
|
|
};
|
|
if (payload) {
|
|
requestHeaders["Content-Length"] = payload.length;
|
|
if (!requestHeaders["Content-Type"]) {
|
|
requestHeaders["Content-Type"] = "application/json";
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.request(
|
|
url,
|
|
{
|
|
method,
|
|
headers: requestHeaders,
|
|
},
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
res.on("end", () => {
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
let parsed = null;
|
|
if (text.trim()) {
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch (_) {
|
|
parsed = text;
|
|
}
|
|
}
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(
|
|
new Error(
|
|
`${method} ${url.pathname} failed with ${res.statusCode}: ${text}`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
resolve({ status: res.statusCode, body: parsed });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function multipartFile(fieldName, file) {
|
|
const boundary = `disasmer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const name = path.basename(file);
|
|
const header = Buffer.from(
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="${fieldName}"; filename="${name}"\r\n` +
|
|
"Content-Type: application/octet-stream\r\n\r\n"
|
|
);
|
|
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
return {
|
|
body: Buffer.concat([header, fs.readFileSync(file), footer]),
|
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
};
|
|
}
|
|
|
|
async function existingRelease(tagName) {
|
|
const releases = await request(
|
|
"GET",
|
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases?limit=100`
|
|
);
|
|
return (releases.body || []).find((release) => release.tag_name === tagName) || null;
|
|
}
|
|
|
|
async function createOrReuseRelease(manifest) {
|
|
const tagName = manifest.release_name;
|
|
const found = await existingRelease(tagName);
|
|
if (found) {
|
|
return { release: found, created: false };
|
|
}
|
|
const targetCommitish =
|
|
(manifest.public_tree_publish && manifest.public_tree_publish.commit) ||
|
|
process.env.DISASMER_PUBLIC_RELEASE_TARGET ||
|
|
"main";
|
|
const body = [
|
|
"Disasmer public release dry run.",
|
|
"",
|
|
`Default operator endpoint: ${manifest.default_operator_endpoint}`,
|
|
`DNS publication state: ${manifest.dns_publication_state}`,
|
|
`Resolver override: ${manifest.resolver_override}`,
|
|
`Public tree identity: ${manifest.public_tree_identity}`,
|
|
`Source commit: ${manifest.source_commit}`,
|
|
].join("\n");
|
|
const created = await request(
|
|
"POST",
|
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases`,
|
|
{
|
|
body: {
|
|
tag_name: tagName,
|
|
target_commitish: targetCommitish,
|
|
name: tagName,
|
|
body,
|
|
draft: false,
|
|
prerelease: true,
|
|
},
|
|
}
|
|
);
|
|
return { release: created.body, created: true };
|
|
}
|
|
|
|
async function loadRelease(releaseId) {
|
|
const response = await request(
|
|
"GET",
|
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${releaseId}`
|
|
);
|
|
return response.body;
|
|
}
|
|
|
|
async function uploadAsset(release, asset) {
|
|
const { body, contentType } = multipartFile("attachment", asset.file);
|
|
const response = await request(
|
|
"POST",
|
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`,
|
|
{
|
|
body,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
},
|
|
}
|
|
);
|
|
return response.body;
|
|
}
|
|
|
|
function existingAssetByName(release, name) {
|
|
return Array.isArray(release.assets)
|
|
? release.assets.find((asset) => asset.name === name) || null
|
|
: null;
|
|
}
|
|
|
|
async function main() {
|
|
requireEnv("DISASMER_FORGEJO_TOKEN", token);
|
|
if (!fs.existsSync(manifestPath)) {
|
|
throw new Error(`missing public release manifest: ${manifestPath}`);
|
|
}
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
resolveRepoIdentity(manifest);
|
|
if (manifest.kind !== "disasmer-public-release-dryrun") {
|
|
throw new Error(`unexpected public release manifest kind: ${manifest.kind}`);
|
|
}
|
|
if (manifest.source_commit !== expectedSourceCommit()) {
|
|
throw new Error(
|
|
"public release manifest is stale; regenerate it for the current acceptance commit before publishing the Forgejo Release"
|
|
);
|
|
}
|
|
const publicTreeAlreadyPushed =
|
|
process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1";
|
|
if (
|
|
!publicTreeAlreadyPushed &&
|
|
(!manifest.public_tree_publish || manifest.public_tree_publish.pushed !== true)
|
|
) {
|
|
throw new Error(
|
|
"public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with DISASMER_PUBLISH_PUBLIC_TREE=1"
|
|
);
|
|
}
|
|
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
|
throw new Error("public release manifest has no assets");
|
|
}
|
|
|
|
const { release: releaseResult, created } = await createOrReuseRelease(manifest);
|
|
const release = await loadRelease(releaseResult.id);
|
|
const uploaded = [];
|
|
const reused = [];
|
|
for (const asset of manifest.assets) {
|
|
if (!fs.existsSync(asset.file)) {
|
|
throw new Error(`missing release asset: ${asset.file}`);
|
|
}
|
|
const existing = existingAssetByName(release, asset.name);
|
|
if (existing) {
|
|
reused.push(existing);
|
|
continue;
|
|
}
|
|
uploaded.push(await uploadAsset(release, asset));
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
const report = {
|
|
kind: "disasmer-public-release-dryrun-forgejo-release",
|
|
forgejo_url: forgejoUrl,
|
|
owner,
|
|
repo: repoName,
|
|
release_id: release.id,
|
|
release_name: release.name || manifest.release_name,
|
|
tag_name: release.tag_name || manifest.release_name,
|
|
release_created: created,
|
|
default_operator_endpoint: manifest.default_operator_endpoint,
|
|
public_tree_identity: manifest.public_tree_identity,
|
|
source_commit: manifest.source_commit,
|
|
uploaded_assets: uploaded.map((asset) => ({
|
|
id: asset.id,
|
|
name: asset.name,
|
|
size: asset.size,
|
|
browser_download_url: asset.browser_download_url,
|
|
})),
|
|
reused_assets: reused.map((asset) => ({
|
|
id: asset.id,
|
|
name: asset.name,
|
|
size: asset.size,
|
|
browser_download_url: asset.browser_download_url,
|
|
})),
|
|
};
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(
|
|
JSON.stringify(
|
|
{ report: reportPath, uploaded: uploaded.length, reused: reused.length },
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|