Public dry run dryrun-750f29790fe2
Source commit: 750f29790fe2046599fbe5b5d06fdb63d92fabff Public tree identity: sha256:02326e08850bb287585789733ea5f3f153e1f7f3fd88afcc24e249107df91506
This commit is contained in:
commit
de66658961
102 changed files with 31669 additions and 0 deletions
336
scripts/publish-public-release-dryrun.js
Executable file
336
scripts/publish-public-release-dryrun.js
Executable file
|
|
@ -0,0 +1,336 @@
|
|||
#!/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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue