限定品牌中心进行中流程并补充 Mac 部署入口
This commit is contained in:
parent
762b3ae69a
commit
dcaa11ce35
15
docs/部署规范.md
15
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 无响应时本次发布失败关闭,不允许无限挂起后由人工跳过源码身份校验。
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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` 本地历史脚本名
|
||||
|
||||
384
scripts/git-incremental-deploy-macos.mjs
Executable file
384
scripts/git-incremental-deploy-macos.mjs
Executable 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
|
||||
})
|
||||
}
|
||||
27
scripts/git-incremental-deploy-macos.sh
Executable file
27
scripts/git-incremental-deploy-macos.sh
Executable 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" "$@"
|
||||
@ -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))
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user