Files
clash-proxy/scripts/updater.mjs
2025-11-03 21:00:29 +08:00

442 lines
14 KiB
JavaScript

import process from "node:process";
import { getOctokit, context } from "@actions/github";
import fetch from "node-fetch";
import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs";
const SEMVER_REGEX =
/v?\d+(?:\.\d+){2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/g;
const stripLeadingV = (version) =>
typeof version === "string" && version.startsWith("v")
? version.slice(1)
: version;
const preferCandidate = (current, candidate) => {
if (!candidate) return current;
if (!current) return candidate;
const candidateHasPre = /[-+]/.test(candidate);
const currentHasPre = /[-+]/.test(current);
if (candidateHasPre && !currentHasPre) return candidate;
if (candidateHasPre === currentHasPre && candidate.length > current.length) {
return candidate;
}
return current;
};
const extractBestSemver = (input) => {
if (typeof input !== "string") return null;
const matches = input.match(SEMVER_REGEX);
if (!matches) return null;
return matches
.map(stripLeadingV)
.reduce((best, candidate) => preferCandidate(best, candidate), null);
};
const resolveReleaseVersion = (release) => {
const sources = [
release?.name,
release?.tag_name,
release?.body,
...(Array.isArray(release?.assets)
? release.assets.map((asset) => asset?.name)
: []),
];
return sources.reduce((best, source) => {
const candidate = extractBestSemver(source);
return preferCandidate(best, candidate);
}, null);
};
// Add stable update JSON filenames
const UPDATE_TAG_NAME = "updater";
const UPDATE_JSON_FILE = "update.json";
const UPDATE_JSON_PROXY = "update-proxy.json";
// Add alpha update JSON filenames
const ALPHA_TAG_NAME = "updater-alpha";
const ALPHA_UPDATE_JSON_FILE = "update.json";
const ALPHA_UPDATE_JSON_PROXY = "update-proxy.json";
// Add autobuild update JSON filenames
const AUTOBUILD_SOURCE_TAG_NAME = "autobuild";
const AUTOBUILD_TAG_NAME = "updater-autobuild";
const AUTOBUILD_UPDATE_JSON_FILE = "update.json";
const AUTOBUILD_UPDATE_JSON_PROXY = "update-proxy.json";
/// generate update.json
/// upload to update tag's release asset
async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
// Fetch all tags using pagination
let allTags = [];
let page = 1;
const perPage = 100;
while (true) {
const { data: pageTags } = await github.rest.repos.listTags({
...options,
per_page: perPage,
page: page,
});
allTags = allTags.concat(pageTags);
// Break if we received fewer tags than requested (last page)
if (pageTags.length < perPage) {
break;
}
page++;
}
const tags = allTags;
console.log(`Retrieved ${tags.length} tags in total`);
// More flexible tag detection with regex patterns
const stableTagRegex = /^v\d+\.\d+\.\d+$/; // Matches vX.Y.Z format
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i; // Matches exact alpha/beta/rc/pre tags
// Get tags for known channels
const stableTag = tags.find((t) => stableTagRegex.test(t.name));
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name));
const autobuildTag = tags.find((t) => t.name === AUTOBUILD_SOURCE_TAG_NAME);
console.log("All tags:", tags.map((t) => t.name).join(", "));
console.log("Stable tag:", stableTag ? stableTag.name : "None found");
console.log(
"Pre-release tag:",
preReleaseTag ? preReleaseTag.name : "None found",
);
console.log(
"Autobuild tag:",
autobuildTag ? autobuildTag.name : "None found",
);
console.log();
const channels = [
{
name: "stable",
tagName: stableTag?.name,
updateReleaseTag: UPDATE_TAG_NAME,
jsonFile: UPDATE_JSON_FILE,
proxyFile: UPDATE_JSON_PROXY,
prerelease: false,
},
{
name: "alpha",
tagName: preReleaseTag?.name,
updateReleaseTag: ALPHA_TAG_NAME,
jsonFile: ALPHA_UPDATE_JSON_FILE,
proxyFile: ALPHA_UPDATE_JSON_PROXY,
prerelease: true,
},
{
name: "autobuild",
tagName: autobuildTag?.name ?? AUTOBUILD_SOURCE_TAG_NAME,
updateReleaseTag: AUTOBUILD_TAG_NAME,
jsonFile: AUTOBUILD_UPDATE_JSON_FILE,
proxyFile: AUTOBUILD_UPDATE_JSON_PROXY,
prerelease: true,
},
];
for (const channel of channels) {
if (!channel.tagName) {
console.log(`[${channel.name}] tag not found, skipping...`);
continue;
}
await processRelease(github, options, channel);
}
}
// Process a release and generate update files for the specified channel
async function processRelease(github, options, channelConfig) {
if (!channelConfig) return;
const {
tagName,
name: channelName,
updateReleaseTag,
jsonFile,
proxyFile,
prerelease,
} = channelConfig;
const channelLabel =
channelName.charAt(0).toUpperCase() + channelName.slice(1);
try {
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: tagName,
});
const releaseTagName = release.tag_name ?? tagName;
const resolvedVersion = resolveReleaseVersion(release);
if (!resolvedVersion) {
throw new Error(
`[${channelName}] Failed to determine semver version from release "${releaseTagName}"`,
);
}
console.log(
`[${channelName}] Preparing update metadata from release "${releaseTagName}"`,
);
console.log(
`[${channelName}] Resolved release version: ${resolvedVersion}`,
);
const updateData = {
version: resolvedVersion,
tag_name: releaseTagName,
notes: await resolveUpdateLog(releaseTagName).catch(() =>
resolveUpdateLogDefault().catch(() => "No changelog available"),
),
pub_date: new Date().toISOString(),
platforms: {
win64: { signature: "", url: "" }, // compatible with older formats
linux: { signature: "", url: "" }, // compatible with older formats
darwin: { signature: "", url: "" }, // compatible with older formats
"darwin-aarch64": { signature: "", url: "" },
"darwin-intel": { signature: "", url: "" },
"darwin-x86_64": { signature: "", url: "" },
"linux-x86_64": { signature: "", url: "" },
"linux-x86": { signature: "", url: "" },
"linux-i686": { signature: "", url: "" },
"linux-aarch64": { signature: "", url: "" },
"linux-armv7": { signature: "", url: "" },
"windows-x86_64": { signature: "", url: "" },
"windows-aarch64": { signature: "", url: "" },
"windows-x86": { signature: "", url: "" },
"windows-i686": { signature: "", url: "" },
},
};
const promises = release.assets.map(async (asset) => {
const { name, browser_download_url } = asset;
// Process all the platform URL and signature data
// win64 url
if (name.endsWith("x64-setup.exe")) {
updateData.platforms.win64.url = browser_download_url;
updateData.platforms["windows-x86_64"].url = browser_download_url;
}
// win64 signature
if (name.endsWith("x64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.win64.signature = sig;
updateData.platforms["windows-x86_64"].signature = sig;
}
// win32 url
if (name.endsWith("x86-setup.exe")) {
updateData.platforms["windows-x86"].url = browser_download_url;
updateData.platforms["windows-i686"].url = browser_download_url;
}
// win32 signature
if (name.endsWith("x86-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86"].signature = sig;
updateData.platforms["windows-i686"].signature = sig;
}
// win arm url
if (name.endsWith("arm64-setup.exe")) {
updateData.platforms["windows-aarch64"].url = browser_download_url;
}
// win arm signature
if (name.endsWith("arm64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-aarch64"].signature = sig;
}
// darwin url (intel)
if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) {
updateData.platforms.darwin.url = browser_download_url;
updateData.platforms["darwin-intel"].url = browser_download_url;
updateData.platforms["darwin-x86_64"].url = browser_download_url;
}
// darwin signature (intel)
if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.darwin.signature = sig;
updateData.platforms["darwin-intel"].signature = sig;
updateData.platforms["darwin-x86_64"].signature = sig;
}
// darwin url (aarch)
if (name.endsWith("aarch64.app.tar.gz")) {
updateData.platforms["darwin-aarch64"].url = browser_download_url;
// 使linux可以检查更新
updateData.platforms.linux.url = browser_download_url;
updateData.platforms["linux-x86_64"].url = browser_download_url;
updateData.platforms["linux-x86"].url = browser_download_url;
updateData.platforms["linux-i686"].url = browser_download_url;
updateData.platforms["linux-aarch64"].url = browser_download_url;
updateData.platforms["linux-armv7"].url = browser_download_url;
}
// darwin signature (aarch)
if (name.endsWith("aarch64.app.tar.gz.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["darwin-aarch64"].signature = sig;
updateData.platforms.linux.signature = sig;
updateData.platforms["linux-x86_64"].signature = sig;
updateData.platforms["linux-x86"].url = browser_download_url;
updateData.platforms["linux-i686"].url = browser_download_url;
updateData.platforms["linux-aarch64"].signature = sig;
updateData.platforms["linux-armv7"].signature = sig;
}
});
await Promise.allSettled(promises);
console.log(`[${channelName}] Update data snapshot:`, updateData);
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(
`[${channelName}] [Error]: failed to parse release for "${key}"`,
);
delete updateData.platforms[key];
}
});
// Generate a proxy update file for accelerated GitHub resources
const updateDataNew = JSON.parse(JSON.stringify(updateData));
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) {
updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url;
} else {
console.log(
`[${channelName}] [Error]: updateDataNew.platforms.${key} is null`,
);
}
});
console.log(
`[${channelName}] Processing update release target "${updateReleaseTag}"`,
);
try {
let updateRelease;
try {
// Try to get the existing release
const response = await github.rest.repos.getReleaseByTag({
...options,
tag: updateReleaseTag,
});
updateRelease = response.data;
console.log(
`[${channelName}] Found existing ${updateReleaseTag} release with ID: ${updateRelease.id}`,
);
} catch (error) {
// If release doesn't exist, create it
if (error.status === 404) {
console.log(
`[${channelName}] Release with tag ${updateReleaseTag} not found, creating new release...`,
);
const createResponse = await github.rest.repos.createRelease({
...options,
tag_name: updateReleaseTag,
name: `Auto-update ${channelLabel} Channel`,
body: `This release contains the update information for the ${channelName} channel.`,
prerelease,
});
updateRelease = createResponse.data;
console.log(
`[${channelName}] Created new ${updateReleaseTag} release with ID: ${updateRelease.id}`,
);
} else {
// If it's another error, throw it
throw error;
}
}
// File names based on release type
// Delete existing assets with these names
for (const asset of updateRelease.assets) {
if (asset.name === jsonFile) {
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: asset.id,
});
}
if (asset.name === proxyFile) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch((deleteError) =>
console.error(
`[${channelName}] Failed to delete existing proxy asset:`,
deleteError.message,
),
); // do not break the pipeline
}
}
// Upload new assets
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: jsonFile,
data: JSON.stringify(updateData, null, 2),
});
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: proxyFile,
data: JSON.stringify(updateDataNew, null, 2),
});
console.log(
`[${channelName}] Successfully uploaded update files to ${updateReleaseTag}`,
);
} catch (error) {
console.error(
`[${channelName}] Failed to process update release:`,
error.message,
);
}
} catch (error) {
if (error.status === 404) {
console.log(
`[${channelName}] Release not found for tag: ${tagName}, skipping...`,
);
} else {
console.error(
`[${channelName}] Failed to get release for tag: ${tagName}`,
error.message,
);
}
}
}
// get the signature file content
async function getSignature(url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/octet-stream" },
});
return response.text();
}
resolveUpdater().catch(console.error);