Resourcemanagement/extra/release/lib.mjs

677 lines
20 KiB
JavaScript

import { loadEnvFile } from "node:process";
import * as childProcess from "child_process";
import semver from "semver";
import fs from "fs";
import tar from "tar";
try {
loadEnvFile();
} catch (_) {}
// Support both the legacy RELEASE_DRY_RUN=1 format and DRY_RUN=true used by GitHub Actions workflows
export const dryRun = process.env.RELEASE_DRY_RUN === "1" || process.env.DRY_RUN === "true";
/**
* Read the release version from environment variables
* @returns {string|undefined} Release version
*/
export function getVersionFromEnv() {
return process.env.RELEASE_VERSION || process.env.RELEASE_BETA_VERSION;
}
/**
* Check if this is a beta release
* @returns {boolean} Is a beta release
*/
export function isBetaRelease() {
return process.env.RELEASE_IS_BETA === "true" || !!process.env.RELEASE_BETA_VERSION;
}
/**
* Save PR number to tmp/pr-number.txt for later stages
* @param {number} prNumber PR number
* @returns {void}
*/
export function savePrNumber(prNumber) {
const tmpDir = "./tmp";
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
fs.writeFileSync(`${tmpDir}/pr-number.txt`, String(prNumber));
}
/**
* Save the squash merge commit SHA to tmp/merge-sha.txt for later stages
* @param {string} sha Merge commit SHA
* @returns {void}
*/
export function saveMergeSha(sha) {
const tmpDir = "./tmp";
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
fs.writeFileSync(`${tmpDir}/merge-sha.txt`, sha);
}
/**
* Read the merge commit SHA from env or tmp/merge-sha.txt
* @returns {string|null} Merge commit SHA or null
*/
export function readMergeSha() {
if (process.env.MERGE_SHA) {
return process.env.MERGE_SHA;
}
const file = "./tmp/merge-sha.txt";
if (fs.existsSync(file)) {
return fs.readFileSync(file, "utf-8").trim() || null;
}
return null;
}
if (dryRun) {
console.info("Dry run enabled.");
}
/**
* Check if docker is running
* @returns {void}
*/
export function checkDocker() {
try {
childProcess.execSync("docker ps");
} catch (error) {
console.error("Docker is not running. Please start docker and try again.");
process.exit(1);
}
}
/**
* Get Docker Hub repository name
* @returns {string[]} List of repository names
*/
export function getRepoNames() {
if (process.env.RELEASE_REPO_NAMES) {
// Split by comma
return process.env.RELEASE_REPO_NAMES.split(",").map((name) => name.trim());
}
return ["louislam/uptime-kuma", "ghcr.io/louislam/uptime-kuma"];
}
/**
* Build frontend dist
* @returns {void}
*/
export function buildDist() {
if (!dryRun) {
childProcess.execSync("npm run build", { stdio: "inherit" });
} else {
console.info("[DRY RUN] npm run build");
}
}
/**
* Build docker image and push to Docker Hub
* @param {string[]} repoNames Docker Hub repository names
* @param {string[]} tags Docker image tags
* @param {string} target Dockerfile's target name
* @param {string} buildArgs Docker build args
* @param {string} dockerfile Path to Dockerfile
* @param {string} platform Build platform
* @returns {void}
*/
export function buildImage(
repoNames,
tags,
target,
buildArgs = "",
dockerfile = "docker/dockerfile",
platform = "linux/amd64,linux/arm64"
) {
let args = ["buildx", "build", "-f", dockerfile, "--platform", platform];
for (let repoName of repoNames) {
// Add tags
for (let tag of tags) {
args.push("-t", `${repoName}:${tag}`);
}
}
args = [...args, "--target", target];
// Add build args
if (buildArgs) {
args.push("--build-arg", buildArgs);
}
args = [...args, ".", "--push"];
if (!dryRun) {
childProcess.spawnSync("docker", args, { stdio: "inherit" });
} else {
console.log(`[DRY RUN] docker ${args.join(" ")}`);
}
}
/**
* Check if the version already exists on Docker Hub
* TODO: use semver to compare versions if it is greater than the previous?
* @param {string[]} repoNames repository name (Only check the name with single slash)
* @param {string} version Version to check
* @returns {void}
*/
export async function checkTagExists(repoNames, version) {
// Skip if the tag is not on Docker Hub
// louislam/uptime-kuma
let dockerHubRepoNames = repoNames.filter((name) => {
return name.split("/").length === 2;
});
for (let repoName of dockerHubRepoNames) {
await checkTagExistsSingle(repoName, version);
}
}
/**
* Check if the version already exists on Docker Hub
* @param {string} repoName repository name
* @param {string} version Version to check
* @returns {Promise<void>}
*/
export async function checkTagExistsSingle(repoName, version) {
console.log(`Checking if version ${version} exists on Docker Hub:`, repoName);
// Get a list of tags from the Docker Hub repository
let tags = [];
// It is mainly to check my careless mistake that I forgot to update the release version in .env, so `page_size` is set to 100 is enough, I think.
const response = await fetch(`https://hub.docker.com/v2/repositories/${repoName}/tags/?page_size=100`);
if (response.ok) {
const data = await response.json();
tags = data.results.map((tag) => tag.name);
} else {
console.error("Failed to get tags from Docker Hub");
process.exit(1);
}
// Check if the version already exists
if (tags.includes(version)) {
console.error(`Version ${version} already exists`);
process.exit(1);
}
}
/**
* Check the version format
* @param {string} version Version to check
* @returns {void}
*/
export function checkVersionFormat(version) {
if (!version) {
console.error("VERSION is required");
process.exit(1);
}
// Check the version format, it should be a semver and must be like this: "2.0.0-beta.0"
if (!semver.valid(version)) {
console.error("VERSION is not a valid semver version");
process.exit(1);
}
}
/**
* Find an open pull request by its head branch
* @param {string} branchName Head branch name
* @returns {Promise<number|null>} PR number or null if not found
*/
export async function findOpenPrByHead(branchName) {
const result = childProcess.spawnSync(
"gh",
["pr", "list", "--head", branchName, "--state", "open", "--json", "number", "--limit", "1"],
{
encoding: "utf-8",
}
);
if (result.status !== 0) {
console.error(result.stderr);
console.error("Failed to list pull requests");
process.exit(1);
}
const prs = JSON.parse(result.stdout);
return prs.length > 0 ? prs[0].number : null;
}
/**
* Check whether a pull request has been merged
* @param {number} prNumber PR number
* @returns {Promise<boolean>} Is the PR merged
*/
export async function isPrMerged(prNumber) {
const result = childProcess.spawnSync("gh", ["pr", "view", String(prNumber), "--json", "state"], {
encoding: "utf-8",
});
if (result.status !== 0) {
console.error(result.stderr);
console.error(`Failed to get state of PR #${prNumber}`);
process.exit(1);
}
return JSON.parse(result.stdout).state === "MERGED";
}
/**
* Get the squash merge commit SHA of a merged pull request
* @param {number} prNumber PR number
* @returns {Promise<string|null>} Merge commit SHA or null if not merged
*/
export async function getPrMergeCommit(prNumber) {
const result = childProcess.spawnSync("gh", ["pr", "view", String(prNumber), "--json", "mergeCommit"], {
encoding: "utf-8",
});
if (result.status !== 0) {
console.error(result.stderr);
console.error(`Failed to get merge commit of PR #${prNumber}`);
process.exit(1);
}
const obj = JSON.parse(result.stdout);
return obj.mergeCommit ? obj.mergeCommit.oid : null;
}
/**
* Check whether a GitHub release already exists (draft or published)
* @param {string} version Version tag
* @returns {Promise<boolean>} Does the release exist
*/
export async function releaseExists(version) {
const result = childProcess.spawnSync("gh", ["release", "view", version, "--json", "tagName"], {
encoding: "utf-8",
});
if (result.status === 0) {
return true;
}
// gh exits with a non-zero code both when the release is missing and on real errors
if ((result.stderr || "").includes("not found")) {
return false;
}
console.error(result.stderr);
console.error(`Failed to check if release ${version} exists`);
process.exit(1);
}
/**
* Create a draft GitHub Release without assets
* Skips creation if the release already exists, so it can be safely re-run
* @param {string} version Version tag
* @param {string} changelog Changelog content
* @param {boolean} isBeta Mark as pre-release
* @param {string|null} targetSha Commit SHA the tag should point to when published
* @returns {Promise<boolean>} true if created, false if it already existed
*/
export async function createDraftRelease(version, changelog, isBeta = false, targetSha = null) {
if (await releaseExists(version)) {
console.log(`Release ${version} already exists, skipping creation.`);
return false;
}
console.log(`Creating draft release ${version}...`);
const releaseBody = `## ${version}
${changelog}`;
let releaseArgs = ["release", "create", version];
if (targetSha) {
releaseArgs.push("--target", targetSha);
}
releaseArgs = releaseArgs.concat(["--draft", "--title", version, "--notes", releaseBody]);
if (isBeta) {
releaseArgs.push("--prerelease");
}
const result = childProcess.spawnSync("gh", releaseArgs, {
encoding: "utf-8",
});
if (result.status !== 0) {
console.error(result.stderr);
console.error("Failed to create release");
process.exit(1);
}
console.log(`Draft release ${version} created.`);
console.log("Next steps:");
console.log(` 1. Review the draft release: https://github.com/louislam/uptime-kuma/releases/tag/${version}`);
console.log(" 2. Edit if needed and publish.");
return true;
}
/**
* Upload asset files to an existing GitHub release
* Uses --clobber, so it can be safely re-run
* @param {string} version Version tag
* @param {string[]} files File paths to upload
* @returns {Promise<void>}
*/
export async function uploadReleaseAssets(version, files) {
if (dryRun) {
console.log(`[DRY RUN] gh release upload ${version} --clobber ${files.join(" ")}`);
return;
}
const args = ["release", "upload", version, "--clobber", ...files];
const result = childProcess.spawnSync("gh", args, {
stdio: "inherit",
});
if (result.status !== 0) {
console.error("Failed to upload assets");
process.exit(1);
}
console.log("Assets uploaded.");
}
/**
* Build and push all docker images for a release
* @param {string[]} repoNames Docker repository names
* @param {string} version Version tag
* @param {boolean} isBeta Beta tags instead of final ones
* @returns {void}
*/
export function buildAllImages(repoNames, version, isBeta) {
const majorVersion = semver.parse(version).major;
if (isBeta) {
// Build slim image (rootless)
buildImage(
repoNames,
["beta-slim-rootless", ver(version, "slim-rootless")],
"rootless",
`BASE_IMAGE=louislam/uptime-kuma:base${majorVersion}-slim`
);
// Build full image (rootless)
buildImage(repoNames, ["beta-rootless", ver(version, "rootless")], "rootless");
// Build slim image
buildImage(
repoNames,
["beta-slim", ver(version, "slim")],
"release",
`BASE_IMAGE=louislam/uptime-kuma:base${majorVersion}-slim`
);
// Build full image
buildImage(repoNames, ["beta", version], "release");
} else {
// Build slim image (rootless)
buildImage(
repoNames,
[`next-slim-rootless`, `${majorVersion}-slim-rootless`, ver(version, "slim-rootless")],
"rootless",
`BASE_IMAGE=louislam/uptime-kuma:base${majorVersion}-slim`
);
// Build full image (rootless)
buildImage(repoNames, ["next-rootless", `${majorVersion}-rootless`, ver(version, "rootless")], "rootless");
// Build slim image
buildImage(
repoNames,
["next-slim", `${majorVersion}-slim`, ver(version, "slim")],
"release",
`BASE_IMAGE=louislam/uptime-kuma:base${majorVersion}-slim`
);
// Build full image
buildImage(repoNames, ["next", `${majorVersion}`, version], "release");
}
}
/**
* Press any key to continue
* @returns {Promise<void>}
*/
export function pressAnyKey() {
console.log("Git Push and Publish the release note on github, then press any key to continue");
process.stdin.setRawMode(true);
process.stdin.resume();
return new Promise((resolve) =>
process.stdin.once("data", (data) => {
process.stdin.setRawMode(false);
process.stdin.pause();
resolve();
})
);
}
/**
* Append version identifier
* @param {string} version Version
* @param {string} identifier Identifier
* @returns {string} Version with identifier
*/
export function ver(version, identifier) {
const obj = semver.parse(version);
if (obj.prerelease.length === 0) {
obj.prerelease = [identifier];
} else {
obj.prerelease[0] = [obj.prerelease[0], identifier].join("-");
}
return obj.format();
}
/**
* Upload artifacts to GitHub
* docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain
* @param {string} version Version
* @param {string} githubToken GitHub token
* @returns {void}
* @deprecated
*/
export function uploadArtifacts(version, githubToken) {
let args = [
"buildx",
"build",
"-f",
"docker/dockerfile",
"--platform",
"linux/amd64",
"-t",
"louislam/uptime-kuma:upload-artifact",
"--build-arg",
`VERSION=${version}`,
"--build-arg",
"GITHUB_TOKEN",
"--target",
"upload-artifact",
".",
"--progress",
"plain",
];
if (!dryRun) {
childProcess.spawnSync("docker", args, {
stdio: "inherit",
env: {
...process.env,
GITHUB_TOKEN: githubToken,
},
});
} else {
console.log(`[DRY RUN] docker ${args.join(" ")}`);
}
}
/**
* Execute a command
* @param {string} cmd Command to execute
* @returns {void}
*/
export function execSync(cmd) {
if (!dryRun) {
childProcess.execSync(cmd, { stdio: "inherit" });
} else {
console.info(`[DRY RUN] ${cmd}`);
}
}
/**
* Check if the current branch matches the expected release branch pattern
* @param {string} expectedBranch Expected branch name (can be "release" or "release-{version}")
* @returns {void}
*/
export function checkReleaseBranch(expectedBranch = "release") {
const res = childProcess.spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = res.stdout.toString().trim();
if (branch !== expectedBranch) {
console.error(`Current branch is ${branch}, please switch to "${expectedBranch}" branch`);
process.exit(1);
}
}
/**
* Create dist.tar.gz from the dist directory
* Similar to "tar -zcvf dist.tar.gz dist", but using nodejs
* @returns {Promise<void>}
*/
export async function createDistTarGz() {
const distPath = "dist";
const outputPath = "./tmp/dist.tar.gz";
const tmpDir = "./tmp";
// Ensure tmp directory exists
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Check if dist directory exists
if (!fs.existsSync(distPath)) {
console.error("Error: dist directory not found");
process.exit(1);
}
console.log(`Creating ${outputPath} from ${distPath}...`);
try {
await tar.create(
{
gzip: true,
file: outputPath,
},
[distPath]
);
console.log(`Successfully created ${outputPath}`);
} catch (error) {
console.error(`Failed to create tarball: ${error.message}`);
process.exit(1);
}
}
/**
* Create a release PR
* @param {string} version Version
* @param {string} previousVersion Previous version tag
* @param {boolean} dryRun Still create the PR, but add "[DRY RUN]" to the title
* @param {string} branchName The branch name to use for the PR head (defaults to "release")
* @param {string} githubRunId The GitHub Actions run ID for linking to artifacts
* @returns {Promise<number>} The PR number
*/
export async function createReleasePR(version, previousVersion, dryRun, branchName = "release", githubRunId = null) {
// Reuse the existing open PR for this branch, so re-running does not close or duplicate it
const existingPrNumber = await findOpenPrByHead(branchName);
if (existingPrNumber) {
console.log(`Found existing open PR #${existingPrNumber} for branch ${branchName}, reusing it.`);
savePrNumber(existingPrNumber);
return existingPrNumber;
}
const title = dryRun ? `chore: update to ${version} (dry run)` : `chore: update to ${version}`;
// Build the artifact link - use direct run link if available, otherwise link to workflow file
const artifactLink = githubRunId
? `https://github.com/louislam/uptime-kuma/actions/runs/${githubRunId}/workflow`
: `https://github.com/louislam/uptime-kuma/actions/workflows/release.yml`;
const tmpDir = "./tmp";
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
const body = `## Release ${version}
This PR prepares the release for version ${version}.
### Release Artifacts
The \`dist.tar.gz\` archive will be available as an artifact in the [workflow run](${artifactLink}).
`;
// Create the PR using gh CLI
// RELEASE_BASE_BRANCH lets the release workflow target a maintenance branch (e.g. 2.5.X) instead of master
const baseBranch = process.env.RELEASE_BASE_BRANCH || "master";
const args = ["pr", "create", "--title", title, "--body", body, "--base", baseBranch, "--head", branchName];
console.log(`Creating PR: ${title}`);
const result = childProcess.spawnSync("gh", args, {
encoding: "utf-8",
stdio: "pipe",
});
if (result.status !== 0) {
console.error(result.stderr);
console.error("Failed to create pull request");
process.exit(1);
}
const prUrl = result.stdout.trim();
console.log(prUrl);
// Extract PR number from URL (e.g., https://github.com/louislam/uptime-kuma/pull/1234)
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : null;
if (prNumber) {
console.log(`PR number: ${prNumber}`);
// Save PR number to file for the finish script
savePrNumber(prNumber);
} else {
console.warn("Could not extract PR number from URL, auto-finish will not be possible");
}
console.log("Successfully created pull request");
return prNumber;
}
/**
* Create a draft release, optionally with dist.tar.gz uploaded as an asset
* @param {string} version Version tag
* @param {string} changelog Changelog content
* @param {boolean} isBeta Mark as pre-release
* @param {string} distTarGz If empty, it will not be uploaded to the release
* @returns {Promise<void>}
*/
export async function createRelease(version, changelog, isBeta = false, distTarGz = undefined) {
await createDraftRelease(version, changelog, isBeta);
if (distTarGz) {
if (!fs.existsSync(distTarGz)) {
console.error(`dist.tar.gz not found: ${distTarGz}`);
process.exit(1);
}
await uploadReleaseAssets(version, [distTarGz]);
}
}