From dcaa11ce3526a798bd507a1529c2aecab48571d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Tue, 25 Aug 2026 17:42:16 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=AE=9A=E5=93=81=E7=89=8C=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E8=BF=9B=E8=A1=8C=E4=B8=AD=E6=B5=81=E7=A8=8B=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20Mac=20=E9=83=A8=E7=BD=B2=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/部署规范.md | 15 + lib/__tests__/ding-workflow-sync.test.ts | 10 + lib/ding-workflow-sync.ts | 39 +-- scripts/README.md | 11 + scripts/git-incremental-deploy-macos.mjs | 384 +++++++++++++++++++++++ scripts/git-incremental-deploy-macos.sh | 27 ++ scripts/git-incremental-deploy.test.mjs | 32 +- 7 files changed, 496 insertions(+), 22 deletions(-) create mode 100755 scripts/git-incremental-deploy-macos.mjs create mode 100755 scripts/git-incremental-deploy-macos.sh diff --git a/docs/部署规范.md b/docs/部署规范.md index 53112f19..1bc5ec6e 100644 --- a/docs/部署规范.md +++ b/docs/部署规范.md @@ -23,6 +23,15 @@ .\scripts\git-incremental-deploy.ps1 ``` +Mac 维护机使用原生调度入口。该入口直接读取上面的主脚本远端模板,构建机与生产机门禁仍只有一套,不在 Mac 构建生产镜像: + +```bash +./scripts/git-incremental-deploy-macos.sh --dry-run +./scripts/git-incremental-deploy-macos.sh --commit '<40位小写提交SHA>' +``` + +默认使用 `~/.ssh/gongxiangp2_build_machine` 连接构建机,并从本机 `~/.ssh/known_hosts` 提取已信任的构建机 ED25519 主机密钥。构建机到生产机继续使用其固定的 `/root/.ssh/gongxiangp2-production-known_hosts`。脚本不读取、保存或传递 SSH 密码。 + 默认参数: - Git 仓库:`http://git.1080p.hk/WuYongJie/gongxiangp2.git`(现场 Gitea 仅提供 HTTP;脚本只允许这一条精确地址) @@ -49,6 +58,12 @@ - 构建机、生产机的固定 SSH SHA256 主机指纹;OpenSSH 模式还要提供受控 `known_hosts` 文件。 - 维护机和构建机已配置只读 Git credential helper。部署脚本禁止把 Git 密码/Header 写入参数、临时脚本或进程命令行。 +Mac 入口需要具备: + +- macOS 自带的 `bash`、`ssh`、`scp`、`ssh-keygen`,以及 Node.js 18+ 和 Git。 +- 已授权到构建机的独立私钥,默认路径为 `~/.ssh/gongxiangp2_build_machine`,权限必须为 `0600` 或更严格。 +- 本机 `known_hosts` 已通过人工核验保存构建机 ED25519 主机密钥;不使用运行时 `ssh-keyscan` 自动接受主机。 + 不要把 Git 密码写进仓库 URL,也不要把临时部署密码写入文档或提交到仓库。Gitea 的 HTTP 通道不具备传输加密与证书身份保证,因此只能使用只读、最小权限 credential helper;正式构建/部署必须显式指定维护机已核对的完整 40 位小写提交 SHA,并继续核对 SSH 主机指纹、镜像 ID 与 manifest digest。 脚本对维护机和构建机的 Git HTTP 读取设置 30 秒低速超时。Gitea 无响应时本次发布失败关闭,不允许无限挂起后由人工跳过源码身份校验。 diff --git a/lib/__tests__/ding-workflow-sync.test.ts b/lib/__tests__/ding-workflow-sync.test.ts index b44da8d9..9b150b3a 100644 --- a/lib/__tests__/ding-workflow-sync.test.ts +++ b/lib/__tests__/ding-workflow-sync.test.ts @@ -1,6 +1,7 @@ import { DING_WORKFLOW_ALLOWED_PROCESS_CODES, DING_WORKFLOW_STARTED_AT_CUTOFF_ISO, + buildBrandCenterOngoingWorkflowWhere, getDingWorkflowProjectType, isAllowedDingWorkflowProcessCode, isDingWorkflowStartedOnOrAfterCutoff, @@ -58,6 +59,15 @@ describe('ding workflow snapshot sync semantics', () => { expect(isAllowedDingWorkflowProcessCode(null)).toBe(false) }) + it('only queries ongoing workflows whose current assignee belongs to Brand Center', () => { + expect(buildBrandCenterOngoingWorkflowWhere()).toEqual({ + isOngoing: true, + processCode: { in: [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] }, + sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, + activeTasks: { array_contains: [{ isBrandCenter: true }] }, + }) + }) + it('normalizes process codes before persistence and comparison', () => { expect(normalizeDingWorkflowProcessCode(' proc-ae9ff3e1-a442-4e6a-85cf-0f2b86409673 ')) .toBe('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673') diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index aa0a48b1..11794f32 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -23,6 +23,7 @@ export const DING_WORKFLOW_ALLOWED_PROCESS_CODES = [ export const DING_WORKFLOW_BRANDS = ['亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] as const const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] const ALLOWED_PROCESS_CODE_SET = new Set(DING_WORKFLOW_ALLOWED_PROCESS_CODES) +const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true }] const PROCESS_TYPE_LABELS: Record = { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程', [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄', @@ -367,6 +368,22 @@ export function isDingWorkflowStartedOnOrAfterCutoff(value: unknown): boolean { return Boolean(startedAt && startedAt >= DING_WORKFLOW_STARTED_AT_CUTOFF_ISO) } +/** + * 品牌中心“进行中项目”的统一查询口径。 + * + * 一个流程可能同时存在多个当前任务;只要至少一个当前节点处理人属于品牌中心, + * 该流程就计入。同步快照中的 activeTasks 只保存未完结任务,因此这里不会误把 + * 历史节点处理人当作当前负责人。 + */ +export function buildBrandCenterOngoingWorkflowWhere() { + return { + isOngoing: true, + processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, + sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, + activeTasks: { array_contains: BRAND_CENTER_ACTIVE_TASK_JSON }, + } +} + function maxSourceCursor(rows: WorkflowSnapshotPayload[], current: string | null) { return rows.reduce((latest, row) => { const candidate = row.sourceUpdatedAtRaw @@ -879,11 +896,7 @@ export async function syncDingWorkflowSnapshots() { }, }) const ongoingCount = await prisma.dingWorkflowSnapshot.count({ - where: { - isOngoing: true, - processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, - sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, - }, + where: buildBrandCenterOngoingWorkflowWhere(), }) const sourceCursorRaw = maxSourceCursor(rows, state.sourceCursorRaw) await prisma.dingWorkflowSyncState.updateMany({ @@ -927,11 +940,7 @@ function normalizeSyncStatus(state: { status: string; lastCompletedAt: Date | nu async function readSummaryWithoutAccessCheck(): Promise { const [total, state] = await prisma.$transaction([ prisma.dingWorkflowSnapshot.count({ - where: { - isOngoing: true, - processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, - sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, - }, + where: buildBrandCenterOngoingWorkflowWhere(), }), prisma.dingWorkflowSyncState.findUnique({ where: { id: SYNC_STATE_ID }, @@ -1018,11 +1027,7 @@ function mapSnapshotListItem(row: { export async function getOngoingDingWorkflowInsights(session: Session | null): Promise { await assertDashboardAccess(session) - const where = { - isOngoing: true, - processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, - sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, - } + const where = buildBrandCenterOngoingWorkflowWhere() const [rows, sync] = await Promise.all([ prisma.dingWorkflowSnapshot.findMany({ where, @@ -1050,9 +1055,7 @@ export async function getOngoingDingWorkflowProjects( const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20)) const query = clean(params?.query) const where = { - isOngoing: true, - processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, - sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, + ...buildBrandCenterOngoingWorkflowWhere(), ...(query ? { OR: [ { title: { contains: query, mode: 'insensitive' as const } }, diff --git a/scripts/README.md b/scripts/README.md index 0c409a56..0f42c0ce 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -58,6 +58,17 @@ 正式部署由维护人员执行,不要求普通用户手动运行。 +### `scripts/git-incremental-deploy-macos.sh` Mac 调度入口 + +Mac 不构建生产镜像。该入口负责校验精确 Git 提交、固定 SSH 主机身份并把临时调度脚本送到构建机;远端执行内容直接读取 `git-incremental-deploy.ps1` 的同一份模板,因此 Docker 构建、镜像身份、生产切换、健康检查和回滚规则不会分叉。 + +```bash +./scripts/git-incremental-deploy-macos.sh --dry-run +./scripts/git-incremental-deploy-macos.sh --commit '<40位小写提交SHA>' +``` + +默认使用 `~/.ssh/gongxiangp2_build_machine`,不接受或保存 SSH 密码。 + ## 2. 历史部署脚本 ### `deploy-*.ps1` 本地历史脚本名 diff --git a/scripts/git-incremental-deploy-macos.mjs b/scripts/git-incremental-deploy-macos.mjs new file mode 100755 index 00000000..dd590bba --- /dev/null +++ b/scripts/git-incremental-deploy-macos.mjs @@ -0,0 +1,384 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from 'node:crypto' +import { execFileSync, spawnSync } from 'node:child_process' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)) +const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '..') +const POWERSHELL_DEPLOY_SCRIPT = path.join(SCRIPT_DIRECTORY, 'git-incremental-deploy.ps1') +const PROXY_RECONCILER = path.join(SCRIPT_DIRECTORY, 'local-proxy-reconcile.sh') + +const TRUSTED_REPOSITORY = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' +const TRUSTED_BRANCH = 'main' +const BUILD_SERVER_IP = '192.168.40.206' +const BUILD_SERVER_USER = 'root' +const BUILD_DIRECTORY = '/root/youyidian-3d-git-build' +const PRODUCTION_SERVER_IP = '192.168.58.20' +const PRODUCTION_SERVER_USER = 'root' +const PRODUCTION_KNOWN_HOSTS_DEFAULT = '/root/.ssh/gongxiangp2-production-known_hosts' +const DEFAULT_UPLOADS_MOUNT = '/mnt/PoolOne/youyidian-uploads' +const TRUSTED_UPLOADS_MOUNTS = new Set([ + DEFAULT_UPLOADS_MOUNT, + '/mnt/PoolOne/apps/youyidian-3d/uploads', +]) + +const USAGE = `gongxiangp2 Mac 部署入口 + +用法: + ./scripts/git-incremental-deploy-macos.sh --dry-run + ./scripts/git-incremental-deploy-macos.sh --commit <40位小写提交SHA> + +选项: + --commit 部署远端 main 中的精确提交;正式部署必填 + --dry-run 仅执行源码、SSH、Registry 与生产连通性预检 + --no-cache 不使用 Docker 构建缓存 + --reuse-verified-image 复用已经校验的精确提交镜像 + --keep-old-images 不清理旧 Docker 镜像 + --aggressive-cache-cleanup 清理超过 7 天的构建缓存 + --no-rollback 禁用失败后的旧镜像自动恢复 + --require-migration-audit 要求使用已批准的数据库迁移审计凭证 + --help 显示帮助 + +本机约定: + BUILD_SERVER_IDENTITY_FILE 默认 ~/.ssh/gongxiangp2_build_machine + BUILD_SERVER_KNOWN_HOSTS_FILE 默认 ~/.ssh/known_hosts(只提取已信任的 ED25519 项) + PROD_SERVER_KNOWN_HOSTS_FILE 默认 ${PRODUCTION_KNOWN_HOSTS_DEFAULT}(构建机上的固定文件) +` + +function shellQuote(value) { + return `'${String(value ?? '').replaceAll("'", "'\\''")}'` +} + +function requireExactCommit(value, label = 'commit') { + if (!/^[0-9a-f]{40}$/.test(value)) { + throw new Error(`${label} 必须是 40 位小写 Git SHA`) + } + return value +} + +export function parseArguments(argv) { + const options = { + commit: '', + dryRun: false, + noCache: false, + reuseVerifiedImage: false, + keepOldImages: false, + aggressiveCleanup: false, + noRollback: false, + requireMigrationAudit: false, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + switch (argument) { + case '--commit': + index += 1 + if (index >= argv.length) throw new Error('--commit 缺少 SHA 参数') + options.commit = requireExactCommit(argv[index]) + break + case '--dry-run': options.dryRun = true; break + case '--no-cache': options.noCache = true; break + case '--reuse-verified-image': options.reuseVerifiedImage = true; break + case '--keep-old-images': options.keepOldImages = true; break + case '--aggressive-cache-cleanup': options.aggressiveCleanup = true; break + case '--no-rollback': options.noRollback = true; break + case '--require-migration-audit': options.requireMigrationAudit = true; break + case '--help': + case '-h': options.help = true; break + default: throw new Error(`不支持的参数:${argument}`) + } + } + + if (!options.help && !options.dryRun && !options.commit) { + throw new Error('正式部署必须通过 --commit 指定 40 位小写提交 SHA') + } + if (options.reuseVerifiedImage && options.noCache) { + throw new Error('--reuse-verified-image 不能与 --no-cache 同时使用') + } + return options +} + +export function extractRemoteTemplate(powerShellSource) { + const match = powerShellSource.match(/\$remoteTemplate = @'\r?\n([\s\S]*?)\r?\n'@\r?\n\r?\nWrite-Step/) + if (!match) throw new Error('无法从主部署脚本读取远端部署模板') + return match[1].replaceAll('\r\n', '\n').replaceAll('\r', '\n') +} + +export function renderRemoteTemplate(template, values) { + const requiredTokens = [...new Set(template.match(/__[A-Z0-9_]+__/g) ?? [])] + const missing = requiredTokens.filter((token) => !(token in values)) + if (missing.length > 0) { + throw new Error(`Mac 入口缺少主部署模板参数:${missing.join(', ')}`) + } + + let rendered = template + for (const token of requiredTokens) rendered = rendered.replaceAll(token, shellQuote(values[token])) + const unresolved = [...new Set(rendered.match(/__[A-Z0-9_]+__/g) ?? [])] + if (unresolved.length > 0) throw new Error(`部署模板仍有未解析参数:${unresolved.join(', ')}`) + return rendered +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? REPOSITORY_ROOT, + encoding: 'utf8', + input: options.input, + env: options.env ?? process.env, + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + }) + if (result.error) throw result.error + if (result.status !== 0) { + const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() + throw new Error(`${command} 执行失败(${result.status})${details ? `:\n${details}` : ''}`) + } + return String(result.stdout ?? '').trim() +} + +function runGit(args, options = {}) { + return run('git', [ + '-c', 'http.version=HTTP/1.1', + '-c', 'http.lowSpeedLimit=1', + '-c', 'http.lowSpeedTime=30', + ...args, + ], options) +} + +function sshArguments(identityFile, knownHostsFile) { + return [ + '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=yes', + '-o', `UserKnownHostsFile=${knownHostsFile}`, + '-o', 'ConnectTimeout=10', + '-i', identityFile, + ] +} + +function fingerprintForPublicKeyLine(line) { + const parts = line.trim().split(/\s+/) + if (parts.length < 3 || parts[1] !== 'ssh-ed25519') { + throw new Error('known_hosts 项不是唯一的 ED25519 主机密钥') + } + const digest = createHash('sha256').update(Buffer.from(parts[2], 'base64')).digest('base64') + return `SHA256:${digest.replace(/=+$/, '')}` +} + +async function createPinnedBuildKnownHosts(sourceFile, temporaryRoot) { + const sourceStat = await fs.lstat(sourceFile).catch(() => null) + if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`构建机 known_hosts 不是可信普通文件:${sourceFile}`) + } + + const lookup = run('ssh-keygen', ['-F', BUILD_SERVER_IP, '-f', sourceFile]) + const matches = lookup + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#') && /\s+ssh-ed25519\s+/.test(line)) + if (matches.length !== 1) { + throw new Error(`本机 known_hosts 必须包含构建机 ${BUILD_SERVER_IP} 的唯一 ED25519 密钥`) + } + + const pinnedFile = path.join(temporaryRoot, 'build-known-hosts') + await fs.writeFile(pinnedFile, `${matches[0]}\n`, { encoding: 'utf8', mode: 0o600 }) + return { pinnedFile, fingerprint: fingerprintForPublicKeyLine(matches[0]) } +} + +async function validateIdentityFile(identityFile) { + const identityStat = await fs.lstat(identityFile).catch(() => null) + if (!identityStat?.isFile() || identityStat.isSymbolicLink()) { + throw new Error(`构建机私钥不是可信普通文件:${identityFile}`) + } + if ((identityStat.mode & 0o077) !== 0) { + throw new Error(`构建机私钥权限必须为 0600 或更严格:${identityFile}`) + } +} + +function resolveProductionFingerprint(sshCommonArgs, productionKnownHostsFile) { + if (!/^\/[A-Za-z0-9._/-]+$/.test(productionKnownHostsFile)) { + throw new Error('构建机上的生产 known_hosts 路径必须是绝对安全路径') + } + const quotedPath = shellQuote(productionKnownHostsFile) + const command = `set -eu; test -f ${quotedPath}; test ! -L ${quotedPath}; ` + + `ssh-keygen -F ${shellQuote(PRODUCTION_SERVER_IP)} -f ${quotedPath} ` + + `| awk '!/^#/ { print $2 " " $3 }' ` + + `| ssh-keygen -lf - -E sha256 ` + + `| awk '{print $2}' | sort -u` + + const output = run('ssh', [ + ...sshCommonArgs, + `${BUILD_SERVER_USER}@${BUILD_SERVER_IP}`, + command, + ]) + const fingerprints = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + if (fingerprints.length !== 1 || !/^SHA256:[A-Za-z0-9+/]{20,}={0,2}$/.test(fingerprints[0])) { + throw new Error('构建机的生产 known_hosts 未解析出唯一 SHA256 主机指纹') + } + return fingerprints[0] +} + +async function resolveAndScanExactCommit(options) { + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-macos-preflight-')) + const bareRepository = path.join(temporaryRoot, 'target.git') + const checkout = path.join(temporaryRoot, 'target-tree') + try { + const remoteLine = runGit(['ls-remote', '--heads', TRUSTED_REPOSITORY, `refs/heads/${TRUSTED_BRANCH}`]) + const remoteParts = remoteLine.split(/\s+/) + if (remoteParts.length !== 2 || remoteParts[1] !== `refs/heads/${TRUSTED_BRANCH}`) { + throw new Error('远端 main 未解析为唯一分支引用') + } + const branchCommit = requireExactCommit(remoteParts[0], '远端 main 提交') + const targetCommit = options.commit || branchCommit + + let useLocalObjects = false + try { + const localBranch = runGit(['-C', REPOSITORY_ROOT, 'rev-parse', '--verify', `${branchCommit}^{commit}`]) + const localTarget = runGit(['-C', REPOSITORY_ROOT, 'rev-parse', '--verify', `${targetCommit}^{commit}`]) + if (localBranch !== branchCommit || localTarget !== targetCommit) throw new Error('本机对象与远端提交不一致') + runGit(['-C', REPOSITORY_ROOT, 'merge-base', '--is-ancestor', targetCommit, branchCommit]) + useLocalObjects = true + } catch { + useLocalObjects = false + } + + if (useLocalObjects) { + runGit(['clone', '--no-checkout', '--shared', REPOSITORY_ROOT, checkout]) + runGit(['-C', checkout, 'checkout', '--detach', '--force', targetCommit]) + } else { + runGit(['init', '--bare', bareRepository]) + runGit([ + `--git-dir=${bareRepository}`, + 'fetch', '--no-tags', '--force', TRUSTED_REPOSITORY, + `+refs/heads/${TRUSTED_BRANCH}:refs/preflight/branch`, + ]) + const fetchedBranch = runGit([`--git-dir=${bareRepository}`, 'rev-parse', '--verify', 'refs/preflight/branch^{commit}']) + if (fetchedBranch !== branchCommit) throw new Error('预检期间远端 main 已发生变化,请重试') + runGit([`--git-dir=${bareRepository}`, 'cat-file', '-e', `${targetCommit}^{commit}`]) + runGit([`--git-dir=${bareRepository}`, 'merge-base', '--is-ancestor', targetCommit, branchCommit]) + runGit(['clone', '--no-checkout', bareRepository, checkout]) + runGit(['-C', checkout, 'checkout', '--detach', '--force', targetCommit]) + } + run('node', ['scripts/check-legacy-storage-boundaries.mjs'], { cwd: checkout }) + return targetCommit + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) + } +} + +async function executeOnBuildServer(payload, sshCommonArgs, temporaryRoot) { + const deploymentId = randomUUID().replaceAll('-', '') + const localPayload = path.join(temporaryRoot, `payload-${deploymentId}.sh`) + const remoteDirectory = `/tmp/gongxiangp2-deploy-${deploymentId}` + const remotePayload = `${remoteDirectory}/payload.sh` + const remoteTarget = `${BUILD_SERVER_USER}@${BUILD_SERVER_IP}` + await fs.writeFile(localPayload, payload, { encoding: 'utf8', mode: 0o600 }) + + const prepareCommand = `set -eu; umask 077; test ! -e ${shellQuote(remoteDirectory)}; mkdir -m 700 ${shellQuote(remoteDirectory)}` + run('ssh', [...sshCommonArgs, remoteTarget, prepareCommand], { stdio: 'inherit' }) + let prepared = true + try { + run('scp', [...sshCommonArgs, localPayload, `${remoteTarget}:${remotePayload}`], { stdio: 'inherit' }) + const runCommand = `set -eu; chmod 600 ${shellQuote(remotePayload)}; set +e; bash ${shellQuote(remotePayload)}; rc=$?; set -e; rm -f ${shellQuote(remotePayload)}; rmdir ${shellQuote(remoteDirectory)}; exit $rc` + const result = spawnSync('ssh', [...sshCommonArgs, remoteTarget, runCommand], { stdio: 'inherit' }) + if (result.error) throw result.error + prepared = false + if (result.status !== 0) throw new Error(`Git 增量部署失败,退出码 ${result.status}`) + } finally { + if (prepared) { + spawnSync('ssh', [ + ...sshCommonArgs, + remoteTarget, + `rm -f ${shellQuote(remotePayload)}; rmdir ${shellQuote(remoteDirectory)} >/dev/null 2>&1 || true`, + ], { stdio: 'ignore' }) + } + } +} + +async function main() { + const options = parseArguments(process.argv.slice(2)) + if (options.help) { + process.stdout.write(USAGE) + return + } + + const identityFile = path.resolve(process.env.BUILD_SERVER_IDENTITY_FILE || path.join(os.homedir(), '.ssh', 'gongxiangp2_build_machine')) + const knownHostsSource = path.resolve(process.env.BUILD_SERVER_KNOWN_HOSTS_FILE || path.join(os.homedir(), '.ssh', 'known_hosts')) + const productionKnownHostsFile = process.env.PROD_SERVER_KNOWN_HOSTS_FILE || PRODUCTION_KNOWN_HOSTS_DEFAULT + const uploadsMount = process.env.GONGXIANGP2_UPLOADS_MOUNT || DEFAULT_UPLOADS_MOUNT + if (!TRUSTED_UPLOADS_MOUNTS.has(uploadsMount)) throw new Error(`不可信的生产 uploads 挂载:${uploadsMount}`) + await validateIdentityFile(identityFile) + + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-macos-deploy-')) + try { + const { pinnedFile, fingerprint: buildFingerprint } = await createPinnedBuildKnownHosts(knownHostsSource, temporaryRoot) + const sshCommonArgs = sshArguments(identityFile, pinnedFile) + const productionFingerprint = process.env.PROD_SERVER_HOST_KEY + || resolveProductionFingerprint(sshCommonArgs, productionKnownHostsFile) + const targetCommit = await resolveAndScanExactCommit(options) + const powerShellSource = await fs.readFile(POWERSHELL_DEPLOY_SCRIPT, 'utf8') + const proxyReconcilerBase64 = (await fs.readFile(PROXY_RECONCILER)).toString('base64') + + const values = { + __REPO_URL__: TRUSTED_REPOSITORY, + __BRANCH__: TRUSTED_BRANCH, + __COMMIT__: targetCommit, + __REMOTE_BUILD_DIR__: BUILD_DIRECTORY, + __TARGET__: 'production', + __BUILD_SERVER_IP__: BUILD_SERVER_IP, + __PROD_SERVER_IP__: PRODUCTION_SERVER_IP, + __PROD_SERVER_USER__: PRODUCTION_SERVER_USER, + __PROD_SERVER_HOST_KEY__: productionFingerprint, + __PROD_KNOWN_HOSTS_FILE__: productionKnownHostsFile, + __REGISTRY_PORT__: '5000', + __IMAGE_NAME__: 'youyidian-3d-material', + __UPLOADS_MOUNT__: uploadsMount, + __MIGRATION_AUDIT_FILE__: process.env.GONGXIANGP2_MIGRATION_AUDIT_FILE || '', + __MIGRATION_AUDIT_SHA256__: process.env.GONGXIANGP2_MIGRATION_AUDIT_SHA256 || '', + __MIGRATION_AUDIT_SOURCE_COMMIT__: process.env.GONGXIANGP2_MIGRATION_AUDIT_SOURCE_COMMIT || (options.requireMigrationAudit ? targetCommit : ''), + __REQUIRE_MIGRATION_AUDIT__: options.requireMigrationAudit ? '1' : '0', + __CONTAINER_NAME__: 'youyidian-3d-material', + __PORT__: '4320', + __ENV_FILE__: '.env', + __PUBLIC_APP_URL__: 'https://sc.jiansun.vip', + __CORS_ALLOWED_ORIGINS__: 'https://sc.jiansun.vip,http://192.168.58.20:4320', + __DINGTALK_CALLBACK_ORIGIN__: 'https://sc.jiansun.vip', + __DINGTALK_INTERNAL_RETURN_ORIGINS__: 'http://192.168.58.20:4320', + __S3_PUBLIC_DOMAIN__: 'https://s3.jiansun.vip/gongxiangpan', + __DOWNLOAD_TOKEN_SECRET__: process.env.DOWNLOAD_TOKEN_SECRET || '', + __PROXY_RECONCILE_SCRIPT_B64__: proxyReconcilerBase64, + __DRY_RUN__: options.dryRun ? '1' : '0', + __NO_CACHE__: options.noCache ? '1' : '0', + __SKIP_BUILD__: '0', + __SKIP_PUSH__: '0', + __REUSE_VERIFIED_IMAGE__: options.reuseVerifiedImage ? '1' : '0', + __SKIP_DEPLOY__: '0', + __KEEP_OLD_IMAGES__: options.keepOldImages ? '1' : '0', + __AGGRESSIVE_CLEANUP__: options.aggressiveCleanup ? '1' : '0', + __ROLLBACK_ON_FAILURE__: options.noRollback ? '0' : '1', + __DEPLOYMENT_STRATEGY__: 'direct', + } + + const payload = renderRemoteTemplate(extractRemoteTemplate(powerShellSource), values) + const syntaxCheck = spawnSync('bash', ['-n'], { input: payload, encoding: 'utf8' }) + if (syntaxCheck.status !== 0) throw new Error(`生成的远端脚本语法无效:${syntaxCheck.stderr}`) + + process.stdout.write(`[INFO] Mac deployment entry\n[INFO] Commit: ${targetCommit}\n[INFO] Build host key: ${buildFingerprint}\n`) + if (options.dryRun) process.stdout.write('[WARN] DryRun:不会修改代码、镜像或生产容器\n') + await executeOnBuildServer(payload, sshCommonArgs, temporaryRoot) + process.stdout.write('[OK] Git 增量部署流程完成\n') + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) + } +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`[ERR] ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/git-incremental-deploy-macos.sh b/scripts/git-incremental-deploy-macos.sh new file mode 100755 index 00000000..129b7e39 --- /dev/null +++ b/scripts/git-incremental-deploy-macos.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" + +NODE_BIN="${NODE_BIN:-}" +if [[ -z "$NODE_BIN" ]] && command -v node >/dev/null 2>&1; then + NODE_BIN="$(command -v node)" +fi +if [[ -z "$NODE_BIN" ]]; then + for candidate in \ + "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" \ + /opt/homebrew/bin/node \ + /usr/local/bin/node; do + if [[ -x "$candidate" ]]; then + NODE_BIN="$candidate" + break + fi + done +fi +if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then + printf '[ERR] 未找到 Node.js 18+;可通过 NODE_BIN=/absolute/path/to/node 显式指定。\n' >&2 + exit 1 +fi + +export PATH="$(dirname -- "$NODE_BIN"):$PATH" +exec "$NODE_BIN" "$SCRIPT_DIR/git-incremental-deploy-macos.mjs" "$@" diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index ee68e030..3a27e55c 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -3,12 +3,15 @@ import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import test from 'node:test' +import { + extractRemoteTemplate, + parseArguments, + renderRemoteTemplate, +} from './git-incremental-deploy-macos.mjs' + test('generated remote deployment payload is valid bash', () => { const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') - const match = source.match(/\$remoteTemplate = @'\r?\n([\s\S]*?)\r?\n'@\r?\n\r?\nWrite-Step/) - assert.ok(match, 'remote deployment template should be present') - - const payload = match[1].replace(/__[A-Z0-9_]+__/g, "''") + const payload = extractRemoteTemplate(source).replace(/__[A-Z0-9_]+__/g, "''") const result = spawnSync('bash', ['-n'], { input: payload, encoding: 'utf8', @@ -16,3 +19,24 @@ test('generated remote deployment payload is valid bash', () => { assert.equal(result.status, 0, result.stderr || result.stdout) }) + +test('Mac entry renders every canonical remote deployment token', () => { + const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') + const template = extractRemoteTemplate(source) + const tokens = [...new Set(template.match(/__[A-Z0-9_]+__/g) ?? [])] + const values = Object.fromEntries(tokens.map((token, index) => [token, token === '__COMMIT__' + ? 'a'.repeat(40) + : `value ${index} with quote ' safely escaped`])) + const payload = renderRemoteTemplate(template, values) + assert.doesNotMatch(payload, /__[A-Z0-9_]+__/) + + const result = spawnSync('bash', ['-n'], { input: payload, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr || result.stdout) +}) + +test('Mac entry requires an exact commit outside DryRun', () => { + assert.throws(() => parseArguments([]), /正式部署必须/) + assert.throws(() => parseArguments(['--commit', 'abc']), /40 位小写/) + assert.equal(parseArguments(['--dry-run']).dryRun, true) + assert.equal(parseArguments(['--commit', 'b'.repeat(40)]).commit, 'b'.repeat(40)) +})