Files

183 lines
7.3 KiB
JavaScript
Raw Permalink Normal View History

/**
* SignPath 远程代码签名模块(electron-builder 自定义签名)
*
* 挂载方式:electron-builder.yml -> win.sign: scripts/signpath-sign.js
*
* electron-builder 在构建 Windows 产物时会对每个待签名文件调用本模块,
* 本模块把文件提交到 SignPath 签名后,下载签名产物并原位覆盖。
*
* 实现与 SignPath 官方 PowerShell 模块(Submit-SigningRequest)对齐:
* POST {base}/v1/{orgId}/SigningRequests multipart/form-data,响应 Location 头 = 请求 URL
* GET {base}/v1/{orgId}/SigningRequests/{id} 轮询 status / isFinalStatus,完成后返回 signedArtifactLink
* GET signedArtifactLink 下载签名产物
* 认证:Authorization: Bearer <API token>
*
* 环境变量(CI 中由 GitHub Secrets 注入;本地构建未设置时自动跳过签名):
* SIGNPATH_API_TOKEN 必填(API Token
* SIGNPATH_ORG_ID 必填(SignPath 组织 ID
* SIGNPATH_PROJECT_SLUG 可选,默认 Koring_Launcher
* SIGNPATH_SIGNING_POLICY_SLUG 签名策略 slug(必填,如 Koring_Launcher_Dev_builder
* SIGNPATH_ARTIFACT_CONFIG_SLUG 产物配置 slug;项目只有一个配置时可省略
* SIGNPATH_API_BASE 可选,默认 https://app.signpath.io/api
*
* 说明:
* - 无 SIGNPATH_API_TOKEN / SIGNPATH_ORG_ID 时直接返回(不签名),
* 因此本地 `pnpm dist:dev/beta/run` 行为与之前完全一致。
* - 签名发生在 electron-builder 生成 blockmap 与 latest.yml 之前,
* 所以清单中的 sha512 自动对应签名后的安装包。
* - 参考文档:https://docs.signpath.io/build-system-integration
*/
'use strict';
const fs = require('fs');
const path = require('path');
const API_BASE = process.env.SIGNPATH_API_BASE || 'https://app.signpath.io/api';
const PROJECT_SLUG = process.env.SIGNPATH_PROJECT_SLUG || 'Koring_Launcher';
const POLL_INTERVAL_MS = 5000;
const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 单文件签名最长等待 15 分钟
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function failWithDetail(res, ctx) {
let detail = '';
try {
detail = await res.text();
} catch {
/* ignore */
}
throw new Error(`[signpath-sign] ${ctx} failed (${res.status}): ${detail}`);
}
async function signWithSignPath(filePath) {
const token = process.env.SIGNPATH_API_TOKEN;
const orgId = process.env.SIGNPATH_ORG_ID;
const policySlug = process.env.SIGNPATH_SIGNING_POLICY_SLUG;
const artifactSlug = process.env.SIGNPATH_ARTIFACT_CONFIG_SLUG;
if (!policySlug) {
throw new Error('[signpath-sign] SIGNPATH_SIGNING_POLICY_SLUG is required when signing is enabled');
}
const headers = { Authorization: `Bearer ${token}` };
// 1. 提交签名请求(multipart/form-data,与官方 PowerShell 模块一致)
const fileBuf = fs.readFileSync(filePath);
const form = new FormData();
form.append('ProjectSlug', PROJECT_SLUG);
form.append('SigningPolicySlug', policySlug);
if (artifactSlug) {
form.append('ArtifactConfigurationSlug', artifactSlug);
}
form.append('Description', `electron-builder auto-sign: ${path.basename(filePath)}`);
form.append('Artifact', new Blob([fileBuf]), path.basename(filePath));
console.log(
`[signpath-sign] submitting signing request: ${path.basename(filePath)} (${(fileBuf.length / 1024 / 1024).toFixed(1)} MB)`
);
const submitRes = await fetch(`${API_BASE}/v1/${orgId}/SigningRequests`, {
method: 'POST',
headers,
body: form,
});
if (!submitRes.ok) {
await failWithDetail(submitRes, 'submit');
}
const requestUrl = submitRes.headers.get('location');
if (!requestUrl) {
throw new Error('[signpath-sign] submit response has no Location header');
}
console.log(`[signpath-sign] request created: ${requestUrl}`);
// 2. 轮询直到最终状态(isFinalStatus),要求 Completed
const deadline = Date.now() + POLL_TIMEOUT_MS;
let data = null;
for (;;) {
await sleep(POLL_INTERVAL_MS);
const pollRes = await fetch(requestUrl, { headers });
if (!pollRes.ok) {
await failWithDetail(pollRes, 'status query');
}
data = await pollRes.json();
console.log(`[signpath-sign] status: ${data.status}${data.workflowStatus ? ` / ${data.workflowStatus}` : ''}`);
if (data.isFinalStatus) break;
if (Date.now() > deadline) {
throw new Error('[signpath-sign] signing timed out');
}
}
if (data.status !== 'Completed') {
throw new Error(`[signpath-sign] request ${data.status}${data.workflowStatus ? ` / ${data.workflowStatus}` : ''}`);
}
// 3. 通过 signedArtifactLink 下载签名产物并原位覆盖(网络错误重试)
const downloadUrl = data.signedArtifactLink;
if (!downloadUrl) {
throw new Error('[signpath-sign] completed request has no signedArtifactLink');
}
const signedBuf = await downloadWithRetry(downloadUrl, headers);
const tmpPath = `${filePath}.signing.tmp`;
fs.writeFileSync(tmpPath, signedBuf);
fs.renameSync(tmpPath, filePath);
console.log(`[signpath-sign] signed OK (${(signedBuf.length / 1024 / 1024).toFixed(1)} MB)`);
}
const DOWNLOAD_MAX_ATTEMPTS = 3;
async function downloadWithRetry(downloadUrl, headers) {
let lastError = null;
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
try {
const dlRes = await fetch(downloadUrl, { headers });
if (!dlRes.ok) {
await failWithDetail(dlRes, 'download signed artifact');
}
return Buffer.from(await dlRes.arrayBuffer());
} catch (err) {
lastError = err;
console.warn(`[signpath-sign] download attempt ${attempt}/${DOWNLOAD_MAX_ATTEMPTS} failed: ${err.message}`);
if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
await sleep(3000 * attempt);
}
}
}
throw lastError;
}
/**
* electron-builder 自定义签名入口。
* configuration.path 为待签名文件绝对路径;无 token 时跳过(本地开发构建)。
*
* SIGNPATH_SKIP_ON_QUOTA=true(内部测试用):
* SignPath 年度配额耗尽时跳过签名并继续构建(产物为未签名),
* 首次命中配额错误后记住状态,后续文件不再尝试提交。
* 正式发布请移除该开关并升级 SignPath 套餐。
*/
let quotaExhausted = false;
module.exports = async function signPathSign(configuration) {
if (!process.env.SIGNPATH_API_TOKEN || !process.env.SIGNPATH_ORG_ID) {
console.log(`[signpath-sign] no SignPath credentials, skip signing: ${configuration.name}`);
return;
}
if (quotaExhausted) {
console.warn(`[signpath-sign] quota exhausted — SKIP signing (UNSIGNED): ${configuration.name}`);
return;
}
// 配额省用模式:只签最终安装包(isNsis=true),跳过内部 exe / 卸载器
if (process.env.SIGNPATH_ONLY_INSTALLER === 'true' && !configuration.isNsis) {
console.log(`[signpath-sign] SIGNPATH_ONLY_INSTALLER, skip inner file: ${configuration.name}`);
return;
}
try {
await signWithSignPath(configuration.path);
} catch (err) {
if (process.env.SIGNPATH_SKIP_ON_QUOTA === 'true' && /quota/i.test(err.message)) {
quotaExhausted = true;
console.warn(`[signpath-sign] SignPath quota exceeded — skip remaining files, artifact will be UNSIGNED: ${err.message}`);
return;
}
throw err;
}
};