mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-11-01 19:53:43 +00:00
147 lines
7.3 KiB
JavaScript
147 lines
7.3 KiB
JavaScript
// Publish only validated dist/ to the existing Tencent Cloud project.
|
|
import { createHash } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
const WEBSITE = fileURLToPath(new URL('../', import.meta.url));
|
|
const executeFile = promisify(execFile);
|
|
|
|
export const PROJECT = { projectId: 'makers-s7dxi8u5vidy', name: 'cs-notes' };
|
|
export const ORIGIN = 'https://cyc2018.xyz';
|
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
|
|
async function download(origin, path, revision, fetcher) {
|
|
const url = new URL(path.split('/').map(encodeURIComponent).join('/'), origin + '/');
|
|
url.searchParams.set('release', revision);
|
|
const response = await fetcher(url, { signal: AbortSignal.timeout(30_000), redirect: 'error' });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${path}`);
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
function verificationPaths(manifest) {
|
|
const paths = ['index.html', 'build-info.json', 'README.md', 'site.css', 'site.js',
|
|
'search.js', 'search-paths.js', manifest.searchIndex, 'image-variants.json'];
|
|
const records = new Map(manifest.files.map(file => [file.path, file]));
|
|
const notes = [...records.keys()].filter(path => path.startsWith('notes/') && path.endsWith('.md')).sort();
|
|
// Exercise ASCII, Unicode and spaces without depending on specific article titles.
|
|
for (const predicate of [path => /^[\x00-\x7F]+$/.test(path),
|
|
path => /[^\x00-\x7F]/.test(path) && !path.includes(' '), path => path.includes(' ')]) {
|
|
const note = notes.find(predicate);
|
|
if (note) paths.push(note);
|
|
}
|
|
for (const prefix of ['notes/pics/', 'optimized/']) {
|
|
const image = manifest.files.find(file => file.path.startsWith(prefix));
|
|
if (image) paths.push(image.path);
|
|
}
|
|
for (const path of paths) {
|
|
if (!records.has(path)) throw new Error(`构建缺少关键文件:${path}`);
|
|
}
|
|
return [...new Set(paths)];
|
|
}
|
|
|
|
async function packageRelease(directory, archive) {
|
|
const environment = { ...process.env };
|
|
delete environment.EDGEONE_API_TOKEN;
|
|
try {
|
|
await executeFile('python3', [resolve(WEBSITE, 'deploy/package_site.py'),
|
|
'--source', resolve(directory), '--output', resolve(archive)], { env: environment });
|
|
} catch (error) {
|
|
// The packaging subprocess receives no publishing token; don't log its command or environment.
|
|
const reason = error.stderr?.trim().split('\n').at(-1) || '请重新生成并校验网站。';
|
|
throw new Error(`发布包生成失败,未上传:${reason}`);
|
|
}
|
|
}
|
|
|
|
export async function verifyPublished(manifestBytes, { fetcher = fetch, origin = ORIGIN } = {}) {
|
|
const manifest = JSON.parse(manifestBytes);
|
|
const paths = verificationPaths(manifest);
|
|
const revision = sha256(manifestBytes);
|
|
const remote = await download(origin, 'asset-manifest.json', revision, fetcher);
|
|
if (sha256(remote) !== revision) throw new Error('正式域名的版本清单尚未与构建一致。');
|
|
const records = new Map(manifest.files.map(file => [file.path, file]));
|
|
// Check actual bytes as well as the manifest; an SPA fallback can also return HTTP 200.
|
|
await Promise.all(paths.map(async path => {
|
|
const record = records.get(path);
|
|
const bytes = await download(origin, path, revision, fetcher);
|
|
if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) {
|
|
throw new Error(`正式域名文件校验不一致:${path}`);
|
|
}
|
|
}));
|
|
return paths.length + 1;
|
|
}
|
|
|
|
export async function deploySite({ directory, archive = resolve(directory, '../.cache/site-release.zip'), getClient, fetcher = fetch,
|
|
sleep = ms => new Promise(done => setTimeout(done, ms)), attempts = 12 }) {
|
|
const manifestBytes = await readFile(resolve(directory, 'asset-manifest.json'));
|
|
const manifest = JSON.parse(manifestBytes);
|
|
verificationPaths(manifest); // Reject local defects before any credential or production mutation.
|
|
let unchanged = false;
|
|
try {
|
|
const remote = await download(ORIGIN, 'asset-manifest.json', sha256(manifestBytes), fetcher);
|
|
unchanged = sha256(remote) === sha256(manifestBytes);
|
|
} catch { /* A missing manifest should trigger a new deployment, never a false skip. */ }
|
|
let deployment;
|
|
if (!unchanged) {
|
|
// Always bind the upload to this validated dist; a cached ZIP may belong to an older build.
|
|
await packageRelease(directory, archive);
|
|
const makers = await getClient();
|
|
const project = await makers.projects.get({ projectId: PROJECT.projectId });
|
|
if (project.projectId !== PROJECT.projectId || project.name !== PROJECT.name) {
|
|
throw new Error('腾讯云项目标识与预期不一致,已停止发布。');
|
|
}
|
|
deployment = await makers.deployments.deploy({
|
|
projectId: PROJECT.projectId, artifact: { archive: resolve(archive) },
|
|
env: 'Production', wait: true, timeout: 600, pollInterval: 5,
|
|
onStatusChange: ({ deployment }) => console.log(`腾讯云部署状态:${deployment.status}`),
|
|
});
|
|
if (deployment.status !== 'Success') {
|
|
throw new Error(`腾讯云部署未成功:${deployment.status}`);
|
|
}
|
|
}
|
|
let verifiedFiles;
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
try {
|
|
verifiedFiles = await verifyPublished(manifestBytes, { fetcher });
|
|
break;
|
|
} catch (error) {
|
|
if (attempt === attempts - 1) throw error;
|
|
await sleep(20_000);
|
|
}
|
|
}
|
|
return { projectId: PROJECT.projectId, origin: ORIGIN,
|
|
sourceCommit: manifest.sourceCommit, generatorHash: manifest.generatorHash,
|
|
manifestSha256: sha256(manifestBytes),
|
|
result: unchanged ? 'unchanged' : 'deployed',
|
|
...(deployment ? { deploymentId: deployment.deploymentId } : {}), verifiedFiles };
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try {
|
|
const report = await deploySite({ directory: resolve(WEBSITE, 'dist'), getClient: async () => {
|
|
const token = process.env.EDGEONE_API_TOKEN;
|
|
if (!token) throw new Error('请在仓库 Actions Secrets 中配置 EDGEONE_API_TOKEN。');
|
|
const { Makers } = await import('@edgeone/makers-sdk');
|
|
// The account is on Tencent Cloud China, regardless of the runner's IP address.
|
|
return new Makers({ token, region: 'china' });
|
|
} });
|
|
await mkdir(WEBSITE, { recursive: true });
|
|
await writeFile(resolve(WEBSITE, 'deployment-report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
await appendFile(process.env.GITHUB_STEP_SUMMARY,
|
|
`网站:${ORIGIN}\n\n结果:${report.result === 'unchanged' ? '版本一致,无需重复发布' : '已发布'}\n\n` +
|
|
`版本:\`${report.sourceCommit}\`;正式域名 ${report.verifiedFiles} 个关键文件校验通过。\n`);
|
|
}
|
|
} catch (error) {
|
|
// Never serialize SDK requests, headers, upload credentials or token-bearing preview URLs.
|
|
if (error?.name?.endsWith('Error') && error?.code) {
|
|
console.error(`腾讯云接口失败:${error.name} (${error.code});请检查令牌有效期与项目权限。`);
|
|
} else {
|
|
console.error(error instanceof Error ? error.message : '网站发布失败。');
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
}
|