限定品牌中心进行中流程并补充 Mac 部署入口

This commit is contained in:
江林铖 2026-08-25 17:42:16 +08:00
parent 762b3ae69a
commit dcaa11ce35
7 changed files with 496 additions and 22 deletions

View File

@ -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 无响应时本次发布失败关闭,不允许无限挂起后由人工跳过源码身份校验。

View File

@ -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')

View File

@ -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<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES)
const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true }]
const PROCESS_TYPE_LABELS: Record<string, string> = {
[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<string | null>((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<OngoingDingWorkflowSummary> {
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<OngoingDingWorkflowInsights> {
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 } },

View File

@ -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` 本地历史脚本名

View File

@ -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 <sha> 部署远端 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
})
}

View File

@ -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" "$@"

View File

@ -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))
})