@aipt/idp-deploy 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +471 -0
  2. package/bin/idpctl.mjs +8 -0
  3. package/compose/edge.yaml +61 -0
  4. package/compose/flow.yaml +34 -0
  5. package/compose/portal.yaml +38 -0
  6. package/compose/postgresql.yaml +62 -0
  7. package/compose/registry.yaml +35 -0
  8. package/compose/smartgo.yaml +271 -0
  9. package/compose/tech.yaml +64 -0
  10. package/contracts/active-profile.schema.json +19 -0
  11. package/contracts/asset-lifecycle.schema.json +49 -0
  12. package/contracts/backup-generation.schema.json +60 -0
  13. package/contracts/component-runtime.schema.json +32 -0
  14. package/contracts/config-release.schema.json +33 -0
  15. package/contracts/deployment-evidence.schema.json +43 -0
  16. package/contracts/deployment-plan.schema.json +67 -0
  17. package/contracts/release-candidate.schema.json +33 -0
  18. package/contracts/release-defaults.schema.json +56 -0
  19. package/contracts/restore-candidate.schema.json +63 -0
  20. package/contracts/smartgo-component-config.schema.json +79 -0
  21. package/contracts/tech-backup-boundary.schema.json +61 -0
  22. package/contracts/tech-source-credentials.schema.json +17 -0
  23. package/deploy.sh +5 -0
  24. package/docs/restore-runbook.md +56 -0
  25. package/governance/asset-lifecycle.v1.json +79 -0
  26. package/package.json +42 -0
  27. package/release/defaults.v1.json +64 -0
  28. package/src/acceptance.mjs +127 -0
  29. package/src/bindings.mjs +518 -0
  30. package/src/cli.mjs +360 -0
  31. package/src/compose.mjs +19 -0
  32. package/src/config.mjs +903 -0
  33. package/src/delivery.mjs +123 -0
  34. package/src/errors.mjs +12 -0
  35. package/src/foundation-contracts.mjs +128 -0
  36. package/src/foundation.mjs +107 -0
  37. package/src/gitops.mjs +285 -0
  38. package/src/hash.mjs +47 -0
  39. package/src/image-lock.mjs +160 -0
  40. package/src/images.mjs +215 -0
  41. package/src/lifecycle.mjs +58 -0
  42. package/src/local-source.mjs +354 -0
  43. package/src/oci-mirror.mjs +10 -0
  44. package/src/operations.mjs +1484 -0
  45. package/src/process.mjs +46 -0
  46. package/src/profiles.mjs +41 -0
  47. package/src/release.mjs +1760 -0
  48. package/src/render.mjs +330 -0
  49. package/src/security.mjs +156 -0
  50. package/src/source-contracts.mjs +106 -0
  51. package/src/sources.mjs +78 -0
  52. package/src/workbench-projects.mjs +118 -0
@@ -0,0 +1,1760 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { rollbackManagedBindingsCas, syncManagedBindings } from './bindings.mjs';
6
+ import { doctorConfig, generateSecrets, initializeConfig, resolveConfiguredOrasTool } from './config.mjs';
7
+ import { IdpError, invariant } from './errors.mjs';
8
+ import { hashFile, sha256, stableJson } from './hash.mjs';
9
+ import { lockImages } from './images.mjs';
10
+ import { dockerOfficialMirrorRef } from './oci-mirror.mjs';
11
+ import {
12
+ composeOperation,
13
+ createBackup,
14
+ readActiveProfileState,
15
+ testRestore,
16
+ verifyBackup,
17
+ verifyDeployment,
18
+ withInstanceLock,
19
+ writeReceipt,
20
+ } from './operations.mjs';
21
+ import { run } from './process.mjs';
22
+ import { resolveProfile } from './profiles.mjs';
23
+ import { readRuntimeGenerationManifest, renderRuntimeBundles } from './render.mjs';
24
+ import { assertSafeDirectory, assertSafeRegularFile, atomicWrite, createExclusiveFile, parseEnv, resolveContained } from './security.mjs';
25
+
26
+ const REQUIRED_PLATFORMS = Object.freeze(['linux/amd64', 'linux/arm64']);
27
+ const RELEASE_LABEL_KEYS = Object.freeze([
28
+ 'org.opencontainers.image.version',
29
+ 'org.opencontainers.image.revision',
30
+ 'dev.idp.release.plan',
31
+ 'dev.idp.release.component',
32
+ ]);
33
+ const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9][a-z0-9.-]*)?$/u;
34
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
35
+ const PINNED_IMAGE_PATTERN = /^\S+@sha256:[0-9a-f]{64}$/u;
36
+ const SBOM_GENERATOR = 'mirror.gcr.io/docker/buildkit-syft-scanner:stable-1@sha256:ae4f3b554449e7e25548e7d8ccc029d17357348e30c6e3df01b92bc93654d6a9';
37
+ const OCI_REPOSITORY_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+$/u;
38
+ const SMARTGO_PUBLIC_REGISTRY_ALIAS = 'host.docker.internal';
39
+ const SMARTGO_PRIVATE_REGISTRY_ALIAS = 'smartgo-private-registry.internal';
40
+ const SMARTGO_OFFLINE_DEPLOY_ROUTE_ARGS = Object.freeze([
41
+ `--config.registry=http://${SMARTGO_PUBLIC_REGISTRY_ALIAS}:4873/`,
42
+ `--config.@aipt:registry=http://${SMARTGO_PRIVATE_REGISTRY_ALIAS}:4873/`,
43
+ `--config.@bench:registry=http://${SMARTGO_PRIVATE_REGISTRY_ALIAS}:4873/`,
44
+ ]);
45
+ const SMARTGO_PNPM_CACHE_MOUNT = 'type=cache,id=smartgo-pnpm,target=/pnpm/store';
46
+ const SMARTGO_NPMRC_SECRET_MOUNT = 'type=secret,id=npmrc,target=/root/.npmrc,required=true';
47
+ const SMARTGO_PERSISTENCE_PREFLIGHT_DIRECTORY = '/tmp/smartgo-persistence-runtime-closure-preflight';
48
+ const SMARTGO_PERSISTENCE_PREFLIGHT_SEGMENTS = Object.freeze([
49
+ 'pnpm install --frozen-lockfile --prefer-offline --network-concurrency=4',
50
+ 'pnpm --filter @smartgo/persistence db:generate >/dev/null',
51
+ `pnpm --filter @smartgo/persistence --prod deploy --legacy ${SMARTGO_PERSISTENCE_PREFLIGHT_DIRECTORY} >/dev/null`,
52
+ `rm -rf ${SMARTGO_PERSISTENCE_PREFLIGHT_DIRECTORY}`,
53
+ ]);
54
+ const SMARTGO_INSTALL_SETUP_SEGMENTS = Object.freeze([
55
+ '(test "$SMARTGO_NPM_CACHE_MODE" = "proxy" || test "$SMARTGO_NPM_CACHE_MODE" = "direct-prime")',
56
+ 'pnpm config set store-dir /pnpm/store',
57
+ 'pnpm config set link-workspace-packages true --location project',
58
+ 'pnpm config set fetch-timeout 300000 --location project',
59
+ 'pnpm config set fetch-retries 5 --location project',
60
+ 'pnpm config set fetch-retry-mintimeout 10000 --location project',
61
+ 'pnpm config set fetch-retry-maxtimeout 60000 --location project',
62
+ ]);
63
+ const SMARTGO_BUILD_PACKAGE_SEGMENTS = Object.freeze([
64
+ 'pnpm --filter @smartgo/persistence db:generate',
65
+ 'pnpm --recursive --if-present build',
66
+ 'node /workspace/deploy/package-next-standalone.mjs gotology',
67
+ 'node /workspace/deploy/package-next-standalone.mjs operations',
68
+ 'node /workspace/deploy/package-next-standalone.mjs studio-poc',
69
+ ]);
70
+ const SMARTGO_RUNTIME_DEPLOYS = Object.freeze([
71
+ Object.freeze({ selector: '@smartgo/api-contracts', target: '/runtime/packages/api-contracts', sourceDirectory: 'packages/api-contracts' }),
72
+ Object.freeze({ selector: '@smartgo/domain', target: '/runtime/packages/domain', sourceDirectory: 'packages/domain' }),
73
+ Object.freeze({ selector: '@smartgo/application', target: '/runtime/packages/application', sourceDirectory: 'packages/application' }),
74
+ Object.freeze({ selector: '@smartgo/object-store', target: '/runtime/packages/object-store', sourceDirectory: 'packages/object-store' }),
75
+ Object.freeze({ selector: '@smartgo/platform-session', target: '/runtime/packages/platform-session', sourceDirectory: 'packages/platform-session' }),
76
+ Object.freeze({ selector: '@smartgo/persistence', target: '/runtime/packages/persistence', sourceDirectory: 'packages/persistence' }),
77
+ Object.freeze({ selector: 'smartgo-api', target: '/runtime/services/smartgo-api', sourceDirectory: 'services/smartgo-api' }),
78
+ Object.freeze({ selector: 'agent-runtime', target: '/runtime/services/agent-runtime', sourceDirectory: 'services/agent-runtime' }),
79
+ Object.freeze({ selector: 'worker', target: '/runtime/services/worker', sourceDirectory: 'services/worker' }),
80
+ ]);
81
+ const SMARTGO_RUNTIME_ROOTS = Object.freeze(['@smartgo/persistence', 'smartgo-api', 'agent-runtime', 'worker']);
82
+ const SMARTGO_RUNTIME_COPY_SOURCES = Object.freeze([
83
+ '/runtime/packages',
84
+ '/runtime/services/smartgo-api',
85
+ '/runtime/services/agent-runtime',
86
+ '/runtime/services/worker',
87
+ '/workspace/apps/gotology/.artifacts/standalone',
88
+ '/workspace/apps/operations/.artifacts/standalone',
89
+ '/workspace/apps/studio-poc/.artifacts/standalone',
90
+ '/workspace/tooling/config-dir.mjs',
91
+ '/workspace/deploy/container-entrypoint.mjs',
92
+ '/workspace/deploy/backup-boundary.mjs',
93
+ '/workspace/deploy/component-contract.json',
94
+ ]);
95
+ const SMARTGO_RUNTIME_POST_DEPLOY_SEGMENTS = Object.freeze([
96
+ 'rm -rf /runtime/services/smartgo-api/src /runtime/services/agent-runtime/src /runtime/services/worker/src',
97
+ "find /runtime/services -type f \\( -name '*.test.*' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.*' \\) -delete",
98
+ '/runtime/packages/persistence/node_modules/.bin/prisma generate --schema /runtime/packages/persistence/prisma/schema.prisma >/dev/null',
99
+ 'node /workspace/deploy/sanitize-runtime-closure.mjs /runtime',
100
+ '/runtime/packages/persistence/node_modules/.bin/prisma --version >/dev/null',
101
+ '/runtime/packages/persistence/node_modules/.bin/tsx --version >/dev/null',
102
+ 'node --input-type=module -e "await import(\'/runtime/packages/persistence/dist/index.js\')"',
103
+ ]);
104
+ const SMARTGO_TYPESCRIPT_LINKS = Object.freeze([
105
+ 'packages/persistence/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@6.0.3__typescript@6.0.3/node_modules/typescript',
106
+ 'packages/persistence/node_modules/.pnpm/node_modules/typescript',
107
+ 'packages/persistence/node_modules/.pnpm/prisma@6.19.3_typescript@6.0.3/node_modules/typescript',
108
+ ]);
109
+ const SMARTGO_TYPESCRIPT_SHIMS = Object.freeze([
110
+ 'packages/persistence/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@6.0.3__typescript@6.0.3/node_modules/@prisma/client/node_modules/.bin/tsc',
111
+ 'packages/persistence/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@6.0.3__typescript@6.0.3/node_modules/@prisma/client/node_modules/.bin/tsserver',
112
+ 'packages/persistence/node_modules/.pnpm/node_modules/.bin/tsc',
113
+ 'packages/persistence/node_modules/.pnpm/node_modules/.bin/tsserver',
114
+ 'packages/persistence/node_modules/.pnpm/prisma@6.19.3_typescript@6.0.3/node_modules/prisma/node_modules/.bin/tsc',
115
+ 'packages/persistence/node_modules/.pnpm/prisma@6.19.3_typescript@6.0.3/node_modules/prisma/node_modules/.bin/tsserver',
116
+ ]);
117
+
118
+ const BUILD_CONTRACTS = Object.freeze({
119
+ tech: {
120
+ environmentKey: 'IDP_TECH_IMAGE', sourceDirectory: 'tech', context: '.',
121
+ dockerfile: 'apps/knowledge-service/Dockerfile', repositorySuffix: '', prebuildContract: 'none',
122
+ },
123
+ flow: {
124
+ environmentKey: 'IDP_FLOW_IMAGE', sourceDirectory: 'flow', context: '.',
125
+ dockerfile: 'deploy/Dockerfile', repositorySuffix: '/flow', prebuildContract: 'none',
126
+ },
127
+ portal: {
128
+ environmentKey: 'IDP_PORTAL_IMAGE', sourceDirectory: 'portal', context: '.',
129
+ dockerfile: 'packages/backend/Dockerfile', repositorySuffix: '/portal', prebuildContract: 'portal-backstage-v1',
130
+ },
131
+ smartgo: {
132
+ environmentKey: 'IDP_SMARTGO_IMAGE', sourceDirectory: 'smartGO', context: '.',
133
+ dockerfile: 'Dockerfile', repositorySuffix: '/smartgo', prebuildContract: 'smartgo-managed-v1',
134
+ },
135
+ });
136
+
137
+ const THIRD_PARTY_BY_SERVICE = Object.freeze({
138
+ postgresql: 'IDP_POSTGRES_IMAGE',
139
+ edge: 'IDP_CADDY_IMAGE',
140
+ registry: 'IDP_REGISTRY_IMAGE',
141
+ smartgoObjectStore: 'IDP_SMARTGO_OBJECT_STORE_IMAGE',
142
+ smartgoObjectStoreClient: 'IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE',
143
+ });
144
+
145
+ function exactKeys(value, expected, code, role) {
146
+ invariant(value && typeof value === 'object' && !Array.isArray(value), code, `${role}必须是对象`);
147
+ invariant(stableJson(Object.keys(value).sort()) === stableJson([...expected].sort()), code, `${role}字段集合无效`);
148
+ }
149
+
150
+ function validateReleaseDefaults(document) {
151
+ exactKeys(document, ['schemaVersion', 'releaseVersion', 'ociRepositoryRoot', 'platforms', 'thirdPartyImages', 'buildImages'], 'IDP_RELEASE_DEFAULTS_INVALID', 'Release defaults');
152
+ invariant(document.schemaVersion === 1, 'IDP_RELEASE_DEFAULTS_VERSION_UNSUPPORTED', '只支持Release defaults v1');
153
+ invariant(VERSION_PATTERN.test(document.releaseVersion ?? ''), 'IDP_RELEASE_VERSION_INVALID', 'Release defaults版本号无效');
154
+ invariant(OCI_REPOSITORY_PATTERN.test(document.ociRepositoryRoot ?? ''), 'IDP_RELEASE_OCI_REPOSITORY_INVALID', 'Release defaults OCI仓库根无效');
155
+ invariant(stableJson(document.platforms) === stableJson(REQUIRED_PLATFORMS), 'IDP_RELEASE_PLATFORMS_INVALID', 'Release defaults必须固定包含linux/amd64与linux/arm64');
156
+ exactKeys(document.thirdPartyImages, ['IDP_POSTGRES_IMAGE', 'IDP_CADDY_IMAGE', 'IDP_REGISTRY_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE'], 'IDP_RELEASE_DEFAULTS_INVALID', '第三方镜像默认值');
157
+ for (const [key, ref] of Object.entries(document.thirdPartyImages)) {
158
+ invariant(PINNED_IMAGE_PATTERN.test(ref ?? ''), 'IDP_RELEASE_DEFAULT_IMAGE_NOT_PINNED', `${key}必须使用OCI digest固定`);
159
+ }
160
+ exactKeys(document.buildImages, Object.keys(BUILD_CONTRACTS), 'IDP_RELEASE_DEFAULTS_INVALID', '自研镜像构建默认值');
161
+ for (const [name, expected] of Object.entries(BUILD_CONTRACTS)) {
162
+ const build = document.buildImages[name];
163
+ exactKeys(build, ['environmentKey', 'sourceDirectory', 'context', 'dockerfile', 'repositorySuffix', 'buildArgs', 'buildSecretFiles', 'prebuildContract'], 'IDP_RELEASE_DEFAULTS_INVALID', `${name}构建合同`);
164
+ for (const [key, value] of Object.entries(expected)) invariant(build[key] === value, 'IDP_RELEASE_BUILD_CONTRACT_DRIFT', `${name}构建合同${key}已偏移`);
165
+ exactKeys(build.buildArgs, ['tech', 'smartgo'].includes(name) ? ['NODE_IMAGE'] : [], 'IDP_RELEASE_BUILD_ARGS_INVALID', `${name}构建参数`);
166
+ if (['tech', 'smartgo'].includes(name)) invariant(PINNED_IMAGE_PATTERN.test(build.buildArgs.NODE_IMAGE ?? ''), 'IDP_RELEASE_NODE_IMAGE_NOT_PINNED', `${name} NODE_IMAGE必须使用OCI digest固定`);
167
+ exactKeys(build.buildSecretFiles, name === 'smartgo' ? ['npmrc'] : [], 'IDP_RELEASE_BUILD_SECRETS_INVALID', `${name}构建Secret`);
168
+ if (name === 'smartgo') invariant(build.buildSecretFiles.npmrc === 'SMARTGO_NPMRC_FILE', 'IDP_RELEASE_BUILD_SECRETS_INVALID', 'SmartGo构建只能读取SMARTGO_NPMRC_FILE');
169
+ }
170
+ return document;
171
+ }
172
+
173
+ export function readReleaseDefaults(repositoryRoot) {
174
+ const candidate = path.join(repositoryRoot, 'release', 'defaults.v1.json');
175
+ assertSafeRegularFile(candidate, 0o644, { allowEmpty: false, role: 'Release defaults' });
176
+ let document;
177
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
178
+ catch { throw new IdpError('IDP_RELEASE_DEFAULTS_JSON_INVALID', 'Release defaults不是有效JSON'); }
179
+ return validateReleaseDefaults(document);
180
+ }
181
+
182
+ function imageRepository(ref) {
183
+ const withoutDigest = String(ref ?? '').replace(/@sha256:[0-9a-f]{64}$/u, '');
184
+ const slash = withoutDigest.lastIndexOf('/');
185
+ const colon = withoutDigest.lastIndexOf(':');
186
+ return colon > slash ? withoutDigest.slice(0, colon) : withoutDigest;
187
+ }
188
+
189
+ export function resolveOciRepositoryRoot(env, explicit, fallback) {
190
+ const configured = env.IDP_OCI_REPOSITORY_ROOT;
191
+ const candidate = explicit ?? (configured && configured !== 'UNCONFIGURED'
192
+ ? configured
193
+ : (env.IDP_TECH_IMAGE && env.IDP_TECH_IMAGE !== 'UNCONFIGURED'
194
+ ? imageRepository(env.IDP_TECH_IMAGE)
195
+ : fallback));
196
+ invariant(candidate && candidate !== 'UNCONFIGURED' && OCI_REPOSITORY_PATTERN.test(candidate), 'IDP_OCI_REPOSITORY_ROOT_REQUIRED', '请配置有效的默认OCI仓库根、IDP_OCI_REPOSITORY_ROOT或可推导仓库路径的IDP_TECH_IMAGE');
197
+ return candidate;
198
+ }
199
+
200
+ function updateHashFromFile(hash, candidate) {
201
+ const descriptor = fs.openSync(candidate, 'r');
202
+ const buffer = Buffer.allocUnsafe(64 * 1024);
203
+ try {
204
+ for (;;) {
205
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, null);
206
+ if (bytes === 0) break;
207
+ hash.update(buffer.subarray(0, bytes));
208
+ }
209
+ } finally { fs.closeSync(descriptor); }
210
+ }
211
+
212
+ function dockerIgnoreRegex(rawPattern) {
213
+ let pattern = rawPattern.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/^\//u, '').replace(/\/$/u, '');
214
+ const containsSlash = pattern.includes('/');
215
+ let body = '';
216
+ for (let index = 0; index < pattern.length; index += 1) {
217
+ const character = pattern[index];
218
+ if (character === '*' && pattern[index + 1] === '*') {
219
+ index += 1;
220
+ if (pattern[index + 1] === '/') { index += 1; body += '(?:.*/)?'; }
221
+ else body += '.*';
222
+ } else if (character === '*') body += '[^/]*';
223
+ else if (character === '?') body += '[^/]';
224
+ else body += character.replace(/[|\\{}()[\]^$+?.]/gu, '\\$&');
225
+ }
226
+ return new RegExp(`${containsSlash ? '^' : '(?:^|/)'}${body}(?:/|$)`, 'u');
227
+ }
228
+
229
+ function readDockerIgnoreRules(sourceRoot) {
230
+ const candidate = path.join(sourceRoot, '.dockerignore');
231
+ assertSafeRegularFile(candidate, 0o644, { allowEmpty: false, role: 'SmartGo .dockerignore' });
232
+ return fs.readFileSync(candidate, 'utf8').split(/\r?\n/u).map((line) => line.trim()).filter((line) => line && !line.startsWith('#')).map((line) => {
233
+ const negated = line.startsWith('!');
234
+ const pattern = negated ? line.slice(1) : line;
235
+ invariant(pattern.length > 0 && !pattern.includes('\0'), 'IDP_RELEASE_DOCKERIGNORE_INVALID', 'SmartGo .dockerignore包含无效规则');
236
+ return { negated, pattern, regex: dockerIgnoreRegex(pattern) };
237
+ });
238
+ }
239
+
240
+ function inspectDockerContext(sourceRoot) {
241
+ const rules = readDockerIgnoreRules(sourceRoot);
242
+ const files = [];
243
+ const generatedSegments = new Set(['.git', '.dyyto', 'node_modules', '.next', '.artifacts', 'dist', 'coverage', 'test-results', 'playwright-report']);
244
+ const ignored = (relative) => {
245
+ let result = false;
246
+ for (const rule of rules) if (rule.regex.test(relative)) result = !rule.negated;
247
+ return result;
248
+ };
249
+ const visit = (directory, prefix = '') => {
250
+ for (const name of fs.readdirSync(directory).sort()) {
251
+ const relative = prefix ? `${prefix}/${name}` : name;
252
+ const candidate = path.join(directory, name);
253
+ const entry = fs.lstatSync(candidate);
254
+ invariant(!entry.isSymbolicLink(), 'IDP_RELEASE_NON_GIT_LINK_FORBIDDEN', `非Git构建上下文禁止链接:${relative}`);
255
+ const segments = relative.split('/');
256
+ if (segments.some((segment) => generatedSegments.has(segment))) continue;
257
+ invariant(!(name === '.env' || (name.startsWith('.env.') && name !== '.env.example')), 'IDP_RELEASE_NON_GIT_SECRET_FILE_FORBIDDEN', `非Git构建上下文禁止环境文件:${relative}`);
258
+ if (ignored(relative)) continue;
259
+ if (entry.isDirectory()) visit(candidate, relative);
260
+ else {
261
+ invariant(entry.isFile() && entry.nlink === 1, 'IDP_RELEASE_SOURCE_ENTRY_INVALID', `非Git构建源包含不支持的文件类型:${relative}`);
262
+ files.push(relative);
263
+ }
264
+ }
265
+ };
266
+ visit(sourceRoot);
267
+ invariant(files.includes('.dockerignore') && files.includes('Dockerfile'), 'IDP_RELEASE_NON_GIT_CONTEXT_INVALID', '非Git构建上下文必须包含Dockerfile与.dockerignore');
268
+ return files.sort();
269
+ }
270
+
271
+ export function inspectSource(sourceRoot, { sourceRunner = run, allowNonGit = false } = {}) {
272
+ const stat = fs.lstatSync(sourceRoot);
273
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_RELEASE_SOURCE_INVALID', `构建源必须是普通目录:${sourceRoot}`);
274
+ let files;
275
+ let sourceMode = 'git-worktree';
276
+ try {
277
+ const listed = sourceRunner('git', ['-C', sourceRoot, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { capture: true });
278
+ files = String(listed.stdout ?? '').split('\0').filter(Boolean).sort();
279
+ } catch (error) {
280
+ invariant(allowNonGit, 'IDP_RELEASE_NON_GIT_SOURCE_FORBIDDEN', `构建源不是可审计Git工作区:${sourceRoot}`);
281
+ files = inspectDockerContext(sourceRoot);
282
+ sourceMode = 'dockerignore-development';
283
+ }
284
+ invariant(files.length > 0, 'IDP_RELEASE_SOURCE_EMPTY', `构建源没有Git管理或未忽略的文件:${sourceRoot}`);
285
+ const hash = crypto.createHash('sha256');
286
+ for (const relative of files) {
287
+ invariant(!path.isAbsolute(relative) && !relative.split(/[\\/]/u).includes('..') && !relative.includes('\0'), 'IDP_RELEASE_SOURCE_PATH_INVALID', '构建源包含不安全路径');
288
+ const candidate = path.resolve(sourceRoot, relative);
289
+ const contained = path.relative(sourceRoot, candidate);
290
+ invariant(contained && !contained.startsWith('..') && !path.isAbsolute(contained), 'IDP_RELEASE_SOURCE_PATH_INVALID', '构建源路径逃逸');
291
+ hash.update(`${Buffer.byteLength(relative)}:${relative}\0`);
292
+ if (!fs.existsSync(candidate)) {
293
+ hash.update('deleted\0');
294
+ continue;
295
+ }
296
+ const entry = fs.lstatSync(candidate);
297
+ hash.update(`${entry.mode & 0o7777}\0`);
298
+ if (entry.isFile()) updateHashFromFile(hash, candidate);
299
+ else if (entry.isSymbolicLink() && sourceMode === 'git-worktree') hash.update(`link:${fs.readlinkSync(candidate)}`);
300
+ else invariant(false, 'IDP_RELEASE_SOURCE_ENTRY_INVALID', `构建源包含不支持的文件类型:${relative}`);
301
+ hash.update('\0');
302
+ }
303
+ return { digest: `sha256:${hash.digest('hex')}`, fileCount: files.length, sourceMode };
304
+ }
305
+
306
+ function defaultProbe(command, args, options = {}) {
307
+ const result = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe', ...options });
308
+ if (result.error) throw new IdpError('IDP_PROCESS_START_FAILED', `${command}启动失败:${result.error.message}`);
309
+ return result;
310
+ }
311
+
312
+ function inspectionPlatforms(document) {
313
+ return [...new Set((document?.manifest?.manifests ?? [])
314
+ .map((manifest) => `${manifest?.platform?.os}/${manifest?.platform?.architecture}`)
315
+ .filter((platform) => REQUIRED_PLATFORMS.includes(platform)))].sort();
316
+ }
317
+
318
+ function inspectionPlatformCounts(document) {
319
+ const counts = Object.fromEntries(REQUIRED_PLATFORMS.map((platform) => [platform, 0]));
320
+ for (const manifest of document?.manifest?.manifests ?? []) {
321
+ const platform = `${manifest?.platform?.os}/${manifest?.platform?.architecture}`;
322
+ if (Object.hasOwn(counts, platform)) counts[platform] += 1;
323
+ }
324
+ return counts;
325
+ }
326
+
327
+ function inspectionReleaseLabels(document) {
328
+ return Object.fromEntries(REQUIRED_PLATFORMS.map((platform) => {
329
+ const labels = document?.image?.[platform]?.config?.Labels;
330
+ return [platform, Object.fromEntries(RELEASE_LABEL_KEYS.map((key) => [
331
+ key,
332
+ labels && typeof labels === 'object' && !Array.isArray(labels) && typeof labels[key] === 'string'
333
+ ? labels[key]
334
+ : null,
335
+ ]))];
336
+ }));
337
+ }
338
+
339
+ export function inspectPublishedImage(ref, { probeRunner = defaultProbe, allowMissing = false } = {}) {
340
+ const mirrorRef = dockerOfficialMirrorRef(ref);
341
+ // 固定 digest 的 Docker Official Image 可先从 Google 官方镜像缓存读取
342
+ // 同一 OCI 事实,避免无意义消耗 Docker Hub 匿名限额;缓存不可用时
343
+ // 再回到原始引用,私有仓库始终只检查原引用。
344
+ let result = probeRunner('docker', ['buildx', 'imagetools', 'inspect', mirrorRef ?? ref, '--format', '{{json .}}'], { capture: true });
345
+ if (result.status !== 0 && mirrorRef) {
346
+ result = probeRunner('docker', ['buildx', 'imagetools', 'inspect', ref, '--format', '{{json .}}'], { capture: true });
347
+ }
348
+ if (result.status !== 0) {
349
+ const message = `${result.stderr ?? ''}\n${result.stdout ?? ''}`;
350
+ if (allowMissing && /(?:not found|manifest unknown|no such manifest|\b404\b)/iu.test(message)) return null;
351
+ throw new IdpError('IDP_RELEASE_IMAGE_INSPECT_FAILED', `无法检查OCI镜像:${ref}`);
352
+ }
353
+ let document;
354
+ try { document = JSON.parse(String(result.stdout ?? '')); }
355
+ catch { throw new IdpError('IDP_RELEASE_IMAGE_INSPECT_INVALID', `OCI镜像检查结果不是有效JSON:${ref}`); }
356
+ const digest = document?.manifest?.digest;
357
+ const platforms = inspectionPlatforms(document);
358
+ invariant(DIGEST_PATTERN.test(digest ?? ''), 'IDP_RELEASE_IMAGE_DIGEST_INVALID', `OCI镜像缺少Index digest:${ref}`);
359
+ invariant(stableJson(platforms) === stableJson([...REQUIRED_PLATFORMS].sort()), 'IDP_RELEASE_IMAGE_PLATFORM_MISSING', `OCI镜像必须同时包含linux/amd64与linux/arm64:${ref}`);
360
+ return {
361
+ digest,
362
+ platforms: REQUIRED_PLATFORMS,
363
+ platformCounts: inspectionPlatformCounts(document),
364
+ releaseLabelsByPlatform: inspectionReleaseLabels(document),
365
+ };
366
+ }
367
+
368
+ function expectedReleaseLabels(plan, planned) {
369
+ return {
370
+ 'org.opencontainers.image.version': plan.releaseTag,
371
+ 'org.opencontainers.image.revision': planned.sourceDigest,
372
+ 'dev.idp.release.plan': plan.planDigest,
373
+ 'dev.idp.release.component': planned.component,
374
+ };
375
+ }
376
+
377
+ function verifyPublishedReleaseIdentity(published, plan, planned, code, { allowLegacyComponent = false } = {}) {
378
+ const expected = expectedReleaseLabels(plan, planned);
379
+ for (const platform of REQUIRED_PLATFORMS) {
380
+ invariant(
381
+ published.platformCounts?.[platform] === 1,
382
+ code,
383
+ `OCI镜像${platform}必须且只能有一个应用Manifest:${planned.tag}`,
384
+ );
385
+ const observed = published.releaseLabelsByPlatform?.[platform];
386
+ const accepted = stableJson(observed) === stableJson(expected) || (
387
+ allowLegacyComponent &&
388
+ observed?.['dev.idp.release.component'] === null &&
389
+ stableJson({ ...observed, 'dev.idp.release.component': planned.component }) === stableJson(expected)
390
+ );
391
+ invariant(accepted, code, `OCI镜像${platform}发布身份与当前不可变Plan不一致:${planned.tag}`);
392
+ }
393
+ return published.releaseLabelsByPlatform;
394
+ }
395
+
396
+ function ensurePrivateDirectory(candidate, role) {
397
+ if (!fs.existsSync(candidate)) fs.mkdirSync(candidate, { recursive: true, mode: 0o700 });
398
+ const stat = assertSafeDirectory(candidate, 0o700, { role });
399
+ invariant((stat.mode & 0o777) === 0o700, 'IDP_RELEASE_DIRECTORY_MODE_INVALID', `${role}权限必须是0700`);
400
+ }
401
+
402
+ function persistPlan(configRoot, facts) {
403
+ const planDigest = sha256(stableJson(facts));
404
+ const directory = resolveContained(configRoot, 'releases/oci-plans', 'OCI Release Plan目录');
405
+ ensurePrivateDirectory(directory, 'OCI Release Plan目录');
406
+ const candidate = path.join(directory, `${planDigest.slice(7)}.json`);
407
+ if (fs.existsSync(candidate)) {
408
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'OCI Release Plan' });
409
+ let existing;
410
+ try { existing = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
411
+ catch { throw new IdpError('IDP_RELEASE_PLAN_INVALID', '已有OCI Release Plan不是有效JSON'); }
412
+ const { createdAt: _createdAt, digest, planDigest: existingPlanDigest, ...existingFacts } = existing;
413
+ invariant(existingPlanDigest === planDigest && DIGEST_PATTERN.test(digest ?? '') && digest === sha256(stableJson({ ...existingFacts, planDigest: existingPlanDigest, createdAt: existing.createdAt })) && stableJson(existingFacts) === stableJson(facts), 'IDP_RELEASE_PLAN_CONFLICT', '已有OCI Release Plan与当前事实冲突');
414
+ return { plan: existing, relativePath: path.relative(configRoot, candidate) };
415
+ }
416
+ const body = { ...facts, planDigest, createdAt: new Date().toISOString() };
417
+ const plan = { ...body, digest: sha256(stableJson(body)) };
418
+ createExclusiveFile(candidate, `${JSON.stringify(plan, null, 2)}\n`, 0o600);
419
+ return { plan, relativePath: path.relative(configRoot, candidate) };
420
+ }
421
+
422
+ function persistDeploymentPlan(configRoot, facts) {
423
+ const planDigest = sha256(stableJson(facts));
424
+ const directory = resolveContained(configRoot, 'releases/deployment-plans', 'Deployment Plan目录');
425
+ ensurePrivateDirectory(directory, 'Deployment Plan目录');
426
+ const candidate = path.join(directory, `${planDigest.slice(7)}.json`);
427
+ if (fs.existsSync(candidate)) {
428
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'Deployment Plan' });
429
+ let existing;
430
+ try { existing = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
431
+ catch { throw new IdpError('IDP_RELEASE_DEPLOYMENT_PLAN_INVALID', '已有Deployment Plan不是有效JSON'); }
432
+ const { createdAt: _createdAt, digest, planDigest: existingPlanDigest, ...existingFacts } = existing;
433
+ invariant(
434
+ existingPlanDigest === planDigest && DIGEST_PATTERN.test(digest ?? '') &&
435
+ digest === sha256(stableJson({ ...existingFacts, planDigest: existingPlanDigest, createdAt: existing.createdAt })) &&
436
+ stableJson(existingFacts) === stableJson(facts),
437
+ 'IDP_RELEASE_DEPLOYMENT_PLAN_CONFLICT', '已有Deployment Plan与当前事实冲突',
438
+ );
439
+ return { plan: existing, relativePath: path.relative(configRoot, candidate) };
440
+ }
441
+ const body = { ...facts, planDigest, createdAt: new Date().toISOString() };
442
+ const plan = { ...body, digest: sha256(stableJson(body)) };
443
+ createExclusiveFile(candidate, `${JSON.stringify(plan, null, 2)}\n`, 0o600);
444
+ return { plan, relativePath: path.relative(configRoot, candidate) };
445
+ }
446
+
447
+ function artifactPath(configRoot, planDigest, component) {
448
+ const directory = resolveContained(configRoot, path.join('releases', 'oci-artifacts', planDigest.slice(7)), 'OCI Release Artifact目录');
449
+ ensurePrivateDirectory(directory, 'OCI Release Artifact目录');
450
+ return path.join(directory, `${component}.json`);
451
+ }
452
+
453
+ function readArtifact(candidate) {
454
+ if (!fs.existsSync(candidate)) return null;
455
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'OCI Release Artifact' });
456
+ let document;
457
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
458
+ catch { throw new IdpError('IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact不是有效JSON'); }
459
+ const { digest, ...body } = document ?? {};
460
+ invariant(DIGEST_PATTERN.test(digest ?? '') && digest === sha256(stableJson(body)), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact摘要无效');
461
+ const commonKeys = ['schemaVersion', 'planDigest', 'component', 'tag', 'imageDigest', 'sourceDigest', 'platforms', 'createdAt', 'digest'];
462
+ if (document.schemaVersion === 1) {
463
+ exactKeys(document, [...commonKeys, 'buildxMetadataDigest'], 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact v1');
464
+ invariant(DIGEST_PATTERN.test(document.buildxMetadataDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact v1缺少Buildx metadata摘要');
465
+ } else {
466
+ invariant(document.schemaVersion === 2, 'IDP_RELEASE_ARTIFACT_VERSION_UNSUPPORTED', '只支持OCI Release Artifact v1/v2');
467
+ exactKeys(document, [...commonKeys, 'evidence'], 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact v2');
468
+ invariant(document.evidence && typeof document.evidence === 'object' && !Array.isArray(document.evidence), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact v2 evidence无效');
469
+ if (document.evidence.type === 'buildx-metadata') {
470
+ exactKeys(document.evidence, ['type', 'metadataImageDigest', 'labelsByPlatform'], 'IDP_RELEASE_ARTIFACT_INVALID', 'Buildx Artifact evidence');
471
+ invariant(DIGEST_PATTERN.test(document.evidence.metadataImageDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', 'Buildx Artifact evidence摘要无效');
472
+ } else if (document.evidence.type === 'host-oci-layout-oras') {
473
+ exactKeys(document.evidence, ['type', 'layoutImageDigest', 'orasVersion', 'orasBinaryDigest', 'labelsByPlatform'], 'IDP_RELEASE_ARTIFACT_INVALID', '宿主OCI Layout Artifact evidence');
474
+ invariant(DIGEST_PATTERN.test(document.evidence.layoutImageDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', '宿主OCI Layout evidence摘要无效');
475
+ invariant(/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(document.evidence.orasVersion ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', '宿主OCI Layout evidence ORAS版本无效');
476
+ invariant(DIGEST_PATTERN.test(document.evidence.orasBinaryDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', '宿主OCI Layout evidence ORAS工具摘要无效');
477
+ } else {
478
+ invariant(document.evidence.type === 'registry-label-adoption', 'IDP_RELEASE_ARTIFACT_EVIDENCE_UNSUPPORTED', 'OCI Release Artifact evidence类型无效');
479
+ exactKeys(document.evidence, ['type', 'labelsByPlatform'], 'IDP_RELEASE_ARTIFACT_INVALID', 'Registry收养Artifact evidence');
480
+ }
481
+ exactKeys(document.evidence.labelsByPlatform, REQUIRED_PLATFORMS, 'IDP_RELEASE_ARTIFACT_INVALID', 'Artifact平台标签');
482
+ for (const platform of REQUIRED_PLATFORMS) {
483
+ exactKeys(document.evidence.labelsByPlatform[platform], RELEASE_LABEL_KEYS, 'IDP_RELEASE_ARTIFACT_INVALID', `${platform} Artifact标签`);
484
+ invariant(
485
+ RELEASE_LABEL_KEYS.every((key) => typeof document.evidence.labelsByPlatform[platform][key] === 'string'),
486
+ 'IDP_RELEASE_ARTIFACT_INVALID', `${platform} Artifact标签值无效`,
487
+ );
488
+ }
489
+ }
490
+ invariant(DIGEST_PATTERN.test(document.planDigest ?? '') && DIGEST_PATTERN.test(document.imageDigest ?? '') && DIGEST_PATTERN.test(document.sourceDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact事实摘要无效');
491
+ invariant(/^[a-z][a-z0-9-]*$/u.test(document.component ?? '') && typeof document.tag === 'string' && document.tag.length > 0, 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact组件或tag无效');
492
+ invariant(stableJson(document.platforms) === stableJson(REQUIRED_PLATFORMS), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact平台集合无效');
493
+ invariant(typeof document.createdAt === 'string' && !Number.isNaN(Date.parse(document.createdAt)), 'IDP_RELEASE_ARTIFACT_INVALID', 'OCI Release Artifact时间无效');
494
+ return document;
495
+ }
496
+
497
+ function persistArtifact(candidate, body) {
498
+ const document = { ...body, digest: sha256(stableJson(body)) };
499
+ if (fs.existsSync(candidate)) {
500
+ const existing = readArtifact(candidate);
501
+ invariant(stableJson(existing) === stableJson(document), 'IDP_RELEASE_ARTIFACT_CONFLICT', '已有OCI Release Artifact与新构建结果冲突');
502
+ return existing;
503
+ }
504
+ createExclusiveFile(candidate, `${JSON.stringify(document, null, 2)}\n`, 0o600);
505
+ return document;
506
+ }
507
+
508
+ function artifactMatchesPublished(artifact, published, plan, planned, observedLabels) {
509
+ const commonIdentity = artifact.planDigest === plan.planDigest && artifact.component === planned.component &&
510
+ artifact.tag === planned.tag && artifact.imageDigest === published.digest && artifact.sourceDigest === planned.sourceDigest &&
511
+ stableJson(artifact.platforms) === stableJson(REQUIRED_PLATFORMS);
512
+ if (!commonIdentity) return false;
513
+ if (artifact.schemaVersion === 1) return artifact.buildxMetadataDigest === published.digest;
514
+ if (stableJson(artifact.evidence.labelsByPlatform) !== stableJson(observedLabels)) return false;
515
+ if (artifact.evidence.type === 'buildx-metadata') return artifact.evidence.metadataImageDigest === published.digest;
516
+ if (artifact.evidence.type === 'host-oci-layout-oras') return artifact.evidence.layoutImageDigest === published.digest;
517
+ return artifact.evidence.type === 'registry-label-adoption';
518
+ }
519
+
520
+ function dockerfileLogicalInstructions(dockerfile) {
521
+ const instructions = [];
522
+ let current = '';
523
+ for (const line of dockerfile.replaceAll('\r\n', '\n').split('\n')) {
524
+ const rightTrimmed = line.replace(/[ \t]+$/u, '');
525
+ if (!current && (!rightTrimmed.trim() || rightTrimmed.trimStart().startsWith('#'))) continue;
526
+ const continued = rightTrimmed.endsWith('\\');
527
+ const fragment = continued ? rightTrimmed.slice(0, -1) : rightTrimmed;
528
+ current = `${current}${current ? ' ' : ''}${fragment.trim()}`;
529
+ if (!continued) {
530
+ if (current) instructions.push(current);
531
+ current = '';
532
+ }
533
+ }
534
+ invariant(!current, 'IDP_RELEASE_SMARTGO_DOCKERFILE_INVALID', 'SmartGo Dockerfile包含未结束的续行指令');
535
+ return instructions;
536
+ }
537
+
538
+ export function validateSmartGoCorepackContract(dockerfile) {
539
+ const instructions = dockerfileLogicalInstructions(dockerfile);
540
+ const prepareRuns = instructions.filter((instruction) => /^RUN\s+/u.test(instruction) && /\bcorepack\s+prepare\b/u.test(instruction));
541
+ invariant(prepareRuns.length > 0, 'IDP_RELEASE_SMARTGO_COREPACK_REGISTRY_INVALID', 'SmartGo Dockerfile必须显式准备锁定版本的Corepack包管理器');
542
+ const protectedPrepare = /(?:^|[\s;&|])COREPACK_NPM_REGISTRY=http:\/\/host\.docker\.internal:4873[ \t]+corepack\s+prepare\b/gu;
543
+ for (const instruction of prepareRuns) {
544
+ const prepareCount = (instruction.match(/\bcorepack\s+prepare\b/gu) ?? []).length;
545
+ const protectedCount = (instruction.match(protectedPrepare) ?? []).length;
546
+ invariant(
547
+ prepareCount === protectedCount,
548
+ 'IDP_RELEASE_SMARTGO_COREPACK_REGISTRY_INVALID',
549
+ 'SmartGo每个corepack prepare必须在同一RUN内立即使用COREPACK_NPM_REGISTRY=http://host.docker.internal:4873',
550
+ );
551
+ }
552
+ invariant(
553
+ !/\bCOREPACK_NPM_(?:TOKEN|USERNAME|PASSWORD)\b/u.test(instructions.join('\n')),
554
+ 'IDP_RELEASE_SMARTGO_COREPACK_CREDENTIAL_FORBIDDEN',
555
+ 'SmartGo Corepack公共代理不得声明Token或用户凭据',
556
+ );
557
+ const integrityAssignmentDisabled = /\bCOREPACK_INTEGRITY_KEYS\s*=\s*(?:0|'0'|"0"|''|"")(?=$|[\s;&|])/u;
558
+ const integrityEnvDisabled = /^(?:ENV|ARG)\s+COREPACK_INTEGRITY_KEYS\s+(?:0|'0'|"0"|''|"")(?=$|\s)/u;
559
+ invariant(
560
+ !instructions.some((instruction) => integrityAssignmentDisabled.test(instruction) || integrityEnvDisabled.test(instruction)),
561
+ 'IDP_RELEASE_SMARTGO_COREPACK_INTEGRITY_DISABLED',
562
+ 'SmartGo Dockerfile不得关闭Corepack integrity校验',
563
+ );
564
+ }
565
+
566
+ export function validateSmartGoPostInstallNetworkContract(dockerfile) {
567
+ const instructions = dockerfileLogicalInstructions(dockerfile);
568
+ invariant(
569
+ !/\bPRISMA_(?:ENGINES_CHECKSUM_IGNORE_MISSING|ENGINES_MIRROR|QUERY_ENGINE_BINARY|QUERY_ENGINE_LIBRARY|SCHEMA_ENGINE_BINARY|FMT_BINARY)\b/u.test(instructions.join('\n')),
570
+ 'IDP_RELEASE_SMARTGO_PRISMA_ENGINE_OVERRIDE_FORBIDDEN',
571
+ 'SmartGo不得关闭Prisma引擎checksum或覆盖冻结引擎镜像与路径',
572
+ );
573
+ const runs = instructions.filter((instruction) => /^RUN\s+/u.test(instruction));
574
+ const parsed = runs.map((instruction) => Object.freeze({ instruction, ...splitRunInstruction(instruction) }));
575
+ const allowedDirectives = new Set([
576
+ '--network=none',
577
+ `--mount=${SMARTGO_PNPM_CACHE_MOUNT}`,
578
+ `--mount=${SMARTGO_NPMRC_SECRET_MOUNT}`,
579
+ ]);
580
+ invariant(parsed.every(({ directives }) => directives.every((directive) => allowedDirectives.has(directive))),
581
+ 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_ISOLATION_INVALID', 'SmartGo RUN只允许批准的network=none、pnpm cache与npmrc Secret前缀');
582
+
583
+ const installRuns = parsed.filter(({ command }) => /(?:^|[\s;&|])pnpm\s+install\b/u.test(command));
584
+ invariant(installRuns.length === 1 && stableJson(installRuns[0].directives) === stableJson([
585
+ `--mount=${SMARTGO_PNPM_CACHE_MOUNT}`,
586
+ `--mount=${SMARTGO_NPMRC_SECRET_MOUNT}`,
587
+ ]), 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_ISOLATION_INVALID', 'SmartGo唯一install/closure-preflight RUN必须保留受控网络与固定cache/Secret');
588
+
589
+ const packageRuns = parsed.filter(({ command }) =>
590
+ /\bpnpm\s+[^;&|]*\bbuild\b/u.test(command) || /\bpackage-next-standalone\.mjs\b/u.test(command));
591
+ invariant(packageRuns.length === 1 && stableJson(packageRuns[0].directives) === stableJson(['--network=none']),
592
+ 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_ISOLATION_INVALID', 'SmartGo build/package RUN必须且只能使用BuildKit network=none');
593
+ invariant(stableJson(shellInstructionSegments(packageRuns[0].instruction)) === stableJson(SMARTGO_BUILD_PACKAGE_SEGMENTS),
594
+ 'IDP_RELEASE_SMARTGO_BUILD_PACKAGE_COMMAND_INVALID', 'SmartGo build/package断网RUN必须按固定顺序执行Workspace Prisma generate、build与三个Next package');
595
+
596
+ const assemblyRuns = parsed.filter(({ command }) => !/(?:^|[\s;&|])pnpm\s+install\b/u.test(command) &&
597
+ shellInstructionSegments(`RUN ${command}`).some((segment) => /\bpnpm\s+[^;&|]*\bdeploy\b/u.test(segment)));
598
+ invariant(assemblyRuns.length === 1 && stableJson(assemblyRuns[0].directives) === stableJson([
599
+ '--network=none',
600
+ `--mount=${SMARTGO_PNPM_CACHE_MOUNT}`,
601
+ ]), 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_ISOLATION_INVALID', 'SmartGo runtime assembly RUN必须按固定顺序使用network=none与唯一pnpm cache');
602
+
603
+ const networks = parsed.flatMap(({ directives }) => directives.filter((directive) => directive.startsWith('--network=')));
604
+ invariant(stableJson(networks) === stableJson(['--network=none', '--network=none']),
605
+ 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_ISOLATION_INVALID', 'SmartGo Dockerfile必须且只能在build/package与runtime assembly使用两个network=none');
606
+ }
607
+
608
+ function splitRunInstruction(instruction) {
609
+ let command = instruction.replace(/^RUN\s+/u, '').trim();
610
+ const mounts = [];
611
+ const directives = [];
612
+ while (command.startsWith('--')) {
613
+ const match = command.match(/^--([a-z][a-z0-9-]*)(?:=([^\s]+))?(?:\s+|$)/u);
614
+ invariant(match, 'IDP_RELEASE_SMARTGO_DOCKERFILE_INVALID', 'SmartGo Dockerfile包含无法解析的RUN前缀选项');
615
+ const directive = match[0].trim();
616
+ directives.push(directive);
617
+ if (match[1] === 'mount') {
618
+ invariant(match[2], 'IDP_RELEASE_SMARTGO_DOCKERFILE_INVALID', 'SmartGo Dockerfile包含无效RUN mount');
619
+ mounts.push(match[2]);
620
+ }
621
+ command = command.slice(match[0].length).trim();
622
+ }
623
+ return { command, directives, mounts };
624
+ }
625
+
626
+ function shellInstructionSegments(instruction) {
627
+ return splitRunInstruction(instruction).command.split(/\s+(?:&&|\|\|)\s+|(?<!\\);\s*/u)
628
+ .map((segment) => segment.trim()).filter(Boolean);
629
+ }
630
+
631
+ function mountOptions(mount) {
632
+ return new Map(mount.split(',').map((part) => {
633
+ const separator = part.indexOf('=');
634
+ return separator < 0 ? [part, 'true'] : [part.slice(0, separator), part.slice(separator + 1)];
635
+ }));
636
+ }
637
+
638
+ function hasSecretMount(instruction) {
639
+ return [...instruction.matchAll(/--mount=([^\s]+)/gu)]
640
+ .some((match) => mountOptions(match[1]).get('type') === 'secret');
641
+ }
642
+
643
+ function hasNpmrcSecretMount(instruction) {
644
+ for (const match of instruction.matchAll(/--mount=([^\s]+)/gu)) {
645
+ const options = mountOptions(match[1]);
646
+ if (options.get('type') !== 'secret') continue;
647
+ const target = options.get('target') ?? options.get('dst') ?? options.get('destination') ?? '';
648
+ if (options.get('id') === 'npmrc' || /(?:^|\/)\.?npmrc$/u.test(target)) return true;
649
+ }
650
+ return false;
651
+ }
652
+
653
+ function hasDependencyNetworkCommand(instruction) {
654
+ const command = splitRunInstruction(instruction).command;
655
+ return /(?:^|[\s;&|])(?:npm|npx|yarn|corepack|curl|wget)(?=$|[\s;&|])/u.test(command) ||
656
+ /(?:^|[\s;&|])pnpm\b[^;&|]*\b(?:install|fetch|view|info|add|update|up|publish|audit|outdated)\b/u.test(command) ||
657
+ /(?:^|[\s;&|])git\s+(?:clone|fetch|pull|ls-remote|submodule\s+update)\b/u.test(command) ||
658
+ /(?:^|[\s;&|])(?:apk|apt|apt-get|yum|dnf)\s+(?:add|install|update|upgrade)\b/u.test(command);
659
+ }
660
+
661
+ function dockerCopySources(instruction) {
662
+ const tokens = instruction.trim().split(/\s+/u);
663
+ invariant(tokens[0] === 'COPY', 'IDP_RELEASE_SMARTGO_RUNTIME_COPY_INVALID', 'SmartGo runtime COPY指令无法解析');
664
+ let index = 1;
665
+ while (tokens[index]?.startsWith('--')) index += 1;
666
+ invariant(tokens.length - index >= 2, 'IDP_RELEASE_SMARTGO_RUNTIME_COPY_INVALID', 'SmartGo runtime COPY必须包含来源与目标');
667
+ return tokens.slice(index, -1);
668
+ }
669
+
670
+ function readSmartGoManifest(manifestPath) {
671
+ let manifest;
672
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); }
673
+ catch { throw new IdpError('IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo Manifest不是有效JSON:${path.basename(path.dirname(manifestPath))}`); }
674
+ invariant(manifest && typeof manifest === 'object' && !Array.isArray(manifest) && typeof manifest.name === 'string' && manifest.name,
675
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', 'SmartGo production Workspace Manifest必须包含有效name');
676
+ return manifest;
677
+ }
678
+
679
+ function deriveSmartGoProductionClosure(sourceRoot) {
680
+ const manifests = new Map();
681
+ for (const kind of ['packages', 'services']) {
682
+ const root = path.join(sourceRoot, kind);
683
+ invariant(fs.existsSync(root), 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo缺少${kind}目录`);
684
+ for (const entry of fs.readdirSync(root, { withFileTypes: true }).filter((candidate) => candidate.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) {
685
+ const sourceDirectory = `${kind}/${entry.name}`;
686
+ const manifestPath = path.join(root, entry.name, 'package.json');
687
+ if (!fs.existsSync(manifestPath)) continue;
688
+ const manifest = readSmartGoManifest(manifestPath);
689
+ invariant(!manifests.has(manifest.name), 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo Workspace存在重复Package名:${manifest.name}`);
690
+ manifests.set(manifest.name, Object.freeze({ manifest, sourceDirectory }));
691
+ }
692
+ }
693
+
694
+ const closure = new Set();
695
+ const pending = [...SMARTGO_RUNTIME_ROOTS];
696
+ while (pending.length) {
697
+ const name = pending.shift();
698
+ if (closure.has(name)) continue;
699
+ const entry = manifests.get(name);
700
+ invariant(entry, 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo production Workspace缺少入口:${name}`);
701
+ closure.add(name);
702
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
703
+ const dependencies = entry.manifest[section] ?? {};
704
+ invariant(dependencies && typeof dependencies === 'object' && !Array.isArray(dependencies),
705
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo ${name} 的 ${section}必须是对象`);
706
+ for (const [dependency, version] of Object.entries(dependencies)) {
707
+ invariant(typeof version === 'string' && version,
708
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo ${name} 的 ${dependency}版本无效`);
709
+ invariant(dependency !== 'vitest' && dependency !== 'typescript' && !dependency.startsWith('@types/') &&
710
+ dependency !== '@aipt/testkit' && !dependency.startsWith('@bench/'),
711
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEV_DEPENDENCY_FORBIDDEN', `SmartGo production闭包不得声明开发工具:${name} -> ${dependency}`);
712
+ if (!version.startsWith('workspace:')) continue;
713
+ invariant(manifests.has(dependency), 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo production Workspace依赖不存在:${dependency}`);
714
+ pending.push(dependency);
715
+ }
716
+ }
717
+ }
718
+
719
+ const expectedNames = SMARTGO_RUNTIME_DEPLOYS.map(({ selector }) => selector);
720
+ invariant(stableJson([...closure].sort()) === stableJson([...expectedNames].sort()),
721
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', 'SmartGo production Workspace闭包必须精确为六个Package与三个Service');
722
+ for (const { selector, sourceDirectory } of SMARTGO_RUNTIME_DEPLOYS) {
723
+ invariant(manifests.get(selector)?.sourceDirectory === sourceDirectory,
724
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CLOSURE_INVALID', `SmartGo production Workspace入口目录漂移:${selector}`);
725
+ }
726
+ }
727
+
728
+ function frozenArrayStringValues(source, name) {
729
+ const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\);`, 'u'));
730
+ invariant(match, 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', `SmartGo sanitizer缺少冻结集合:${name}`);
731
+ return [...match[1].matchAll(/"(?:[^"\\]|\\.)*"/gu)].map(([value]) => JSON.parse(value));
732
+ }
733
+
734
+ export function validateSmartGoRuntimeSanitizerContract(sanitizer) {
735
+ const expectedEntryValues = SMARTGO_RUNTIME_DEPLOYS.flatMap(({ sourceDirectory, selector }) => [sourceDirectory, selector]);
736
+ invariant(stableJson(frozenArrayStringValues(sanitizer, 'runtimeEntries')) === stableJson(expectedEntryValues),
737
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer必须验证九个production入口的精确目录与Package名');
738
+ invariant(stableJson(frozenArrayStringValues(sanitizer, 'expectedTypeScriptLinks')) === stableJson(SMARTGO_TYPESCRIPT_LINKS),
739
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer的Prisma peer TypeScript symlink集合无效');
740
+ invariant(stableJson(frozenArrayStringValues(sanitizer, 'expectedTypeScriptShims')) === stableJson(SMARTGO_TYPESCRIPT_SHIMS),
741
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer的TypeScript executable shim集合无效');
742
+
743
+ const requiredEvidence = [
744
+ 'const TYPESCRIPT_VERSION = "6.0.3";',
745
+ 'const expectedRoot = resolve(options.expectedRoot ?? "/runtime");',
746
+ 'if (requestedRoot !== expectedRoot)',
747
+ 'if (root !== await realpath(expectedRoot))',
748
+ 'await assertRuntimeEntries(root);',
749
+ 'if (!dist?.isDirectory())',
750
+ 'if (await lstat(resolve(root, "services", service, "src")).catch(() => null))',
751
+ 'const artifacts = await collectTypeScriptArtifacts(root, typeScriptRoot);',
752
+ 'sameSet(artifacts.links, expectedTypeScriptLinks, "TypeScript symlink");',
753
+ 'sameSet(artifacts.shims, expectedTypeScriptShims, "TypeScript shim");',
754
+ 'for (const relativePath of [...artifacts.links, ...artifacts.shims]) await rm(resolve(root, relativePath));',
755
+ 'await rm(typeScriptRoot, { recursive: true });',
756
+ 'if (entry.isSymbolicLink() && !(await realpath(path).catch(() => null))) broken.push(relativePath);',
757
+ 'if (forbidden.length) throw new Error(',
758
+ 'if (broken.length) throw new Error(',
759
+ 'await verifySanitizedClosure(root);',
760
+ 'export async function verifyRuntimeClosure(runtimeRoot, options = {})',
761
+ 'await verifyRuntimeClosure(root, { expectedRoot: root });',
762
+ 'const result = await sanitizeRuntimeClosure(process.argv[2] ?? "");',
763
+ ];
764
+ invariant(requiredEvidence.every((snippet) => sanitizer.includes(snippet)),
765
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer缺少精确根、入口、删除、断链或开发工具失败证明');
766
+ for (const marker of ['typescript', 'vitest', '@types', '@aipt\\/testkit', 'tsc', 'tsserver']) {
767
+ invariant(sanitizer.includes(marker), 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', `SmartGo sanitizer缺少开发工具拒绝标记:${marker}`);
768
+ }
769
+ invariant((sanitizer.match(/\bawait\s+rm\(/gu) ?? []).length === 2 && !/\brm\([^\n;]*\{[^}]*force\s*:\s*true/u.test(sanitizer),
770
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer只允许精确删除冻结link/shim与TypeScript Package');
771
+ invariant(!/(?:node:child_process|\bexec(?:File)?\(|\bspawn\(|\bfetch\(|https?:\/\/)/u.test(sanitizer),
772
+ 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo sanitizer不得执行子进程或网络访问');
773
+ }
774
+
775
+ export function validateSmartGoProductionClosureContract(dockerfile, sourceRoot) {
776
+ deriveSmartGoProductionClosure(sourceRoot);
777
+ const sanitizerPath = path.join(sourceRoot, 'deploy', 'sanitize-runtime-closure.mjs');
778
+ invariant(fs.existsSync(sanitizerPath), 'IDP_RELEASE_SMARTGO_SANITIZER_INVALID', 'SmartGo缺少受控runtime closure sanitizer');
779
+ validateSmartGoRuntimeSanitizerContract(fs.readFileSync(sanitizerPath, 'utf8'));
780
+ const manifestPath = path.join(sourceRoot, 'packages', 'persistence', 'package.json');
781
+ let manifest;
782
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); }
783
+ catch { throw new IdpError('IDP_RELEASE_SMARTGO_PERSISTENCE_MANIFEST_INVALID', 'SmartGo Persistence Manifest不是有效JSON'); }
784
+ invariant(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'IDP_RELEASE_SMARTGO_PERSISTENCE_MANIFEST_INVALID', 'SmartGo Persistence Manifest必须是对象');
785
+ invariant(
786
+ manifest.dependencies?.prisma === '6.19.3' && manifest.dependencies?.tsx === '4.20.6',
787
+ 'IDP_RELEASE_SMARTGO_PERSISTENCE_RUNTIME_DEPENDENCY_INVALID',
788
+ 'SmartGo Persistence必须把固定版本prisma与tsx声明为生产依赖',
789
+ );
790
+ invariant(
791
+ manifest.devDependencies?.prisma === undefined && manifest.devDependencies?.tsx === undefined,
792
+ 'IDP_RELEASE_SMARTGO_PERSISTENCE_RUNTIME_DEPENDENCY_INVALID',
793
+ 'SmartGo Persistence不得把prisma或tsx保留在devDependencies',
794
+ );
795
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
796
+ const dependencies = manifest[section] ?? {};
797
+ invariant(dependencies && typeof dependencies === 'object' && !Array.isArray(dependencies), 'IDP_RELEASE_SMARTGO_PERSISTENCE_MANIFEST_INVALID', `SmartGo Persistence ${section}必须是对象`);
798
+ for (const name of Object.keys(dependencies)) {
799
+ invariant(
800
+ name !== 'vitest' && name !== 'typescript' && !name.startsWith('@types/') && name !== '@aipt/testkit' && !name.startsWith('@bench/'),
801
+ 'IDP_RELEASE_SMARTGO_PERSISTENCE_DEV_DEPENDENCY_FORBIDDEN',
802
+ `SmartGo Persistence生产闭包不得声明开发工具:${name}`,
803
+ );
804
+ }
805
+ }
806
+
807
+ const instructions = dockerfileLogicalInstructions(dockerfile);
808
+ const runs = instructions.filter((instruction) => /^RUN\s+/u.test(instruction));
809
+ invariant(
810
+ !runs.some((instruction) => /(?:^|[\s;&|])pnpm\s+view\b/u.test(instruction)),
811
+ 'IDP_RELEASE_SMARTGO_METADATA_PRIME_FORBIDDEN',
812
+ 'SmartGo production-only runtime闭包不得保留pnpm view或metadata-prime',
813
+ );
814
+ const installIndexes = runs.map((instruction, index) => ({ index, instruction }))
815
+ .filter(({ instruction }) => /(?:^|[\s;&|])pnpm\s+install\b/u.test(instruction));
816
+ invariant(installIndexes.length === 1, 'IDP_RELEASE_SMARTGO_INSTALL_BOUNDARY_INVALID', 'SmartGo Dockerfile必须且只能有一个依赖安装RUN');
817
+ const { index: installIndex, instruction: installInstruction } = installIndexes[0];
818
+ invariant(
819
+ stableJson(splitRunInstruction(installInstruction).mounts) === stableJson([SMARTGO_PNPM_CACHE_MOUNT, SMARTGO_NPMRC_SECRET_MOUNT]),
820
+ 'IDP_RELEASE_SMARTGO_INSTALL_MOUNT_INVALID',
821
+ 'SmartGo依赖安装RUN只能挂载固定pnpm store cache与受控npmrc Secret',
822
+ );
823
+ const installSegments = shellInstructionSegments(installInstruction);
824
+ invariant(
825
+ stableJson(installSegments.slice(-SMARTGO_PERSISTENCE_PREFLIGHT_SEGMENTS.length)) === stableJson(SMARTGO_PERSISTENCE_PREFLIGHT_SEGMENTS),
826
+ 'IDP_RELEASE_SMARTGO_CLOSURE_PREFLIGHT_INVALID',
827
+ 'SmartGo frozen install后必须立即执行并清理唯一的Persistence production closure-preflight',
828
+ );
829
+ invariant(
830
+ splitRunInstruction(installInstruction).command === [...SMARTGO_INSTALL_SETUP_SEGMENTS, ...SMARTGO_PERSISTENCE_PREFLIGHT_SEGMENTS].join(' && '),
831
+ 'IDP_RELEASE_SMARTGO_INSTALL_COMMAND_INVALID',
832
+ 'SmartGo install Secret窗口只能执行批准的配置、frozen install、closure-preflight与同RUN清理',
833
+ );
834
+ const preflightMentions = runs.flatMap((instruction) => shellInstructionSegments(instruction))
835
+ .filter((segment) => segment.includes(SMARTGO_PERSISTENCE_PREFLIGHT_DIRECTORY));
836
+ invariant(
837
+ stableJson(preflightMentions) === stableJson(SMARTGO_PERSISTENCE_PREFLIGHT_SEGMENTS.filter((segment) => segment.includes(SMARTGO_PERSISTENCE_PREFLIGHT_DIRECTORY))),
838
+ 'IDP_RELEASE_SMARTGO_CLOSURE_PREFLIGHT_INVALID',
839
+ 'SmartGo closure-preflight临时目录只能由固定deploy与同RUN清理命令使用',
840
+ );
841
+ invariant(
842
+ !runs.slice(installIndex + 1).some(hasSecretMount),
843
+ 'IDP_RELEASE_SMARTGO_POST_INSTALL_SECRET_FORBIDDEN',
844
+ 'SmartGo依赖安装之后不得再挂载任何Secret',
845
+ );
846
+ invariant(
847
+ !runs.slice(installIndex + 1).some(hasDependencyNetworkCommand),
848
+ 'IDP_RELEASE_SMARTGO_POST_INSTALL_NETWORK_FORBIDDEN',
849
+ 'SmartGo依赖安装之后不得执行网络依赖或下载命令',
850
+ );
851
+ const runtimeStageIndex = instructions.findIndex((instruction) => /^FROM\s+\$\{NODE_IMAGE\}\s+AS\s+runtime$/u.test(instruction));
852
+ invariant(runtimeStageIndex >= 0, 'IDP_RELEASE_SMARTGO_RUNTIME_STAGE_MISSING', 'SmartGo Dockerfile缺少受控runtime stage');
853
+ const runtimeCopies = instructions.slice(runtimeStageIndex + 1).filter((instruction) => /^COPY\s+/u.test(instruction));
854
+ const copySources = runtimeCopies.flatMap(dockerCopySources);
855
+ for (const source of copySources) {
856
+ invariant(
857
+ source !== '/workspace' && !source.startsWith('/workspace/packages') &&
858
+ source !== '/workspace/deploy/sanitize-runtime-closure.mjs' &&
859
+ !/(?:^|\/)node_modules\/(?:vitest|typescript|@types|@aipt\/testkit)(?:\/|$)/u.test(source),
860
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEV_TOOL_COPY_FORBIDDEN',
861
+ 'SmartGo runtime不得复制Package源码根、整个Workspace、sanitizer或已知开发工具路径',
862
+ );
863
+ }
864
+ invariant(
865
+ stableJson(copySources) === stableJson(SMARTGO_RUNTIME_COPY_SOURCES),
866
+ 'IDP_RELEASE_SMARTGO_RUNTIME_COPY_INVALID',
867
+ 'SmartGo runtime COPY必须与批准的production deploy、standalone与最小入口文件白名单精确相等',
868
+ );
869
+ }
870
+
871
+ export function validateSmartGoRuntimePackagingContract(dockerfile) {
872
+ validateSmartGoPostInstallNetworkContract(dockerfile);
873
+ const instructions = dockerfileLogicalInstructions(dockerfile);
874
+ const runs = instructions.filter((instruction) => /^RUN\s+/u.test(instruction));
875
+ const runtimeRuns = runs.filter((instruction) => !/(?:^|[\s;&|])pnpm\s+install\b/u.test(instruction));
876
+ const deploySegments = runtimeRuns.flatMap((instruction) => shellInstructionSegments(instruction)
877
+ .filter((segment) => /\bpnpm\s+[^;&|]*\bdeploy\b/u.test(segment)));
878
+ invariant(
879
+ deploySegments.length > 0,
880
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_NOT_OFFLINE',
881
+ 'SmartGo Dockerfile必须使用pnpm deploy生成runtime产物',
882
+ );
883
+ invariant(
884
+ deploySegments.every((segment) => /(?:^|\s)--offline(?=\s|$)/u.test(segment)),
885
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_NOT_OFFLINE',
886
+ 'SmartGo每个runtime pnpm deploy必须显式使用--offline',
887
+ );
888
+ for (const segment of deploySegments) {
889
+ const tokens = segment.split(/\s+/u);
890
+ invariant(
891
+ tokens[0] === 'pnpm' && tokens[1] === '--offline' &&
892
+ SMARTGO_OFFLINE_DEPLOY_ROUTE_ARGS.every((expected, index) => tokens[index + 2] === expected),
893
+ 'IDP_RELEASE_SMARTGO_RUNTIME_REGISTRY_INVALID',
894
+ 'SmartGo每个runtime pnpm deploy必须使用固定顺序的公共与私有Registry路由',
895
+ );
896
+ const configured = tokens.filter((token) => token.startsWith('--config.'));
897
+ invariant(
898
+ stableJson(configured) === stableJson(SMARTGO_OFFLINE_DEPLOY_ROUTE_ARGS),
899
+ 'IDP_RELEASE_SMARTGO_RUNTIME_REGISTRY_INVALID',
900
+ 'SmartGo runtime pnpm deploy只允许三条固定Registry路由且各出现一次',
901
+ );
902
+ invariant(
903
+ !tokens.some((token) =>
904
+ /^--(?:registry|proxy|https-proxy|userconfig|_authToken|_auth|_password|username|password|token)(?:=|$)/iu.test(token) ||
905
+ /^(?:NPM_CONFIG_[A-Z0-9_]*|npm_config_[a-z0-9_]*|NODE_AUTH_TOKEN|NPM_TOKEN|NPM_AUTH_TOKEN)=/u.test(token)),
906
+ 'IDP_RELEASE_SMARTGO_RUNTIME_CREDENTIAL_FORBIDDEN',
907
+ 'SmartGo runtime pnpm deploy不得夹带Registry、Proxy或凭据覆盖',
908
+ );
909
+ }
910
+ const expectedDeploySegments = SMARTGO_RUNTIME_DEPLOYS.map(({ selector, target }) =>
911
+ `pnpm --offline ${SMARTGO_OFFLINE_DEPLOY_ROUTE_ARGS.join(' ')} --filter ${selector} --prod deploy --legacy ${target}`);
912
+ invariant(
913
+ stableJson(deploySegments) === stableJson(expectedDeploySegments),
914
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_SET_INVALID',
915
+ 'SmartGo必须按固定顺序为六个Package与三个Service生成九个production-only runtime闭包',
916
+ );
917
+ const deployRuns = runtimeRuns.filter((instruction) => shellInstructionSegments(instruction)
918
+ .some((segment) => /\bpnpm\s+[^;&|]*\bdeploy\b/u.test(segment)));
919
+ invariant(deployRuns.length === 1, 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_RUN_INVALID', 'SmartGo runtime deploy必须位于独立的单一RUN');
920
+ const deployRun = splitRunInstruction(deployRuns[0]);
921
+ invariant(
922
+ stableJson(deployRun.mounts) === stableJson([SMARTGO_PNPM_CACHE_MOUNT]),
923
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_MOUNT_INVALID',
924
+ 'SmartGo runtime deploy RUN只能挂载固定pnpm store cache',
925
+ );
926
+ invariant(
927
+ !/\bpnpm\s+[^;&|]*\bbuild\b/u.test(deployRun.command) && !/\bpackage-next-standalone\.mjs\b/u.test(deployRun.command),
928
+ 'IDP_RELEASE_SMARTGO_RUNTIME_DEPLOY_RUN_INVALID',
929
+ 'SmartGo build/package不得与runtime deploy混入同一RUN',
930
+ );
931
+ invariant(
932
+ stableJson(shellInstructionSegments(deployRuns[0])) === stableJson([...expectedDeploySegments, ...SMARTGO_RUNTIME_POST_DEPLOY_SEGMENTS]),
933
+ 'IDP_RELEASE_SMARTGO_RUNTIME_ASSEMBLY_PROOF_INVALID',
934
+ 'SmartGo九个runtime deploy后必须按固定顺序清理Service源码、生成runtime Prisma Client、执行sanitizer并验证Prisma、tsx与Persistence模块',
935
+ );
936
+ const allRuntimeSegments = runtimeRuns.flatMap(shellInstructionSegments);
937
+ for (const expected of SMARTGO_RUNTIME_POST_DEPLOY_SEGMENTS.slice(2)) {
938
+ invariant(allRuntimeSegments.filter((segment) => segment === expected).length === 1,
939
+ 'IDP_RELEASE_SMARTGO_RUNTIME_ASSEMBLY_PROOF_INVALID', 'SmartGo runtime assembly证明命令必须且只能出现一次');
940
+ }
941
+ const packageBoundary = runs.findIndex((instruction) =>
942
+ /\bpnpm\s+[^;&|]*\bbuild\b/u.test(instruction) || /\bpackage-next-standalone\.mjs\b/u.test(instruction));
943
+ invariant(packageBoundary >= 0, 'IDP_RELEASE_SMARTGO_RUNTIME_PACKAGE_MISSING', 'SmartGo Dockerfile缺少可验证的build/package阶段');
944
+ invariant(
945
+ !runs.slice(packageBoundary).some(hasNpmrcSecretMount),
946
+ 'IDP_RELEASE_SMARTGO_POST_BUILD_SECRET_FORBIDDEN',
947
+ 'SmartGo build/package及其后续RUN不得重新挂载npmrc Secret',
948
+ );
949
+ }
950
+
951
+ function runPrebuild(contract, sourceRoot, runner) {
952
+ if (contract === 'none') return;
953
+ if (contract === 'smartgo-managed-v1') {
954
+ const dockerfile = fs.readFileSync(path.join(sourceRoot, 'Dockerfile'), 'utf8');
955
+ invariant(/^ARG NODE_IMAGE=/mu.test(dockerfile) && (dockerfile.match(/^FROM \$\{NODE_IMAGE\}/gmu) ?? []).length >= 2, 'IDP_RELEASE_SMARTGO_BASE_IMAGE_UNPINNED', 'SmartGo Dockerfile必须让build/runtime都通过受控NODE_IMAGE构建参数固定基础镜像');
956
+ validateSmartGoCorepackContract(dockerfile);
957
+ validateSmartGoProductionClosureContract(dockerfile, sourceRoot);
958
+ validateSmartGoRuntimePackagingContract(dockerfile);
959
+ const dockerignore = fs.readFileSync(path.join(sourceRoot, '.dockerignore'), 'utf8');
960
+ invariant(!/^apps\/studio-poc\s*$/mu.test(dockerignore), 'IDP_RELEASE_SMARTGO_STUDIO_EXCLUDED', 'SmartGo .dockerignore不得排除有效Studio App');
961
+ return;
962
+ }
963
+ invariant(contract === 'portal-backstage-v1', 'IDP_RELEASE_PREBUILD_UNKNOWN', `未知受控Prebuild合同:${contract}`);
964
+ for (const args of [
965
+ ['yarn', 'install', '--immutable'],
966
+ ['yarn', 'tsc'],
967
+ ['yarn', 'build:backend'],
968
+ ]) runner('corepack', args, { cwd: sourceRoot });
969
+ }
970
+
971
+ function buildMetadataFile(configRoot, component, planDigest) {
972
+ invariant(/^[a-z][a-z0-9-]*$/u.test(component), 'IDP_RELEASE_COMPONENT_INVALID', '构建组件名称无效');
973
+ const relativeDirectory = path.join('releases', 'oci-staging');
974
+ const directory = resolveContained(configRoot, relativeDirectory, 'Buildx metadata staging目录');
975
+ ensurePrivateDirectory(directory, 'Buildx metadata staging目录');
976
+ const relativeCandidate = path.join(
977
+ relativeDirectory,
978
+ `${planDigest.slice(7, 19)}.${component}.${process.pid}.${crypto.randomUUID()}.metadata.json`,
979
+ );
980
+ const candidate = resolveContained(configRoot, relativeCandidate, 'Buildx metadata文件');
981
+ invariant(path.dirname(candidate) === directory, 'IDP_RELEASE_BUILD_METADATA_PATH_INVALID', 'Buildx metadata文件必须位于安全staging目录');
982
+ createExclusiveFile(candidate, '', 0o600);
983
+ const stat = assertSafeRegularFile(candidate, 0o600, { role: 'Buildx metadata文件' });
984
+ invariant((stat.mode & 0o777) === 0o600, 'IDP_RELEASE_BUILD_METADATA_MODE_INVALID', 'Buildx metadata文件权限必须是0600');
985
+ return candidate;
986
+ }
987
+
988
+ function readBuildMetadata(candidate) {
989
+ let descriptor;
990
+ try { descriptor = fs.openSync(candidate, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); }
991
+ catch { throw new IdpError('IDP_RELEASE_BUILD_METADATA_UNSAFE', 'Buildx metadata文件无法以禁止链接的方式读取'); }
992
+ let text;
993
+ try {
994
+ let stat = fs.fstatSync(descriptor);
995
+ invariant(stat.isFile() && stat.nlink === 1, 'IDP_RELEASE_BUILD_METADATA_UNSAFE', 'Buildx metadata必须是无链接的普通文件');
996
+ if ((stat.mode & 0o777) !== 0o600) {
997
+ fs.fchmodSync(descriptor, 0o600);
998
+ stat = fs.fstatSync(descriptor);
999
+ }
1000
+ invariant((stat.mode & 0o777) === 0o600, 'IDP_RELEASE_BUILD_METADATA_MODE_INVALID', 'Buildx metadata文件权限必须是0600');
1001
+ invariant(stat.size > 0 && stat.size <= 1024 * 1024, 'IDP_RELEASE_BUILD_METADATA_INVALID', 'Buildx metadata文件大小无效');
1002
+ text = fs.readFileSync(descriptor, 'utf8');
1003
+ } finally { fs.closeSync(descriptor); }
1004
+ let document;
1005
+ try { document = JSON.parse(text); }
1006
+ catch { throw new IdpError('IDP_RELEASE_BUILD_METADATA_INVALID', 'Buildx metadata不是有效JSON'); }
1007
+ invariant(document && typeof document === 'object' && !Array.isArray(document), 'IDP_RELEASE_BUILD_METADATA_INVALID', 'Buildx metadata必须是对象');
1008
+ const imageDigest = document['containerimage.digest'];
1009
+ invariant(DIGEST_PATTERN.test(imageDigest ?? ''), 'IDP_RELEASE_BUILD_METADATA_DIGEST_INVALID', 'Buildx metadata缺少有效的containerimage.digest');
1010
+ return { imageDigest };
1011
+ }
1012
+
1013
+ function invalidSmartGoNpmrc(lineNumber, reason) {
1014
+ throw new IdpError('IDP_RELEASE_BUILD_NPMRC_INVALID', `SmartGo npmrc第${lineNumber}行${reason}`);
1015
+ }
1016
+
1017
+ function parseSmartGoRegistry(value, lineNumber) {
1018
+ if (!/^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):4873\/$/u.test(value)) {
1019
+ invalidSmartGoNpmrc(lineNumber, '必须精确使用HTTP回环Registry的4873端口和根路径');
1020
+ }
1021
+ }
1022
+
1023
+ function transformSmartGoNpmrc(original) {
1024
+ invariant(original.length > 0 && !original.includes('\0'), 'IDP_RELEASE_BUILD_SECRET_INVALID', 'SmartGo npmrc必须是非空文本');
1025
+ const requiredRegistryKeys = ['registry', '@aipt:registry', '@bench:registry'];
1026
+ const seen = new Set();
1027
+ const transformed = original.split(/\r?\n/u).map((line, offset) => {
1028
+ const lineNumber = offset + 1;
1029
+ const trimmed = line.trim();
1030
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) return line;
1031
+ const separator = trimmed.indexOf('=');
1032
+ if (separator <= 0) invalidSmartGoNpmrc(lineNumber, '不在允许键白名单');
1033
+ const key = trimmed.slice(0, separator).trim();
1034
+ const value = trimmed.slice(separator + 1).trim();
1035
+ if (!value) invalidSmartGoNpmrc(lineNumber, '值不得为空');
1036
+ if (requiredRegistryKeys.includes(key)) {
1037
+ if (seen.has(key)) invalidSmartGoNpmrc(lineNumber, '不得重复声明');
1038
+ seen.add(key);
1039
+ parseSmartGoRegistry(value, lineNumber);
1040
+ const alias = key === 'registry' ? SMARTGO_PUBLIC_REGISTRY_ALIAS : SMARTGO_PRIVATE_REGISTRY_ALIAS;
1041
+ return `${key}=http://${alias}:4873/`;
1042
+ }
1043
+ const token = key.match(/^\/\/(?:127\.0\.0\.1|localhost|\[::1\]):4873\/:_authToken$/u);
1044
+ if (token) {
1045
+ if (seen.has('authority:_authToken')) invalidSmartGoNpmrc(lineNumber, '包含重复Token authority');
1046
+ if (/\s/u.test(value)) invalidSmartGoNpmrc(lineNumber, 'Token必须是非空单行值');
1047
+ seen.add('authority:_authToken');
1048
+ return `//${SMARTGO_PRIVATE_REGISTRY_ALIAS}:4873/:_authToken=${value}`;
1049
+ }
1050
+ invalidSmartGoNpmrc(lineNumber, '不在允许键白名单');
1051
+ });
1052
+ for (const key of [...requiredRegistryKeys, 'authority:_authToken']) {
1053
+ invariant(seen.has(key), 'IDP_RELEASE_BUILD_NPMRC_INVALID', `SmartGo npmrc缺少必需白名单键:${key}`);
1054
+ }
1055
+ return transformed.join('\n');
1056
+ }
1057
+
1058
+ function stageSmartGoNpmrc(source, runtimeRoot) {
1059
+ ensurePrivateDirectory(runtimeRoot, 'Runtime目录');
1060
+ const temporaryRoot = path.join(runtimeRoot, 'tmp');
1061
+ ensurePrivateDirectory(temporaryRoot, 'Runtime tmp目录');
1062
+ const original = fs.readFileSync(source, 'utf8');
1063
+ const transformed = transformSmartGoNpmrc(original);
1064
+ const directory = path.join(temporaryRoot, `smartgo-build-secret-${process.pid}-${crypto.randomUUID()}`);
1065
+ fs.mkdirSync(directory, { mode: 0o700 });
1066
+ const candidate = path.join(directory, 'npmrc');
1067
+ try { createExclusiveFile(candidate, transformed, 0o600); }
1068
+ catch (error) {
1069
+ try { fs.rmSync(directory, { recursive: true, force: true }); } catch {}
1070
+ throw error;
1071
+ }
1072
+ return { candidate, directory };
1073
+ }
1074
+
1075
+ function verifyOrasToolVersion(tool, runner) {
1076
+ if (!tool) return null;
1077
+ let result;
1078
+ try { result = runner(tool.binary, ['version'], { capture: true }); }
1079
+ catch (error) {
1080
+ throw new IdpError('IDP_RELEASE_ORAS_VERSION_PROBE_FAILED', '无法验证Config Dir中ORAS工具的固定版本', { originalCode: error?.code });
1081
+ }
1082
+ const output = String(result?.stdout ?? '');
1083
+ const matches = [...output.matchAll(/^Version:\s*([^\s]+)\s*$/gmu)].map((match) => match[1]);
1084
+ invariant(matches.length === 1 && matches[0] === tool.version, 'IDP_RELEASE_ORAS_VERSION_MISMATCH', 'ORAS工具实际版本与IDP_ORAS_VERSION不一致');
1085
+ return tool;
1086
+ }
1087
+
1088
+ function verifyOciLayoutBuilder(name, runner) {
1089
+ let inspected;
1090
+ try {
1091
+ inspected = runner('docker', ['buildx', 'inspect', name], { capture: true, maxBuffer: 4 * 1024 * 1024 });
1092
+ } catch (error) {
1093
+ throw new IdpError('IDP_RELEASE_OCI_BUILDER_INSPECT_FAILED', '无法验证已配置的OCI Layout Builder', { originalCode: error?.code });
1094
+ }
1095
+ const output = String(inspected?.stdout ?? '').replaceAll('\r\n', '\n');
1096
+ const nodesMarker = /^Nodes:\s*$/mu.exec(output);
1097
+ invariant(nodesMarker && typeof nodesMarker.index === 'number', 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', 'Buildx Builder inspect结果缺少Nodes边界');
1098
+ const header = output.slice(0, nodesMarker.index);
1099
+ const nodesText = output.slice(nodesMarker.index + nodesMarker[0].length);
1100
+ const names = [...header.matchAll(/^Name:\s+(\S+)\s*$/gmu)].map((match) => match[1]);
1101
+ const drivers = [...header.matchAll(/^Driver:\s+(\S+)\s*$/gmu)].map((match) => match[1]);
1102
+ invariant(names.length === 1 && names[0] === name, 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', 'Buildx Builder inspect名称与配置不一致');
1103
+ invariant(drivers.length === 1, 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', 'Buildx Builder inspect缺少唯一driver');
1104
+ const driver = drivers[0];
1105
+ invariant(
1106
+ ['docker-container', 'kubernetes', 'remote'].includes(driver),
1107
+ 'IDP_RELEASE_OCI_BUILDER_DRIVER_UNSUPPORTED',
1108
+ 'OCI Layout Builder driver不在批准白名单',
1109
+ );
1110
+ const nodeStarts = [...nodesText.matchAll(/^Name:\s+\S+\s*$/gmu)].map((match) => match.index);
1111
+ invariant(nodeStarts.length > 0, 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', 'Buildx Builder inspect没有节点');
1112
+ const runningPlatforms = new Set();
1113
+ let runningNodes = 0;
1114
+ for (const [index, start] of nodeStarts.entries()) {
1115
+ const end = nodeStarts[index + 1] ?? nodesText.length;
1116
+ const node = nodesText.slice(start, end);
1117
+ const statuses = [...node.matchAll(/^Status:\s+(\S+)\s*$/gmu)].map((match) => match[1]);
1118
+ const platforms = [...node.matchAll(/^Platforms:\s+(.+)\s*$/gmu)].map((match) => match[1]);
1119
+ invariant(statuses.length === 1, 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', 'Buildx Builder节点缺少唯一运行状态');
1120
+ if (statuses[0] !== 'running') continue;
1121
+ runningNodes += 1;
1122
+ invariant(platforms.length === 1, 'IDP_RELEASE_OCI_BUILDER_INSPECT_INVALID', '运行中的Buildx Builder节点缺少平台集合');
1123
+ for (const platform of platforms[0].split(',').map((value) => value.trim().replace(/\*$/u, '')).filter(Boolean)) runningPlatforms.add(platform);
1124
+ }
1125
+ invariant(runningNodes > 0, 'IDP_RELEASE_OCI_BUILDER_NOT_RUNNING', 'OCI Layout Builder没有运行中的节点');
1126
+ invariant(
1127
+ REQUIRED_PLATFORMS.every((platform) => runningPlatforms.has(platform)),
1128
+ 'IDP_RELEASE_OCI_BUILDER_PLATFORM_MISSING',
1129
+ 'OCI Layout Builder必须同时支持linux/amd64与linux/arm64',
1130
+ );
1131
+ return Object.freeze({ name, driver });
1132
+ }
1133
+
1134
+ function revalidateOrasTool(configRoot, expected) {
1135
+ let envFile;
1136
+ let env;
1137
+ try {
1138
+ envFile = resolveContained(configRoot, '.env', '根配置');
1139
+ assertSafeRegularFile(envFile, 0o600, { allowEmpty: false, role: '根.env' });
1140
+ env = parseEnv(fs.readFileSync(envFile, 'utf8'), envFile);
1141
+ } catch {
1142
+ throw new IdpError('IDP_RELEASE_ORAS_CONFIG_CHANGED_DURING_BUILD', '根配置在构建期间变为不安全状态,拒绝回退推送');
1143
+ }
1144
+ const observed = resolveConfiguredOrasTool(configRoot, env);
1145
+ invariant(
1146
+ observed && stableJson(observed) === stableJson(expected),
1147
+ 'IDP_RELEASE_ORAS_TOOL_CHANGED_DURING_BUILD',
1148
+ 'ORAS工具固定事实在构建期间发生变化,拒绝回退推送',
1149
+ );
1150
+ return observed;
1151
+ }
1152
+
1153
+ function createOciLayoutDirectory(runtimeRoot, component, planDigest) {
1154
+ invariant(!/[,\r\n]/u.test(runtimeRoot), 'IDP_RELEASE_OCI_LAYOUT_PATH_INVALID', 'OCI Layout Runtime Root不得包含逗号或换行');
1155
+ const temporaryRoot = path.join(runtimeRoot, 'tmp');
1156
+ const prefix = path.join(temporaryRoot, `oci-layout-${planDigest.slice(7, 19)}-${component}-`);
1157
+ let candidate;
1158
+ let stat;
1159
+ try {
1160
+ ensurePrivateDirectory(runtimeRoot, 'Runtime目录');
1161
+ ensurePrivateDirectory(temporaryRoot, 'Runtime tmp目录');
1162
+ candidate = fs.mkdtempSync(prefix);
1163
+ fs.chmodSync(candidate, 0o700);
1164
+ stat = assertSafeDirectory(candidate, 0o700, { role: 'OCI Layout staging目录' });
1165
+ } catch {
1166
+ throw new IdpError('IDP_RELEASE_OCI_LAYOUT_STAGING_FAILED', '无法创建安全的OCI Layout staging目录');
1167
+ }
1168
+ invariant((stat.mode & 0o777) === 0o700, 'IDP_RELEASE_OCI_LAYOUT_MODE_INVALID', 'OCI Layout staging目录权限必须是0700');
1169
+ return candidate;
1170
+ }
1171
+
1172
+ function inspectOciLayout(directory, releaseTag, expectedDigest) {
1173
+ let root;
1174
+ try { root = assertSafeDirectory(directory, 0o700, { role: 'OCI Layout staging目录' }); }
1175
+ catch { throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout staging目录不安全'); }
1176
+ invariant((root.mode & 0o777) === 0o700, 'IDP_RELEASE_OCI_LAYOUT_MODE_INVALID', 'OCI Layout staging目录权限必须是0700');
1177
+ let topLevel;
1178
+ try { topLevel = fs.readdirSync(directory).sort(); }
1179
+ catch { throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout顶层文件集合无法安全读取'); }
1180
+ invariant(stableJson(topLevel) === stableJson(['blobs', 'index.json', 'oci-layout']), 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout顶层文件集合无效');
1181
+ const layoutFile = path.join(directory, 'oci-layout');
1182
+ const indexFile = path.join(directory, 'index.json');
1183
+ const blobsDirectory = path.join(directory, 'blobs');
1184
+ let indexStat;
1185
+ try {
1186
+ assertSafeRegularFile(layoutFile, 0o644, { allowEmpty: false, role: 'OCI Layout版本文件' });
1187
+ indexStat = assertSafeRegularFile(indexFile, 0o644, { allowEmpty: false, role: 'OCI Layout Index' });
1188
+ assertSafeDirectory(blobsDirectory, 0o755, { role: 'OCI Layout blobs目录' });
1189
+ invariant(stableJson(fs.readdirSync(blobsDirectory).sort()) === stableJson(['sha256']), 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout blobs只能使用sha256目录');
1190
+ const audit = (candidate) => {
1191
+ for (const name of fs.readdirSync(candidate)) {
1192
+ const child = path.join(candidate, name);
1193
+ const stat = fs.lstatSync(child);
1194
+ invariant(!stat.isSymbolicLink(), 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout不得包含符号链接');
1195
+ if (stat.isDirectory()) audit(child);
1196
+ else invariant(stat.isFile() && stat.nlink === 1, 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout只能包含单硬链接普通文件');
1197
+ }
1198
+ };
1199
+ audit(blobsDirectory);
1200
+ } catch (error) {
1201
+ if (error instanceof IdpError && error.code === 'IDP_RELEASE_OCI_LAYOUT_INVALID') throw error;
1202
+ throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout文件边界不安全');
1203
+ }
1204
+ invariant(indexStat.size <= 1024 * 1024, 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout Index不能超过1 MiB');
1205
+ let layout;
1206
+ let index;
1207
+ try {
1208
+ layout = JSON.parse(fs.readFileSync(layoutFile, 'utf8'));
1209
+ index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
1210
+ } catch {
1211
+ throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout元数据不是有效JSON');
1212
+ }
1213
+ invariant(
1214
+ stableJson(layout) === stableJson({ imageLayoutVersion: '1.0.0' }),
1215
+ 'IDP_RELEASE_OCI_LAYOUT_INVALID',
1216
+ 'OCI Layout版本合同无效',
1217
+ );
1218
+ invariant(index?.schemaVersion === 2 && Array.isArray(index.manifests), 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout Index合同无效');
1219
+ const descriptors = index.manifests.filter((descriptor) => descriptor?.annotations?.['org.opencontainers.image.ref.name'] === releaseTag);
1220
+ invariant(descriptors.length === 1, 'IDP_RELEASE_OCI_LAYOUT_REFERENCE_INVALID', 'OCI Layout必须且只能包含当前Release tag引用');
1221
+ invariant(descriptors[0].digest === expectedDigest, 'IDP_RELEASE_OCI_LAYOUT_DIGEST_MISMATCH', 'OCI Layout引用摘要与Buildx metadata不一致');
1222
+ invariant(Number.isSafeInteger(descriptors[0].size) && descriptors[0].size > 0, 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout引用大小无效');
1223
+ const descriptorBlob = path.join(directory, 'blobs', 'sha256', descriptors[0].digest.slice(7));
1224
+ let descriptorStat;
1225
+ try { descriptorStat = assertSafeRegularFile(descriptorBlob, 0o644, { allowEmpty: false, role: 'OCI Layout引用Blob' }); }
1226
+ catch { throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout引用Blob缺失或不安全'); }
1227
+ invariant(descriptorStat.size === descriptors[0].size, 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout引用Blob大小不一致');
1228
+ let descriptorDigest;
1229
+ try { descriptorDigest = hashFile(descriptorBlob); }
1230
+ catch { throw new IdpError('IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout引用Blob无法安全读取'); }
1231
+ invariant(descriptorDigest === descriptors[0].digest, 'IDP_RELEASE_OCI_LAYOUT_INVALID', 'OCI Layout引用Blob摘要不一致');
1232
+ }
1233
+
1234
+ function buildxArguments({ build, component, sourceRoot, tag, sourceDigest, planDigest, metadataFile, publication, builderName }) {
1235
+ const releaseTag = tag.slice(tag.lastIndexOf(':') + 1);
1236
+ const transport = publication.type === 'registry-push'
1237
+ ? ['--push']
1238
+ : ['--output', `type=oci,dest=${publication.directory},tar=false,name=${releaseTag}`];
1239
+ const selectedBuilder = builderName ? ['--builder', builderName] : [];
1240
+ const args = [
1241
+ 'buildx', 'build', ...selectedBuilder, '--platform', REQUIRED_PLATFORMS.join(','), '--pull', ...transport,
1242
+ '--provenance=mode=max', `--attest=type=sbom,generator=${SBOM_GENERATOR}`, '--file', path.resolve(sourceRoot, build.dockerfile), '--tag', tag,
1243
+ '--metadata-file', metadataFile,
1244
+ '--label', `org.opencontainers.image.version=${releaseTag}`,
1245
+ '--label', `org.opencontainers.image.revision=${sourceDigest}`,
1246
+ '--label', `dev.idp.release.plan=${planDigest}`,
1247
+ '--label', `dev.idp.release.component=${component}`,
1248
+ ];
1249
+ for (const [key, value] of Object.entries(build.buildArgs).sort(([left], [right]) => left.localeCompare(right))) args.push('--build-arg', `${key}=${value}`);
1250
+ if (component === 'smartgo') {
1251
+ args.push('--add-host', `${SMARTGO_PUBLIC_REGISTRY_ALIAS}=host-gateway`);
1252
+ args.push('--add-host', `${SMARTGO_PRIVATE_REGISTRY_ALIAS}=host-gateway`);
1253
+ }
1254
+ return args;
1255
+ }
1256
+
1257
+ function runBuildx({ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot, publication, builderName, secretArguments, runner }) {
1258
+ const metadataFile = buildMetadataFile(configRoot, component, planDigest);
1259
+ try {
1260
+ const args = buildxArguments({ build, component, sourceRoot, tag, sourceDigest, planDigest, metadataFile, publication, builderName });
1261
+ args.push(...secretArguments, path.resolve(sourceRoot, build.context));
1262
+ runner('docker', args);
1263
+ return readBuildMetadata(metadataFile);
1264
+ } finally {
1265
+ try { fs.unlinkSync(metadataFile); }
1266
+ catch (error) {
1267
+ if (fs.existsSync(metadataFile)) throw new IdpError('IDP_RELEASE_BUILD_METADATA_CLEANUP_FAILED', '无法清理Buildx metadata staging', { causeCode: error?.code });
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ function publishWithHostOciLayout({ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot, runtimeRoot, builderName, secretArguments, orasTool, runner }) {
1273
+ const directory = createOciLayoutDirectory(runtimeRoot, component, planDigest);
1274
+ let primaryError;
1275
+ try {
1276
+ let metadata;
1277
+ try {
1278
+ metadata = runBuildx({
1279
+ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot,
1280
+ publication: { type: 'oci-layout', directory }, builderName, secretArguments, runner,
1281
+ });
1282
+ } catch (error) {
1283
+ if (error?.code !== 'IDP_PROCESS_FAILED') throw error;
1284
+ throw new IdpError('IDP_RELEASE_OCI_LAYOUT_BUILD_FAILED', 'Buildx导出OCI Layout失败', { originalCode: error.code });
1285
+ }
1286
+ const releaseTag = tag.slice(tag.lastIndexOf(':') + 1);
1287
+ inspectOciLayout(directory, releaseTag, metadata.imageDigest);
1288
+ const verifiedTool = revalidateOrasTool(configRoot, orasTool);
1289
+ try {
1290
+ runner(verifiedTool.binary, ['cp', '--from-oci-layout', `${directory}:${releaseTag}`, tag, '--concurrency', '1'], { capture: true, maxBuffer: 16 * 1024 * 1024 });
1291
+ } catch (error) {
1292
+ throw new IdpError('IDP_RELEASE_ORAS_COPY_FAILED', 'ORAS单并发复制OCI Layout失败', { originalCode: error?.code });
1293
+ }
1294
+ return {
1295
+ imageDigest: metadata.imageDigest,
1296
+ evidence: {
1297
+ type: 'host-oci-layout-oras',
1298
+ layoutImageDigest: metadata.imageDigest,
1299
+ orasVersion: verifiedTool.version,
1300
+ orasBinaryDigest: verifiedTool.digest,
1301
+ },
1302
+ };
1303
+ } catch (error) {
1304
+ primaryError = error;
1305
+ throw error;
1306
+ } finally {
1307
+ try { fs.rmSync(directory, { recursive: true, force: false }); }
1308
+ catch (error) {
1309
+ throw new IdpError('IDP_RELEASE_OCI_LAYOUT_CLEANUP_FAILED', '无法清理OCI Layout staging,拒绝继续发布', { originalCode: primaryError?.code, causeCode: error?.code });
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ function buildAndPublish({ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot, runtimeRoot, builderName, orasTool, runner }) {
1315
+ if (orasTool) invariant(!/[,\r\n]/u.test(runtimeRoot), 'IDP_RELEASE_OCI_LAYOUT_PATH_INVALID', 'OCI Layout Runtime Root不得包含逗号或换行');
1316
+ runPrebuild(build.prebuildContract, sourceRoot, runner);
1317
+ const dockerfile = path.resolve(sourceRoot, build.dockerfile);
1318
+ const context = path.resolve(sourceRoot, build.context);
1319
+ invariant(fs.existsSync(dockerfile) && fs.lstatSync(dockerfile).isFile() && !fs.lstatSync(dockerfile).isSymbolicLink(), 'IDP_RELEASE_DOCKERFILE_MISSING', `${component} Dockerfile不存在或不安全`);
1320
+ invariant(fs.existsSync(context) && fs.lstatSync(context).isDirectory() && !fs.lstatSync(context).isSymbolicLink(), 'IDP_RELEASE_BUILD_CONTEXT_MISSING', `${component}构建Context不存在或不安全`);
1321
+ const stagedSecrets = [];
1322
+ const secretArguments = [];
1323
+ try {
1324
+ if (Object.keys(build.buildSecretFiles).length > 0) {
1325
+ const componentRoot = resolveContained(configRoot, `components/${component}`, `${component}构建配置目录`);
1326
+ const envFile = resolveContained(componentRoot, '.env', `${component}构建配置`);
1327
+ assertSafeRegularFile(envFile, 0o600, { allowEmpty: false, role: `${component}构建配置` });
1328
+ const values = parseEnv(fs.readFileSync(envFile, 'utf8'), envFile);
1329
+ for (const [id, key] of Object.entries(build.buildSecretFiles).sort(([left], [right]) => left.localeCompare(right))) {
1330
+ const reference = values[key];
1331
+ invariant(typeof reference === 'string' && reference.length > 0, 'IDP_RELEASE_BUILD_SECRET_REFERENCE_MISSING', `${component}.${key}必须声明Config Dir相对路径`);
1332
+ const configured = resolveContained(componentRoot, reference, `${component}.${key}`);
1333
+ assertSafeRegularFile(configured, 0o600, { allowEmpty: false, role: `${component}.${key}` });
1334
+ const staged = component === 'smartgo' && id === 'npmrc' ? stageSmartGoNpmrc(configured, runtimeRoot) : null;
1335
+ if (staged) stagedSecrets.push(staged);
1336
+ secretArguments.push('--secret', `id=${id},src=${staged?.candidate ?? configured}`);
1337
+ }
1338
+ }
1339
+ try {
1340
+ const metadata = runBuildx({
1341
+ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot,
1342
+ publication: { type: 'registry-push' }, builderName, secretArguments, runner,
1343
+ });
1344
+ return {
1345
+ imageDigest: metadata.imageDigest,
1346
+ evidence: { type: 'buildx-metadata', metadataImageDigest: metadata.imageDigest },
1347
+ };
1348
+ } catch (error) {
1349
+ if (!orasTool || error?.code !== 'IDP_PROCESS_FAILED') throw error;
1350
+ return publishWithHostOciLayout({
1351
+ build, component, sourceRoot, tag, sourceDigest, planDigest, configRoot, runtimeRoot,
1352
+ builderName, secretArguments, orasTool, runner,
1353
+ });
1354
+ }
1355
+ } finally {
1356
+ for (const staged of stagedSecrets) {
1357
+ try { fs.rmSync(staged.directory, { recursive: true, force: false }); }
1358
+ catch (error) { throw new IdpError('IDP_RELEASE_BUILD_SECRET_CLEANUP_FAILED', '无法清理临时BuildKit Secret staging,拒绝继续发布', { causeCode: error?.code }); }
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ function snapshotConfig(configRoot) {
1364
+ const envPath = path.join(configRoot, '.env');
1365
+ const lockPath = path.join(configRoot, 'images.lock.json');
1366
+ return {
1367
+ envPath, lockPath,
1368
+ envText: fs.readFileSync(envPath, 'utf8'),
1369
+ lockText: fs.existsSync(lockPath) ? fs.readFileSync(lockPath, 'utf8') : null,
1370
+ };
1371
+ }
1372
+
1373
+ function currentText(candidate) {
1374
+ return fs.existsSync(candidate) ? fs.readFileSync(candidate, 'utf8') : null;
1375
+ }
1376
+
1377
+ export function restoreConfigCas(before, after) {
1378
+ invariant(currentText(after.envPath) === after.envText && currentText(after.lockPath) === after.lockText, 'IDP_RELEASE_CONFIG_CAS_CONFLICT', '发布失败后检测到用户或其他进程修改了.env/images.lock.json,拒绝覆盖');
1379
+ try {
1380
+ atomicWrite(before.envPath, before.envText, 0o600);
1381
+ if (before.lockText === null) {
1382
+ if (fs.existsSync(before.lockPath)) fs.unlinkSync(before.lockPath);
1383
+ }
1384
+ else atomicWrite(before.lockPath, before.lockText, 0o600);
1385
+ } catch (error) {
1386
+ try {
1387
+ atomicWrite(after.envPath, after.envText, 0o600);
1388
+ if (after.lockText === null) {
1389
+ if (fs.existsSync(after.lockPath)) fs.unlinkSync(after.lockPath);
1390
+ } else atomicWrite(after.lockPath, after.lockText, 0o600);
1391
+ } catch (recoveryError) {
1392
+ throw new IdpError('IDP_RELEASE_CONFIG_RECOVERY_REQUIRED', `发布配置回滚失败且无法恢复CAS后状态:${recoveryError.message}`, { originalError: error.message });
1393
+ }
1394
+ throw new IdpError('IDP_RELEASE_CONFIG_ROLLBACK_FAILED', `发布配置回滚失败:${error.message}`);
1395
+ }
1396
+ }
1397
+
1398
+ function selectedBuilds(defaults, active) {
1399
+ return ['tech', 'flow', 'portal', 'smartgo'].filter((component) => active.has(component)).map((component) => [component, defaults.buildImages[component]]);
1400
+ }
1401
+
1402
+ function selectedThirdParty(defaults, active, env) {
1403
+ const result = {};
1404
+ for (const [service, key] of Object.entries(THIRD_PARTY_BY_SERVICE)) {
1405
+ const selected = ['smartgoObjectStore', 'smartgoObjectStoreClient'].includes(service)
1406
+ ? active.has('smartgo')
1407
+ : active.has(service);
1408
+ if (!selected) continue;
1409
+ const configured = env[key];
1410
+ result[key] = configured && configured !== 'UNCONFIGURED' ? configured : defaults.thirdPartyImages[key];
1411
+ }
1412
+ return result;
1413
+ }
1414
+
1415
+ function backupProfilesForActive(profile) {
1416
+ if (profile === 'flow') return [];
1417
+ if (profile === 'foundation') return ['tech-only', 'registry'];
1418
+ return [profile];
1419
+ }
1420
+
1421
+ function createVerifiedBackups({
1422
+ repositoryRoot, configRoot, activeProfile, instanceLease,
1423
+ backupCreator, backupVerifier, restoreTester,
1424
+ }) {
1425
+ const results = [];
1426
+ for (const backupProfile of backupProfilesForActive(activeProfile)) {
1427
+ const created = backupCreator({ repositoryRoot, configRoot, profile: backupProfile, instanceLease, allowLegacyImageLock: true });
1428
+ const generationId = created?.receipt?.generationId;
1429
+ invariant(typeof generationId === 'string' && generationId.length > 0, 'IDP_RELEASE_BACKUP_EVIDENCE_INVALID', `${backupProfile}备份未返回Generation ID`);
1430
+ const verified = backupVerifier({ configRoot, generationId, instanceLease });
1431
+ const restored = restoreTester({ repositoryRoot, configRoot, generationId, instanceLease, allowLegacyImageLock: true });
1432
+ results.push({
1433
+ profile: backupProfile,
1434
+ generationId,
1435
+ manifestDigest: created.receipt.manifestDigest,
1436
+ createReceiptDigest: created.receipt.digest,
1437
+ verifyReceiptDigest: verified.receipt.digest,
1438
+ restoreTestReceiptDigest: restored.receipt.digest,
1439
+ });
1440
+ }
1441
+ return results;
1442
+ }
1443
+
1444
+ function configSnapshotDigest(snapshot) {
1445
+ return sha256(stableJson({
1446
+ envDigest: sha256(snapshot.envText),
1447
+ imageLockDigest: snapshot.lockText === null ? null : sha256(snapshot.lockText),
1448
+ }));
1449
+ }
1450
+
1451
+ function snapshotUsesLegacyImageLock(snapshot) {
1452
+ if (snapshot.lockText === null) return false;
1453
+ try { return JSON.parse(snapshot.lockText).schemaVersion === 1; }
1454
+ catch { return false; }
1455
+ }
1456
+
1457
+ export function releaseDeploy({
1458
+ repositoryRoot,
1459
+ configRoot,
1460
+ profile = 'foundation',
1461
+ version,
1462
+ ociRepositoryRoot: suppliedOciRepositoryRoot,
1463
+ runner = run,
1464
+ probeRunner = defaultProbe,
1465
+ locker = lockImages,
1466
+ compose = composeOperation,
1467
+ verifier = verifyDeployment,
1468
+ bindingSync = syncManagedBindings,
1469
+ bindingRollbacker = rollbackManagedBindingsCas,
1470
+ backupCreator = createBackup,
1471
+ backupVerifier = verifyBackup,
1472
+ restoreTester = testRestore,
1473
+ receiptWriter = writeReceipt,
1474
+ activeStateReader = readActiveProfileState,
1475
+ runtimeRenderer = renderRuntimeBundles,
1476
+ runtimeManifestReader = readRuntimeGenerationManifest,
1477
+ instanceLocker = withInstanceLock,
1478
+ defaults: suppliedDefaults,
1479
+ sourceInspector = inspectSource,
1480
+ } = {}) {
1481
+ invariant(path.isAbsolute(repositoryRoot ?? ''), 'IDP_RELEASE_REPOSITORY_ROOT_INVALID', 'repositoryRoot必须是绝对路径');
1482
+ const active = new Set(resolveProfile(profile));
1483
+ const defaults = validateReleaseDefaults(suppliedDefaults ?? readReleaseDefaults(repositoryRoot));
1484
+ const selectedVersion = version ?? defaults.releaseVersion;
1485
+ invariant(VERSION_PATTERN.test(selectedVersion), 'IDP_RELEASE_VERSION_INVALID', '--version必须是安全的SemVer版本号');
1486
+ initializeConfig(configRoot);
1487
+ const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile });
1488
+ return instanceLocker(preliminary.root, preliminary.env, `release:deploy:${profile}`, (instanceLease) => {
1489
+ generateSecrets(configRoot);
1490
+ const transactionStart = doctorConfig(configRoot, { requireConfigured: false, profile });
1491
+ invariant(
1492
+ transactionStart.report.digest === preliminary.report.digest && transactionStart.env.IDP_RUNTIME_DIR === preliminary.env.IDP_RUNTIME_DIR,
1493
+ 'IDP_RELEASE_CONFIG_CHANGED_BEFORE_TRANSACTION',
1494
+ 'Config Dir在发布总锁建立前发生变化,请重新执行发布',
1495
+ );
1496
+ invariant(
1497
+ transactionStart.report.unconfigured.length === 0,
1498
+ 'IDP_RELEASE_INPUTS_UNCONFIGURED',
1499
+ `构建前置配置尚未完成:${transactionStart.report.unconfigured.map(({ key, reason }) => `${key}(${reason})`).join('、')}`,
1500
+ );
1501
+ const previousActiveProfile = activeStateReader(transactionStart.env);
1502
+ const previousActiveDigest = previousActiveProfile?.digest ?? null;
1503
+ if (previousActiveProfile?.status === 'active') {
1504
+ const renderedPrevious = runtimeRenderer(configRoot, previousActiveProfile.profile);
1505
+ const sameProjection = renderedPrevious.manifest.digest === previousActiveProfile.renderDigest ||
1506
+ stableJson(runtimeManifestReader(
1507
+ transactionStart.env.IDP_RUNTIME_DIR,
1508
+ previousActiveProfile.profile,
1509
+ previousActiveProfile.renderDigest,
1510
+ ).files) === stableJson(renderedPrevious.manifest.files);
1511
+ invariant(
1512
+ sameProjection,
1513
+ 'IDP_RELEASE_ACTIVE_RUNTIME_CONFIG_DRIFT',
1514
+ '当前配置无法重现活动Profile的运行时投影;请先处理配置漂移,再执行一键更新',
1515
+ );
1516
+ }
1517
+ const ociRepositoryRoot = resolveOciRepositoryRoot(transactionStart.env, suppliedOciRepositoryRoot, defaults.ociRepositoryRoot);
1518
+ const workspaceRoot = path.dirname(repositoryRoot);
1519
+ const builds = selectedBuilds(defaults, active);
1520
+ const sourceFacts = builds.map(([component, build]) => {
1521
+ const sourceRoot = path.join(workspaceRoot, build.sourceDirectory);
1522
+ const allowNonGit = component === 'smartgo' && transactionStart.env.IDP_ALLOW_NON_GIT_SMARTGO_SOURCE === 'true' && transactionStart.env.IDP_ENVIRONMENT !== 'production';
1523
+ const source = sourceInspector(sourceRoot, { sourceRunner: runner, allowNonGit });
1524
+ return { component, sourceDigest: source.digest, fileCount: source.fileCount, sourceMode: source.sourceMode ?? 'git-worktree', buildContractDigest: sha256(stableJson(build)) };
1525
+ });
1526
+ const defaultsDigest = sha256(stableJson(defaults));
1527
+ const sourceDigest = sha256(stableJson({ defaultsDigest, sources: sourceFacts }));
1528
+ const releaseTag = `${selectedVersion}-r${sourceDigest.slice(7, 19)}`;
1529
+ const plannedBuilds = builds.map(([component, build]) => ({
1530
+ component,
1531
+ environmentKey: build.environmentKey,
1532
+ tag: `${ociRepositoryRoot}${build.repositorySuffix}:${releaseTag}`,
1533
+ sourceDigest: sourceFacts.find((entry) => entry.component === component).sourceDigest,
1534
+ buildContractDigest: sourceFacts.find((entry) => entry.component === component).buildContractDigest,
1535
+ }));
1536
+ const planFacts = {
1537
+ schemaVersion: 1, profile, version: selectedVersion, releaseTag,
1538
+ platforms: REQUIRED_PLATFORMS, defaultsDigest, sourceDigest,
1539
+ ociRepositoryRoot, builds: plannedBuilds,
1540
+ };
1541
+ const { plan, relativePath: planPath } = persistPlan(configRoot, planFacts);
1542
+ const artifacts = [];
1543
+ let orasTool;
1544
+ let ociLayoutBuilder;
1545
+ let orasToolPrepared = false;
1546
+ for (const planned of plannedBuilds) {
1547
+ const build = defaults.buildImages[planned.component];
1548
+ const candidate = artifactPath(configRoot, plan.planDigest, planned.component);
1549
+ const localArtifact = readArtifact(candidate);
1550
+ const published = inspectPublishedImage(planned.tag, { probeRunner, allowMissing: true });
1551
+ if (published) {
1552
+ const observedLabels = verifyPublishedReleaseIdentity(
1553
+ published,
1554
+ plan,
1555
+ planned,
1556
+ localArtifact ? 'IDP_RELEASE_TAG_CONFLICT' : 'IDP_RELEASE_TAG_UNOWNED',
1557
+ { allowLegacyComponent: localArtifact?.schemaVersion === 1 },
1558
+ );
1559
+ if (!localArtifact) {
1560
+ artifacts.push(persistArtifact(candidate, {
1561
+ schemaVersion: 2,
1562
+ planDigest: plan.planDigest,
1563
+ component: planned.component,
1564
+ tag: planned.tag,
1565
+ imageDigest: published.digest,
1566
+ sourceDigest: planned.sourceDigest,
1567
+ platforms: REQUIRED_PLATFORMS,
1568
+ evidence: {
1569
+ type: 'registry-label-adoption',
1570
+ labelsByPlatform: observedLabels,
1571
+ },
1572
+ createdAt: new Date().toISOString(),
1573
+ }));
1574
+ continue;
1575
+ }
1576
+ invariant(
1577
+ artifactMatchesPublished(localArtifact, published, plan, planned, observedLabels),
1578
+ 'IDP_RELEASE_TAG_CONFLICT', `tag与本地Plan Artifact不一致,请提升版本:${planned.tag}`,
1579
+ );
1580
+ artifacts.push(localArtifact);
1581
+ continue;
1582
+ }
1583
+ invariant(!localArtifact, 'IDP_RELEASE_ARTIFACT_REMOTE_MISSING', `本地Artifact已存在但远程tag缺失,拒绝使用同版本重建:${planned.tag}`);
1584
+ if (!orasToolPrepared) {
1585
+ orasTool = verifyOrasToolVersion(resolveConfiguredOrasTool(configRoot, transactionStart.env), runner);
1586
+ if (orasTool) invariant(!/[,\r\n]/u.test(transactionStart.env.IDP_RUNTIME_DIR), 'IDP_RELEASE_OCI_LAYOUT_PATH_INVALID', 'OCI Layout Runtime Root不得包含逗号或换行');
1587
+ ociLayoutBuilder = orasTool ? verifyOciLayoutBuilder(orasTool.builder, runner) : null;
1588
+ orasToolPrepared = true;
1589
+ }
1590
+ const sourceRoot = path.join(workspaceRoot, build.sourceDirectory);
1591
+ const buildResult = buildAndPublish({
1592
+ build, component: planned.component, sourceRoot, tag: planned.tag,
1593
+ sourceDigest: planned.sourceDigest, planDigest: plan.planDigest, configRoot,
1594
+ runtimeRoot: transactionStart.env.IDP_RUNTIME_DIR, builderName: ociLayoutBuilder?.name, orasTool, runner,
1595
+ });
1596
+ const inspected = inspectPublishedImage(planned.tag, { probeRunner, allowMissing: false });
1597
+ invariant(
1598
+ buildResult.imageDigest === inspected.digest,
1599
+ 'IDP_RELEASE_BUILD_DIGEST_MISMATCH',
1600
+ `Buildx metadata与推送后的OCI Index digest不一致,拒绝锁定tag:${planned.tag}`,
1601
+ );
1602
+ const observedLabels = verifyPublishedReleaseIdentity(
1603
+ inspected, plan, planned, 'IDP_RELEASE_BUILD_IDENTITY_MISMATCH',
1604
+ );
1605
+ const artifactBody = {
1606
+ schemaVersion: 2, planDigest: plan.planDigest, component: planned.component,
1607
+ tag: planned.tag, imageDigest: inspected.digest,
1608
+ sourceDigest: planned.sourceDigest,
1609
+ platforms: REQUIRED_PLATFORMS,
1610
+ evidence: {
1611
+ ...buildResult.evidence,
1612
+ labelsByPlatform: observedLabels,
1613
+ },
1614
+ createdAt: new Date().toISOString(),
1615
+ };
1616
+ artifacts.push(persistArtifact(candidate, artifactBody));
1617
+ }
1618
+
1619
+ for (const fact of sourceFacts) {
1620
+ const build = defaults.buildImages[fact.component];
1621
+ const allowNonGit = fact.component === 'smartgo' && transactionStart.env.IDP_ALLOW_NON_GIT_SMARTGO_SOURCE === 'true' && transactionStart.env.IDP_ENVIRONMENT !== 'production';
1622
+ const observed = sourceInspector(path.join(workspaceRoot, build.sourceDirectory), { sourceRunner: runner, allowNonGit });
1623
+ invariant(
1624
+ observed.digest === fact.sourceDigest && observed.fileCount === fact.fileCount && (observed.sourceMode ?? 'git-worktree') === fact.sourceMode,
1625
+ 'IDP_RELEASE_SOURCE_CHANGED_DURING_BUILD',
1626
+ `${fact.component}源码在构建期间发生变化,拒绝Apply镜像配置`,
1627
+ );
1628
+ }
1629
+ const transactionReady = doctorConfig(configRoot, { requireConfigured: false, profile });
1630
+ invariant(
1631
+ transactionReady.report.digest === transactionStart.report.digest,
1632
+ 'IDP_RELEASE_CONFIG_CHANGED_DURING_BUILD',
1633
+ 'Config Dir在镜像构建期间发生变化,拒绝Apply',
1634
+ );
1635
+ const observedActive = activeStateReader(transactionReady.env);
1636
+ invariant(
1637
+ (observedActive?.digest ?? null) === previousActiveDigest,
1638
+ 'IDP_RELEASE_ACTIVE_PROFILE_CHANGED_DURING_BUILD',
1639
+ '活动Profile在镜像构建期间发生变化,拒绝Apply',
1640
+ );
1641
+ const overrides = selectedThirdParty(defaults, active, transactionReady.env);
1642
+ for (const planned of plannedBuilds) {
1643
+ const artifact = artifacts.find((entry) => entry.component === planned.component);
1644
+ invariant(artifact && DIGEST_PATTERN.test(artifact.imageDigest ?? ''), 'IDP_RELEASE_ARTIFACT_INVALID', `${planned.component}缺少可信镜像摘要`);
1645
+ overrides[planned.environmentKey] = `${planned.tag}@${artifact.imageDigest}`;
1646
+ }
1647
+ for (const key of Object.values(THIRD_PARTY_BY_SERVICE)) {
1648
+ if (overrides[key]) inspectPublishedImage(overrides[key], { probeRunner, allowMissing: false });
1649
+ }
1650
+ const backups = previousActiveProfile?.status === 'active'
1651
+ ? createVerifiedBackups({
1652
+ repositoryRoot, configRoot, activeProfile: previousActiveProfile.profile, instanceLease,
1653
+ backupCreator, backupVerifier, restoreTester,
1654
+ })
1655
+ : [];
1656
+ const before = snapshotConfig(configRoot);
1657
+ const { plan: deploymentPlan, relativePath: deploymentPlanPath } = persistDeploymentPlan(configRoot, {
1658
+ schemaVersion: 1,
1659
+ ociPlanDigest: plan.planDigest,
1660
+ targetProfile: profile,
1661
+ previousActiveProfileDigest: previousActiveDigest,
1662
+ configPreimageDigest: configSnapshotDigest(before),
1663
+ images: Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right)).map(([key, ref]) => ({ key, ref })),
1664
+ backups,
1665
+ });
1666
+ let after;
1667
+ let imageLock;
1668
+ let runtimeAttempted = false;
1669
+ let bindingRollback = null;
1670
+ try {
1671
+ imageLock = locker(configRoot, { profile, overrides, requireProfileImages: true, instanceLease });
1672
+ after = snapshotConfig(configRoot);
1673
+ // 远端 Index 与平台身份已在 Apply 前逐项验证。这里仍拉取本机缺失内容,
1674
+ // 但不对已按 digest 存在的官方基础镜像重复访问 Docker Hub,避免限流
1675
+ // 在备份和恢复演练之后制造与镜像事实无关的事务失败。
1676
+ const pull = compose({ repositoryRoot, configRoot, profile, command: ['pull', '--policy', 'missing'], requireConfigured: true, instanceLease });
1677
+ runtimeAttempted = true;
1678
+ const switched = compose({ repositoryRoot, configRoot, profile, command: ['up', '-d', '--wait'], requireConfigured: true, allowProfileSwitch: true, instanceLease });
1679
+ const verified = verifier({ repositoryRoot, configRoot, profile, instanceLease });
1680
+ const bindings = active.has('tech') || active.has('registry') ? bindingSync({ configRoot, profile, instanceLease }) : null;
1681
+ bindingRollback = bindings?.rollback ?? null;
1682
+ invariant(
1683
+ currentText(after.envPath) === after.envText && currentText(after.lockPath) === after.lockText,
1684
+ 'IDP_RELEASE_CONFIG_CHANGED_AFTER_APPLY',
1685
+ '发布Apply后检测到.env或images.lock.json并发变化,拒绝写入成功回执',
1686
+ );
1687
+ const refreshed = doctorConfig(configRoot, { requireConfigured: true, profile });
1688
+ const releaseReceipt = receiptWriter(refreshed.env, 'release-deploy', {
1689
+ profile, version: selectedVersion, releaseTag, planDigest: plan.planDigest,
1690
+ deploymentPlanDigest: deploymentPlan.planDigest,
1691
+ imageLockDigest: imageLock.lockDigest,
1692
+ images: artifacts.map(({ component, tag, imageDigest }) => ({ component, tag, imageDigest })),
1693
+ backups,
1694
+ pullReceiptDigest: pull.receipt.digest, switchReceiptDigest: switched.receipt.digest,
1695
+ verifyReceiptDigest: verified.receipt.digest, bindingPlanDigest: bindings?.planDigest ?? null,
1696
+ }).receipt;
1697
+ return {
1698
+ schemaVersion: 1, status: 'deployed', profile, version: selectedVersion, releaseTag,
1699
+ planDigest: plan.planDigest, planPath,
1700
+ deploymentPlanDigest: deploymentPlan.planDigest, deploymentPlanPath,
1701
+ imageLockDigest: imageLock.lockDigest, backups,
1702
+ images: artifacts.map(({ component, tag, imageDigest, platforms }) => ({ component, tag, imageDigest, platforms })),
1703
+ verifyReceiptDigest: verified.receipt.digest, bindingPlanDigest: bindings?.planDigest ?? null,
1704
+ receiptDigest: releaseReceipt.digest,
1705
+ };
1706
+ } catch (error) {
1707
+ if (!after) throw error;
1708
+ let bindingRecoveryError;
1709
+ if (bindingRollback) {
1710
+ try { bindingRollbacker({ configRoot, rollback: bindingRollback, instanceLease }); }
1711
+ catch (rollbackError) { bindingRecoveryError = rollbackError; }
1712
+ }
1713
+ try { restoreConfigCas(before, after); }
1714
+ catch (configRollbackError) {
1715
+ throw new IdpError(
1716
+ 'IDP_RELEASE_RECOVERY_REQUIRED',
1717
+ `发布失败且配置无法安全回滚:${configRollbackError.message}`,
1718
+ { originalError: error.message, bindingRecoveryError: bindingRecoveryError?.message, backupGenerationIds: backups.map(({ generationId }) => generationId) },
1719
+ );
1720
+ }
1721
+ let runtimeRecoveryError;
1722
+ if (previousActiveProfile?.status === 'active') {
1723
+ const allowLegacyImageLock = snapshotUsesLegacyImageLock(before);
1724
+ try {
1725
+ compose({
1726
+ repositoryRoot, configRoot, profile: previousActiveProfile.profile,
1727
+ command: ['up', '-d', '--wait'], requireConfigured: true, allowProfileSwitch: true, allowLegacyImageLock, instanceLease,
1728
+ });
1729
+ verifier({ repositoryRoot, configRoot, profile: previousActiveProfile.profile, allowLegacyImageLock, instanceLease });
1730
+ } catch (rollbackError) { runtimeRecoveryError = rollbackError; }
1731
+ } else if (runtimeAttempted) {
1732
+ try {
1733
+ compose({
1734
+ repositoryRoot, configRoot, profile,
1735
+ command: ['down'], requireConfigured: false, allowProfileSwitch: false, instanceLease,
1736
+ });
1737
+ const restored = doctorConfig(configRoot, { requireConfigured: false, profile });
1738
+ const stopped = activeStateReader(restored.env);
1739
+ invariant(stopped?.status === 'stopped' && stopped.profile === profile, 'IDP_RELEASE_RUNTIME_CLEANUP_UNPROVEN', '首次部署失败后的容器停止状态无法证明');
1740
+ } catch (cleanupError) { runtimeRecoveryError = cleanupError; }
1741
+ }
1742
+ if (bindingRecoveryError || runtimeRecoveryError) {
1743
+ const reasons = [
1744
+ bindingRecoveryError ? `Binding恢复失败:${bindingRecoveryError.message}` : null,
1745
+ runtimeRecoveryError ? `运行态恢复失败:${runtimeRecoveryError.message}` : null,
1746
+ ].filter(Boolean).join(';');
1747
+ throw new IdpError(
1748
+ 'IDP_RELEASE_RECOVERY_REQUIRED',
1749
+ `发布配置已恢复,但仍需人工恢复:${reasons}`,
1750
+ { originalError: error.message, backupGenerationIds: backups.map(({ generationId }) => generationId) },
1751
+ );
1752
+ }
1753
+ throw new IdpError(
1754
+ 'IDP_RELEASE_DEPLOY_FAILED_ROLLED_BACK',
1755
+ `发布失败,配置、运行态与受管Binding已恢复:${error.message}`,
1756
+ { originalCode: error.code, backupGenerationIds: backups.map(({ generationId }) => generationId) },
1757
+ );
1758
+ }
1759
+ });
1760
+ }