openxiangda 1.0.172 → 1.0.174

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.
package/lib/cli.js CHANGED
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const os = require('os');
5
5
  const { builtinModules } = require('module');
6
- const { spawnSync } = require('child_process');
6
+ const { spawn, spawnSync } = require('child_process');
7
7
  const { pathToFileURL } = require('url');
8
8
  const esbuild = require('esbuild');
9
9
  const {
@@ -20,6 +20,7 @@ const {
20
20
  } = require('./config');
21
21
  const { requestJson: rawRequestJson } = require('./http');
22
22
  const { runWorkspaceJsCodeBuildBatch } = require('./js-code-build');
23
+ const { loadLocalFormSchema } = require('./form-schema-loader');
23
24
  const {
24
25
  buildGitBaseSourceArtifacts,
25
26
  canonicalJsonSha256,
@@ -63,6 +64,12 @@ const {
63
64
  inspectReleaseSourceIntegration,
64
65
  prepareReleaseSourceRevision,
65
66
  } = require('./release-mainline');
67
+ const {
68
+ buildWorkspaceReleaseCommands,
69
+ buildWorkspaceReleaseSteps,
70
+ normalizeReleaseTargets,
71
+ releasePlanHash,
72
+ } = require('./release-plan');
66
73
  const {
67
74
  analyzeManifestSourceDependencies,
68
75
  getImpactedResourceTargets,
@@ -221,7 +228,7 @@ Usage:
221
228
  openxiangda open-api credential list|get|create|update|rotate-secret [id] [--profile name] [--show-secret] [--yes] [--json]
222
229
  openxiangda design gates|template|review [--topic code] [--json]
223
230
  openxiangda sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive [change] [--change id] [--json]
224
- openxiangda release begin|status|integration-status|renew|end [--change id] [--profile name] [--json]
231
+ openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--json]
225
232
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
226
233
  openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--profile name] [--json]
227
234
  openxiangda env [--profile name]
@@ -729,9 +736,11 @@ async function sdd(args) {
729
736
  assertOrClaimWorktreeOwner({ cwd: process.cwd(), changeId });
730
737
  const result = createMainlineSddBundle({
731
738
  cwd: process.cwd(),
739
+ configText: readWorkspaceConfigText(),
732
740
  changeId,
733
741
  title: flags.title || flags.name,
734
742
  changes: flags.changes,
743
+ profile: flags.profile,
735
744
  force: Boolean(flags.force),
736
745
  });
737
746
  if (flags.json) return writeJson(result);
@@ -884,15 +893,639 @@ function parseSddAffectedFlags(flags) {
884
893
  };
885
894
  }
886
895
 
896
+ function releaseExecutionDir(changeId) {
897
+ return path.join(
898
+ process.cwd(),
899
+ '.openxiangda',
900
+ 'releases',
901
+ String(changeId || '').trim()
902
+ );
903
+ }
904
+
905
+ function releaseExecutionFile(changeId) {
906
+ return path.join(releaseExecutionDir(changeId), 'execution.json');
907
+ }
908
+
909
+ function writePrivateJsonAtomic(file, value) {
910
+ fs.mkdirSync(path.dirname(file), { recursive: true });
911
+ const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
912
+ fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`, {
913
+ mode: 0o600,
914
+ });
915
+ fs.renameSync(tempFile, file);
916
+ }
917
+
918
+ function readReleaseExecution(changeId) {
919
+ const file = releaseExecutionFile(changeId);
920
+ if (!fs.existsSync(file)) return null;
921
+ try {
922
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
923
+ } catch (error) {
924
+ fail(
925
+ `RELEASE_EXECUTION_INVALID: ${path.relative(process.cwd(), file)} 无法读取: ${error.message}`
926
+ );
927
+ }
928
+ }
929
+
930
+ function summarizeReleaseStepResult(result) {
931
+ const value =
932
+ result && typeof result === 'object' && !Array.isArray(result)
933
+ ? result
934
+ : {};
935
+ const findId = candidate =>
936
+ String(
937
+ candidate?.id ||
938
+ candidate?.releaseId ||
939
+ candidate?.activeAppReleaseId ||
940
+ ''
941
+ ).trim() || null;
942
+ return {
943
+ resultHash: canonicalJsonSha256(value),
944
+ releaseId:
945
+ findId(value.activated) ||
946
+ findId(value.verified) ||
947
+ findId(value.prepared) ||
948
+ findId(value),
949
+ };
950
+ }
951
+
952
+ function releaseExecutionContextFromBegin(result, target, changeId) {
953
+ const value =
954
+ result && typeof result === 'object' && !Array.isArray(result)
955
+ ? result
956
+ : {};
957
+ const releaseSourceRevision =
958
+ value.releaseSourceRevision &&
959
+ typeof value.releaseSourceRevision === 'object' &&
960
+ !Array.isArray(value.releaseSourceRevision)
961
+ ? value.releaseSourceRevision
962
+ : null;
963
+ if (
964
+ !value.leaseId ||
965
+ !value.clientSessionId ||
966
+ !releaseSourceRevision?.baseCommit
967
+ ) {
968
+ return null;
969
+ }
970
+ return {
971
+ appType: target.appType,
972
+ profile: target.profileName,
973
+ changeId,
974
+ leaseId: String(value.leaseId),
975
+ baselineId: value.baselineId ? String(value.baselineId) : null,
976
+ clientSessionId: String(value.clientSessionId),
977
+ releaseSourceRevision,
978
+ capturedAt: new Date().toISOString(),
979
+ };
980
+ }
981
+
982
+ function isDefinitelyPreWriteReleaseError(error) {
983
+ const code = String(error?.code || '');
984
+ if (new Set([
985
+ 'RELEASE_PUBLISH_REVISION_CHANGED',
986
+ 'RELEASE_SOURCE_DIRTY',
987
+ 'RELEASE_SOURCE_BRANCH_REQUIRED',
988
+ 'RELEASE_SOURCE_MAINLINE_REQUIRED',
989
+ 'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
990
+ 'RELEASE_SOURCE_BEHIND_MAIN',
991
+ 'RELEASE_GIT_AUTH_REQUIRED',
992
+ 'RELEASE_MAIN_BRANCH_UNRESOLVED',
993
+ 'RELEASE_MAIN_REF_UNVERIFIED',
994
+ 'SDD_PREPUBLISH_FAILED',
995
+ 'PUBLISH_CONTEXT_REQUIRED',
996
+ 'APP_RELEASE_STAGED_SCOPE_REQUIRED',
997
+ 'APP_RELEASE_STAGED_SCOPE_MISMATCH',
998
+ ]).has(code)) {
999
+ return true;
1000
+ }
1001
+ return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
1002
+ code
1003
+ );
1004
+ }
1005
+
1006
+ function parseChildJsonOutput(stdout) {
1007
+ const text = String(stdout || '').trim();
1008
+ if (!text) return null;
1009
+ try {
1010
+ return JSON.parse(text);
1011
+ } catch {
1012
+ const lines = text.split(/\r?\n/);
1013
+ for (let index = 0; index < lines.length; index += 1) {
1014
+ const candidate = lines.slice(index).join('\n').trim();
1015
+ if (!candidate.startsWith('{') && !candidate.startsWith('[')) continue;
1016
+ try {
1017
+ return JSON.parse(candidate);
1018
+ } catch {
1019
+ // Continue until the first complete JSON payload is found.
1020
+ }
1021
+ }
1022
+ return { output: maskText(text).slice(-4000) };
1023
+ }
1024
+ }
1025
+
1026
+ function runOpenXiangdaChild(args, options = {}) {
1027
+ return new Promise((resolve, reject) => {
1028
+ const bin = path.join(__dirname, '..', 'bin', 'openxiangda.js');
1029
+ const child = spawn(process.execPath, [bin, ...args, '--json'], {
1030
+ cwd: process.cwd(),
1031
+ env: process.env,
1032
+ stdio: ['ignore', 'pipe', 'pipe'],
1033
+ });
1034
+ let stdout = '';
1035
+ let stderr = '';
1036
+ child.stdout.on('data', chunk => {
1037
+ stdout += String(chunk);
1038
+ });
1039
+ child.stderr.on('data', chunk => {
1040
+ const text = String(chunk);
1041
+ stderr += text;
1042
+ if (!options.quiet) warn(maskText(text).trimEnd());
1043
+ });
1044
+ child.on('error', reject);
1045
+ child.on('close', code => {
1046
+ if (code === 0) {
1047
+ resolve(parseChildJsonOutput(stdout));
1048
+ return;
1049
+ }
1050
+ const message = maskText(
1051
+ String(stderr || stdout || `openxiangda ${args.join(' ')} failed`)
1052
+ ).trim();
1053
+ const error = new Error(message);
1054
+ error.code =
1055
+ message.match(/\b([A-Z][A-Z0-9_]{3,})\b/)?.[1] ||
1056
+ 'RELEASE_STEP_FAILED';
1057
+ error.exitCode = code;
1058
+ reject(error);
1059
+ });
1060
+ });
1061
+ }
1062
+
1063
+ function normalizeReleaseWaitSeconds(flags) {
1064
+ const raw =
1065
+ readStringFlag(flags, 'wait-seconds') ||
1066
+ readStringFlag(flags, 'lease-wait-seconds');
1067
+ if (raw === '') return 1800;
1068
+ const value = Number(raw);
1069
+ if (!Number.isFinite(value) || value < 0 || value > 86400) {
1070
+ fail('--wait-seconds 必须是 0-86400 之间的秒数');
1071
+ }
1072
+ return Math.floor(value);
1073
+ }
1074
+
1075
+ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1076
+ if (getUsableStoredPublishLease(target)) return { waitedMs: 0 };
1077
+ const waitSeconds = normalizeReleaseWaitSeconds(flags);
1078
+ const startedAt = Date.now();
1079
+ const deadline = startedAt + waitSeconds * 1000;
1080
+ const clientSessionId = createPublishClientSessionId();
1081
+ let attempts = 0;
1082
+ while (true) {
1083
+ const remote = await fetchPublishLeaseStatus(
1084
+ config,
1085
+ target,
1086
+ clientSessionId
1087
+ );
1088
+ if (!remote?.active) {
1089
+ return { waitedMs: Date.now() - startedAt, attempts, remote };
1090
+ }
1091
+ const remoteHolder =
1092
+ (typeof remote?.holder === 'string' ? remote.holder : null) ||
1093
+ remote?.holder?.changeId ||
1094
+ remote?.changeId ||
1095
+ remote?.holder?.clientSessionId ||
1096
+ 'another release';
1097
+ if (Date.now() >= deadline) {
1098
+ fail(
1099
+ `PUBLISH_LEASE_WAIT_TIMEOUT: ${target.appType} 仍由 ${remoteHolder} 发布中;已等待 ${waitSeconds}s`
1100
+ );
1101
+ }
1102
+ attempts += 1;
1103
+ const suggestedSeconds = Number(
1104
+ remote?.retryAfterSeconds || remote?.retryAfter || 5
1105
+ );
1106
+ const delayMs = Math.min(
1107
+ Math.max(Number.isFinite(suggestedSeconds) ? suggestedSeconds * 1000 : 5000, 1000),
1108
+ 30000,
1109
+ Math.max(1000, deadline - Date.now())
1110
+ );
1111
+ warn(
1112
+ `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s`
1113
+ );
1114
+ await sleep(delayMs);
1115
+ }
1116
+ }
1117
+
1118
+ function readReleaseRecoveryContext(target, changeId) {
1119
+ if (!changeId) return null;
1120
+ const execution = readReleaseExecution(changeId);
1121
+ const context = execution?.releaseContext;
1122
+ if (!context || typeof context !== 'object' || Array.isArray(context)) {
1123
+ return null;
1124
+ }
1125
+ if (
1126
+ context.appType !== target.appType ||
1127
+ context.profile !== target.profileName ||
1128
+ context.changeId !== changeId ||
1129
+ !context.leaseId ||
1130
+ !context.clientSessionId ||
1131
+ !context.releaseSourceRevision
1132
+ ) {
1133
+ return null;
1134
+ }
1135
+ return { execution, context };
1136
+ }
1137
+
1138
+ function remoteLeaseOwnedByRecovery(remote, recovery) {
1139
+ if (!remote?.active || !recovery) return false;
1140
+ const holder = remote.holder;
1141
+ const holderIsSelf =
1142
+ holder === 'self' ||
1143
+ holder?.self === true ||
1144
+ holder?.clientSessionId === recovery.clientSessionId;
1145
+ return Boolean(
1146
+ holderIsSelf &&
1147
+ String(remote.leaseId || '') === String(recovery.leaseId) &&
1148
+ String(remote.changeId || '') === String(recovery.changeId)
1149
+ );
1150
+ }
1151
+
1152
+ async function endReleaseWithoutLocalLease(
1153
+ config,
1154
+ target,
1155
+ flags,
1156
+ baseline
1157
+ ) {
1158
+ const changeId = readStringFlag(flags, 'change');
1159
+ const recovered = readReleaseRecoveryContext(target, changeId);
1160
+ const recovery = recovered?.context || null;
1161
+ const clientSessionId =
1162
+ recovery?.clientSessionId ||
1163
+ baseline?.clientSessionId ||
1164
+ createPublishClientSessionId();
1165
+ const remote = await fetchPublishLeaseStatus(
1166
+ config,
1167
+ target,
1168
+ clientSessionId
1169
+ );
1170
+ const sourceRevision =
1171
+ baseline?.releaseSourceRevision ||
1172
+ recovery?.releaseSourceRevision ||
1173
+ null;
1174
+
1175
+ if (remote?.active) {
1176
+ if (!recovery) {
1177
+ fail(
1178
+ `PUBLISH_LEASE_RECOVERY_REQUIRED: 远端仍有活动发布租约 ${remote.leaseId || '-'};本地状态缺失,禁止误报 inactive。请使用原 change 的私有 execution.json 恢复,或等待租约过期后只读核对`
1179
+ );
1180
+ }
1181
+ if (!remoteLeaseOwnedByRecovery(remote, recovery)) {
1182
+ fail(
1183
+ 'PUBLISH_LEASE_RECOVERY_MISMATCH: 远端活动租约不属于当前 change/session;未释放任何租约'
1184
+ );
1185
+ }
1186
+ const integration = assertReleaseSourceIntegrated(sourceRevision, {
1187
+ cwd: process.cwd(),
1188
+ });
1189
+ const remoteResult = await requestWithAuth(
1190
+ config,
1191
+ target.profileName,
1192
+ publishLeaseApiPath(
1193
+ target,
1194
+ `/${encodeURIComponent(recovery.leaseId)}/release`
1195
+ ),
1196
+ {
1197
+ method: 'POST',
1198
+ body: {
1199
+ completion: 'mainline-integrated-recovered',
1200
+ sourceCommit: integration.sourceCommit,
1201
+ mainBranch: integration.mainBranch,
1202
+ mainTipCommit: integration.mainTipCommit,
1203
+ },
1204
+ }
1205
+ );
1206
+ if (baseline) {
1207
+ clearChangeBaseline(target, baseline.baselineId || baseline.id);
1208
+ }
1209
+ releaseWorktreeOwner({ cwd: process.cwd() });
1210
+ recovered.execution.leaseReleasedAt = new Date().toISOString();
1211
+ recovered.execution.leaseReleaseRecovered = true;
1212
+ recovered.execution.updatedAt = recovered.execution.leaseReleasedAt;
1213
+ writePrivateJsonAtomic(
1214
+ releaseExecutionFile(changeId),
1215
+ recovered.execution
1216
+ );
1217
+ return {
1218
+ ...(remoteResult && typeof remoteResult === 'object'
1219
+ ? remoteResult
1220
+ : {}),
1221
+ active: false,
1222
+ appType: target.appType,
1223
+ recovered: true,
1224
+ integration,
1225
+ };
1226
+ }
1227
+
1228
+ const integration = sourceRevision
1229
+ ? assertReleaseSourceIntegrated(sourceRevision, { cwd: process.cwd() })
1230
+ : null;
1231
+ if (baseline && !integration) {
1232
+ fail(
1233
+ 'RELEASE_SOURCE_LINEAGE_REQUIRED: 本地仍有发布基线,但缺少可验证的 releaseSourceRevision;禁止无证据清理'
1234
+ );
1235
+ }
1236
+ if (baseline) {
1237
+ clearChangeBaseline(target, baseline.baselineId || baseline.id);
1238
+ }
1239
+ releaseWorktreeOwner({ cwd: process.cwd() });
1240
+ return {
1241
+ active: false,
1242
+ appType: target.appType,
1243
+ local: false,
1244
+ remote,
1245
+ recovered: false,
1246
+ integration,
1247
+ };
1248
+ }
1249
+
1250
+ async function publishWorkspaceRelease(config, target, flags = {}) {
1251
+ const changeId = readStringFlag(flags, 'change');
1252
+ if (!changeId) {
1253
+ fail('用法: openxiangda release publish --change <id> --profile <name>');
1254
+ }
1255
+ const plan = buildWorkspaceReleasePlan({
1256
+ changed: true,
1257
+ since: flags.since,
1258
+ profile: target.profileName,
1259
+ skipSdd: true,
1260
+ changeId,
1261
+ expandDependencies: Boolean(flags['expand-dependencies']),
1262
+ });
1263
+ if (plan.errors.length > 0) {
1264
+ fail(
1265
+ `RELEASE_PLAN_INVALID: ${plan.errors
1266
+ .map(error => error.message || error.name)
1267
+ .join('; ')}`
1268
+ );
1269
+ }
1270
+ const steps = buildWorkspaceReleaseSteps(
1271
+ plan.targets,
1272
+ plan.workspace.runtimeMode,
1273
+ target.profileName,
1274
+ changeId
1275
+ );
1276
+ const planHash = releasePlanHash({
1277
+ schemaVersion: 'openxiangda_release_execution_v1',
1278
+ appType: target.appType,
1279
+ profile: target.profileName,
1280
+ changeId,
1281
+ runtimeMode: plan.workspace.runtimeMode,
1282
+ targets: plan.activationTargets,
1283
+ commands: steps.map(step => step.command),
1284
+ });
1285
+ if (flags['dry-run']) {
1286
+ return {
1287
+ dryRun: true,
1288
+ appType: target.appType,
1289
+ profile: target.profileName,
1290
+ changeId,
1291
+ planHash,
1292
+ targets: plan.targets,
1293
+ activationTargets: plan.activationTargets,
1294
+ steps,
1295
+ };
1296
+ }
1297
+ const verification = verifySddChange({
1298
+ cwd: process.cwd(),
1299
+ changeId,
1300
+ releasePlan: {
1301
+ ...plan,
1302
+ commands: steps.map(step => step.command),
1303
+ },
1304
+ writeMetadata: true,
1305
+ stage: 'prepublish',
1306
+ });
1307
+ if (!verification.passed) {
1308
+ fail(
1309
+ `SDD_PREPUBLISH_FAILED: ${verification.errors
1310
+ .map(error => error.message)
1311
+ .join('; ')}`
1312
+ );
1313
+ }
1314
+ if (steps.length === 0) {
1315
+ return {
1316
+ completed: true,
1317
+ noOp: true,
1318
+ appType: target.appType,
1319
+ profile: target.profileName,
1320
+ changeId,
1321
+ planHash,
1322
+ execution: null,
1323
+ };
1324
+ }
1325
+ const existing = readReleaseExecution(changeId);
1326
+ if (
1327
+ existing &&
1328
+ (existing.planHash !== planHash ||
1329
+ existing.appType !== target.appType ||
1330
+ existing.profile !== target.profileName)
1331
+ ) {
1332
+ fail(
1333
+ 'RELEASE_EXECUTION_PLAN_CHANGED: 已存在不同计划的执行日志;请先完成或人工核对旧发布,禁止静默覆盖'
1334
+ );
1335
+ }
1336
+ if (
1337
+ existing?.status === 'write-review-required' &&
1338
+ !flags['resume-after-review']
1339
+ ) {
1340
+ fail(
1341
+ 'RELEASE_WRITE_REVIEW_REQUIRED: 上次写请求结果不确定;请先只读核对 staged release,确认可重试后追加 --resume-after-review'
1342
+ );
1343
+ }
1344
+ const now = new Date().toISOString();
1345
+ const execution = existing || {
1346
+ schemaVersion: 'openxiangda_release_execution_v1',
1347
+ appType: target.appType,
1348
+ profile: target.profileName,
1349
+ changeId,
1350
+ planHash,
1351
+ status: 'planned',
1352
+ createdAt: now,
1353
+ updatedAt: now,
1354
+ stagedWriteOccurred: false,
1355
+ writeAttempted: false,
1356
+ steps: [
1357
+ {
1358
+ id: 'lease-and-capture',
1359
+ command: `openxiangda release begin --change ${changeId} --profile ${target.profileName} --freeze-app-capture`,
1360
+ status: 'pending',
1361
+ },
1362
+ ...steps.map(step => ({
1363
+ id: step.id,
1364
+ command: step.command,
1365
+ stagedKind: step.stagedKind,
1366
+ status: 'pending',
1367
+ })),
1368
+ {
1369
+ id: 'release-lease',
1370
+ command: `openxiangda release end --change ${changeId} --profile ${target.profileName}`,
1371
+ status: 'pending',
1372
+ },
1373
+ ],
1374
+ };
1375
+ if (execution.status === 'completed') {
1376
+ return {
1377
+ completed: true,
1378
+ resumed: true,
1379
+ appType: target.appType,
1380
+ profile: target.profileName,
1381
+ changeId,
1382
+ planHash,
1383
+ executionFile: path
1384
+ .relative(process.cwd(), releaseExecutionFile(changeId))
1385
+ .replace(/\\/g, '/'),
1386
+ execution,
1387
+ };
1388
+ }
1389
+ execution.status = 'running';
1390
+ execution.updatedAt = new Date().toISOString();
1391
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1392
+
1393
+ const runnable = [
1394
+ {
1395
+ id: 'lease-and-capture',
1396
+ args: [
1397
+ 'release',
1398
+ 'begin',
1399
+ '--change',
1400
+ changeId,
1401
+ '--profile',
1402
+ target.profileName,
1403
+ '--freeze-app-capture',
1404
+ ],
1405
+ },
1406
+ ...steps,
1407
+ {
1408
+ id: 'release-lease',
1409
+ args: [
1410
+ 'release',
1411
+ 'end',
1412
+ '--change',
1413
+ changeId,
1414
+ '--profile',
1415
+ target.profileName,
1416
+ ],
1417
+ },
1418
+ ];
1419
+ try {
1420
+ for (const step of runnable) {
1421
+ const journalStep = execution.steps.find(item => item.id === step.id);
1422
+ if (journalStep?.status === 'completed') continue;
1423
+ if (step.id === 'lease-and-capture') {
1424
+ await waitForPublishLeaseAvailability(config, target, flags);
1425
+ }
1426
+ journalStep.status = 'running';
1427
+ journalStep.startedAt = new Date().toISOString();
1428
+ execution.updatedAt = journalStep.startedAt;
1429
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1430
+ warn(`发布步骤开始: ${step.id}`);
1431
+ const result = await runOpenXiangdaChild(step.args);
1432
+ journalStep.status = 'completed';
1433
+ journalStep.completedAt = new Date().toISOString();
1434
+ journalStep.result = summarizeReleaseStepResult(result);
1435
+ if (step.id === 'lease-and-capture') {
1436
+ execution.releaseContext = releaseExecutionContextFromBegin(
1437
+ result,
1438
+ target,
1439
+ changeId
1440
+ );
1441
+ }
1442
+ if (
1443
+ step.id !== 'lease-and-capture' &&
1444
+ step.id !== 'release-lease' &&
1445
+ step.writes !== false
1446
+ ) {
1447
+ execution.writeAttempted = true;
1448
+ }
1449
+ if (step.stagedKind) execution.stagedWriteOccurred = true;
1450
+ execution.updatedAt = journalStep.completedAt;
1451
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1452
+ }
1453
+ execution.status = 'completed';
1454
+ execution.completedAt = new Date().toISOString();
1455
+ execution.updatedAt = execution.completedAt;
1456
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1457
+ return {
1458
+ completed: true,
1459
+ resumed: Boolean(existing),
1460
+ appType: target.appType,
1461
+ profile: target.profileName,
1462
+ changeId,
1463
+ planHash,
1464
+ executionFile: path
1465
+ .relative(process.cwd(), releaseExecutionFile(changeId))
1466
+ .replace(/\\/g, '/'),
1467
+ execution,
1468
+ };
1469
+ } catch (error) {
1470
+ const runningStep = execution.steps.find(step => step.status === 'running');
1471
+ if (runningStep) {
1472
+ runningStep.status = 'failed';
1473
+ runningStep.failedAt = new Date().toISOString();
1474
+ runningStep.error = {
1475
+ code: error.code || 'RELEASE_STEP_FAILED',
1476
+ message: maskText(error.message).slice(0, 1000),
1477
+ };
1478
+ const runningDefinition = runnable.find(
1479
+ step => step.id === runningStep.id
1480
+ );
1481
+ if (
1482
+ runningDefinition &&
1483
+ runningDefinition.id !== 'lease-and-capture' &&
1484
+ runningDefinition.id !== 'release-lease' &&
1485
+ runningDefinition.writes !== false &&
1486
+ !isDefinitelyPreWriteReleaseError(error)
1487
+ ) {
1488
+ execution.writeAttempted = true;
1489
+ }
1490
+ }
1491
+ execution.status = execution.stagedWriteOccurred
1492
+ ? 'staged-resumable'
1493
+ : execution.writeAttempted
1494
+ ? 'write-review-required'
1495
+ : 'failed';
1496
+ execution.updatedAt = new Date().toISOString();
1497
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1498
+ if (!execution.writeAttempted) {
1499
+ try {
1500
+ await runOpenXiangdaChild(
1501
+ [
1502
+ 'release',
1503
+ 'end',
1504
+ '--change',
1505
+ changeId,
1506
+ '--profile',
1507
+ target.profileName,
1508
+ ],
1509
+ { quiet: true }
1510
+ );
1511
+ } catch {
1512
+ // The original error remains authoritative; lease cleanup is best effort.
1513
+ }
1514
+ }
1515
+ throw error;
1516
+ }
1517
+ }
1518
+
887
1519
  async function release(args) {
888
1520
  const { subcommand, rest } = parseSubcommandArgs(args);
889
1521
  const { flags, positional } = parseArgs(rest);
890
1522
  if (wantsSubcommandHelp(subcommand, flags)) {
891
1523
  print([
892
- '用法: openxiangda release begin|status|integration-status|renew|end [--change id] [--profile name] [--json]',
1524
+ '用法: openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--json]',
893
1525
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
894
1526
  ' openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--profile name] [--json]',
895
1527
  '常用流程:',
1528
+ ' openxiangda release publish --change <id> --profile <name>',
896
1529
  ' openxiangda release begin --change <id> --profile <name>',
897
1530
  ' openxiangda resource publish function --only <code> --change <id> --profile <name>',
898
1531
  ' openxiangda runtime deploy --change <id> --profile <name>',
@@ -903,6 +1536,8 @@ async function release(args) {
903
1536
  ' openxiangda release backend-rollback <releaseId> --change <id> --reason "..." --profile <name>',
904
1537
  ' openxiangda release app-finalize --change <id> --staged-resources-json <JSON|file> --profile <name>',
905
1538
  '说明:',
1539
+ ' - publish 默认等待租约并按私有执行日志恢复;上次写结果不确定时必须只读核对后显式 --resume-after-review。',
1540
+ ' - app-head 默认只输出紧凑 head 摘要;需要完整 manifest 时显式追加 --full。',
906
1541
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
907
1542
  ' - 带 --change 的 resource/runtime 写命令在没有本地 lease 时会自动 begin,并在后续命令复用。',
908
1543
  ' - 子 Runtime/Page/Backend/Form Release 默认先 stage;app-finalize 用 --staged-resources-json 覆盖 capture 中的 changed children,并在同一事务中原子激活。',
@@ -941,6 +1576,16 @@ async function release(args) {
941
1576
  flags
942
1577
  );
943
1578
  }
1579
+ if (subcommand === 'publish') {
1580
+ const result = await publishWorkspaceRelease(config, target, flags);
1581
+ if (flags.json) return writeJson(result);
1582
+ print(
1583
+ result.dryRun
1584
+ ? `发布计划已生成(未写入): ${result.changeId} steps=${result.steps.length}`
1585
+ : `应用原子发布完成: ${result.appType} change=${result.changeId}`
1586
+ );
1587
+ return;
1588
+ }
944
1589
  const leaseIdFromArgs = positional[0] || readStringFlag(flags, 'lease-id');
945
1590
 
946
1591
  if (subcommand === 'status') {
@@ -1040,15 +1685,56 @@ async function release(args) {
1040
1685
  throw error;
1041
1686
  }
1042
1687
  const finalizedBaseline = finalizeReleaseBaseline(target, baseline);
1043
- const stagedResourcesFile = newReleaseLifecycle
1044
- ? initializeStagedAppReleaseResources(target, changeId)
1045
- : null;
1688
+ let frozenCapture = null;
1689
+ let releaseSessionFile = null;
1690
+ let stagedResourcesFile = null;
1691
+ try {
1692
+ if (flags['freeze-app-capture']) {
1693
+ frozenCapture = await fetchAppReleaseCapture(config, target, flags);
1694
+ releaseSessionFile = saveFrozenAppReleaseCapture(
1695
+ target,
1696
+ changeId,
1697
+ finalizedBaseline,
1698
+ lease,
1699
+ frozenCapture
1700
+ );
1701
+ }
1702
+ stagedResourcesFile = newReleaseLifecycle
1703
+ ? initializeStagedAppReleaseResources(target, changeId)
1704
+ : null;
1705
+ } catch (error) {
1706
+ if (newReleaseLifecycle) {
1707
+ try {
1708
+ await requestWithAuth(
1709
+ config,
1710
+ target.profileName,
1711
+ publishLeaseApiPath(
1712
+ target,
1713
+ `/${encodeURIComponent(lease.leaseId)}/release`
1714
+ ),
1715
+ { method: 'POST', body: { completion: 'local-prepare-failed' } }
1716
+ );
1717
+ } catch {
1718
+ // Preserve the local preparation failure as the primary error.
1719
+ }
1720
+ clearPublishLease(target, lease.leaseId);
1721
+ clearChangeBaseline(target, finalizedBaseline?.baselineId);
1722
+ releaseWorktreeOwner({ cwd: process.cwd() });
1723
+ }
1724
+ throw error;
1725
+ }
1046
1726
  const result = {
1047
1727
  ...lease,
1048
1728
  baselineId: finalizedBaseline?.baselineId,
1049
1729
  baselineHeadDigest: finalizedBaseline?.headDigest,
1050
1730
  sourceBase: finalizedBaseline?.sourceBase,
1051
1731
  releaseSourceRevision: finalizedBaseline?.releaseSourceRevision,
1732
+ ...(frozenCapture
1733
+ ? {
1734
+ captureHash: frozenCapture.captureHash,
1735
+ releaseSessionFile,
1736
+ }
1737
+ : {}),
1052
1738
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
1053
1739
  };
1054
1740
  if (flags.json) return writeJson(result);
@@ -1119,34 +1805,22 @@ async function release(args) {
1119
1805
  return;
1120
1806
  }
1121
1807
  if (!leaseIdFromArgs) assertLocalPublishLeaseOwnership(stored);
1122
- assertOrClaimWorktreeOwner({
1123
- cwd: process.cwd(),
1124
- changeId: stored?.changeId || readStringFlag(flags, 'change') || 'unscoped-publish',
1125
- });
1126
1808
  const leaseId = leaseIdFromArgs || stored?.leaseId;
1127
1809
  if (!leaseId) {
1128
- const integration = baseline?.releaseSourceRevision
1129
- ? assertReleaseSourceIntegrated(baseline.releaseSourceRevision, {
1130
- cwd: process.cwd(),
1131
- })
1132
- : null;
1133
- if (baseline && !integration) {
1134
- fail(
1135
- 'RELEASE_SOURCE_LINEAGE_REQUIRED: 本地仍有发布基线,但缺少可验证的 releaseSourceRevision;禁止无证据清理'
1136
- );
1137
- }
1138
- const result = {
1139
- active: false,
1140
- appType: target.appType,
1141
- local: false,
1142
- integration,
1143
- };
1144
- if (baseline) clearChangeBaseline(target, baseline.baselineId);
1145
- releaseWorktreeOwner({ cwd: process.cwd() });
1810
+ const result = await endReleaseWithoutLocalLease(
1811
+ config,
1812
+ target,
1813
+ flags,
1814
+ baseline
1815
+ );
1146
1816
  if (flags.json) return writeJson(result);
1147
1817
  print(`当前工作区没有活动发布租约: ${target.appType}`);
1148
1818
  return;
1149
1819
  }
1820
+ assertOrClaimWorktreeOwner({
1821
+ cwd: process.cwd(),
1822
+ changeId: stored?.changeId || readStringFlag(flags, 'change') || 'unscoped-publish',
1823
+ });
1150
1824
  const integration = assertReleaseSourceIntegrated(
1151
1825
  baseline?.releaseSourceRevision,
1152
1826
  { cwd: process.cwd() }
@@ -1191,7 +1865,7 @@ async function release(args) {
1191
1865
  }
1192
1866
  }
1193
1867
 
1194
- fail('用法: openxiangda release begin|status|integration-status|renew|end|backend-*|app-* [--change id] [--profile name]');
1868
+ fail('用法: openxiangda release publish|begin|status|integration-status|renew|end|backend-*|app-* [--change id] [--profile name]');
1195
1869
  }
1196
1870
 
1197
1871
  async function fetchPublishLeaseStatus(config, target, clientSessionId) {
@@ -1460,6 +2134,74 @@ async function fetchAppReleaseCapture(config, target, flags = {}) {
1460
2134
  return clonePlainJson(capture);
1461
2135
  }
1462
2136
 
2137
+ function releaseSessionFile(changeId) {
2138
+ return path.join(releaseExecutionDir(changeId), 'release-session.json');
2139
+ }
2140
+
2141
+ function saveFrozenAppReleaseCapture(
2142
+ target,
2143
+ changeId,
2144
+ baseline,
2145
+ lease,
2146
+ capture
2147
+ ) {
2148
+ const file = releaseSessionFile(changeId);
2149
+ writePrivateJsonAtomic(file, {
2150
+ schemaVersion: 'openxiangda_release_session_v1',
2151
+ appType: target.appType,
2152
+ profile: target.profileName,
2153
+ changeId,
2154
+ baselineId: baseline?.baselineId || baseline?.id || null,
2155
+ leaseId: lease?.leaseId || lease?.id || null,
2156
+ sourceRevision: baseline?.releaseSourceRevision || null,
2157
+ capturedAt: new Date().toISOString(),
2158
+ capture,
2159
+ });
2160
+ return path.relative(process.cwd(), file).replace(/\\/g, '/');
2161
+ }
2162
+
2163
+ function readFrozenAppReleaseCapture(target, flags = {}) {
2164
+ const changeId = readStringFlag(flags, 'change');
2165
+ if (!changeId) return null;
2166
+ const file = releaseSessionFile(changeId);
2167
+ if (!fs.existsSync(file)) return null;
2168
+ let session;
2169
+ try {
2170
+ session = JSON.parse(fs.readFileSync(file, 'utf8'));
2171
+ } catch (error) {
2172
+ fail(`RELEASE_SESSION_INVALID: ${error.message}`);
2173
+ }
2174
+ if (
2175
+ session?.schemaVersion !== 'openxiangda_release_session_v1' ||
2176
+ session?.appType !== target.appType ||
2177
+ session?.profile !== target.profileName ||
2178
+ session?.changeId !== changeId
2179
+ ) {
2180
+ fail(
2181
+ 'RELEASE_SESSION_CONTEXT_MISMATCH: 冻结的 App capture 与当前 app/profile/change 不一致'
2182
+ );
2183
+ }
2184
+ const capture = session.capture;
2185
+ if (
2186
+ !capture ||
2187
+ !Array.isArray(capture.resources) ||
2188
+ !/^[a-f0-9]{64}$/i.test(String(capture.captureHash || ''))
2189
+ ) {
2190
+ fail('RELEASE_SESSION_CAPTURE_INVALID: 冻结的 App capture 不完整');
2191
+ }
2192
+ const expectedCaptureHash = readStringFlag(flags, 'expected-capture-hash');
2193
+ if (
2194
+ expectedCaptureHash &&
2195
+ expectedCaptureHash.toLowerCase() !==
2196
+ String(capture.captureHash).toLowerCase()
2197
+ ) {
2198
+ fail(
2199
+ `APP_RELEASE_CAPTURE_MOVED: 期望 capture ${expectedCaptureHash},冻结为 ${capture.captureHash}`
2200
+ );
2201
+ }
2202
+ return clonePlainJson(capture);
2203
+ }
2204
+
1463
2205
  function appReleaseWriteBody(releaseContext, extra = {}) {
1464
2206
  return {
1465
2207
  ...releaseContext.control,
@@ -1790,7 +2532,13 @@ function assertStagedAppReleaseScope(target, flags, resources) {
1790
2532
  }
1791
2533
  throw error;
1792
2534
  }
1793
- const targets = scope.targets || {};
2535
+ const runtimeMode = getWorkspaceRuntimeModeFromConfig(
2536
+ readWorkspaceConfigText()
2537
+ );
2538
+ const { activationTargets: targets } = normalizeReleaseTargets(
2539
+ scope.targets || {},
2540
+ runtimeMode
2541
+ );
1794
2542
  const normalized = normalizeStagedAppReleaseResources(resources);
1795
2543
  const byKind = kind => normalized.filter(resource => resource.kind === kind);
1796
2544
  const expectedBackendSelectors = [
@@ -1873,8 +2621,15 @@ function changedAppReleaseResources(detailResources, captureResources) {
1873
2621
  }
1874
2622
 
1875
2623
  function assertAppReleaseActualGate(target, flags, scope, command) {
2624
+ const runtimeMode = getWorkspaceRuntimeModeFromConfig(
2625
+ readWorkspaceConfigText()
2626
+ );
2627
+ const normalized = normalizeReleaseTargets(
2628
+ scope.targets || {},
2629
+ runtimeMode
2630
+ );
1876
2631
  assertSddReleaseGate(
1877
- buildExplicitSddReleasePlan(scope.targets, {
2632
+ buildExplicitSddReleasePlan(normalized.activationTargets, {
1878
2633
  files: scope.changedFiles || [],
1879
2634
  commands: [command],
1880
2635
  changeId: scope.changeId,
@@ -2042,6 +2797,70 @@ async function activateAppReleaseDetail(
2042
2797
  );
2043
2798
  }
2044
2799
 
2800
+ function summarizeAppReleaseHead(data) {
2801
+ const value =
2802
+ data && typeof data === 'object' && !Array.isArray(data) ? data : {};
2803
+ const release =
2804
+ value.release &&
2805
+ typeof value.release === 'object' &&
2806
+ !Array.isArray(value.release)
2807
+ ? value.release
2808
+ : {};
2809
+ const manifest =
2810
+ release.manifestJson &&
2811
+ typeof release.manifestJson === 'object'
2812
+ ? release.manifestJson
2813
+ : value.manifestJson &&
2814
+ typeof value.manifestJson === 'object'
2815
+ ? value.manifestJson
2816
+ : {};
2817
+ const resources = Array.isArray(manifest.resources)
2818
+ ? manifest.resources
2819
+ : Array.isArray(value.resources)
2820
+ ? value.resources
2821
+ : [];
2822
+ const resourcesByKind = Object.fromEntries(
2823
+ Array.from(
2824
+ resources.reduce((counts, resource) => {
2825
+ const kind = String(resource?.kind || 'Unknown');
2826
+ counts.set(kind, (counts.get(kind) || 0) + 1);
2827
+ return counts;
2828
+ }, new Map())
2829
+ ).sort(([left], [right]) => left.localeCompare(right))
2830
+ );
2831
+ const compactRelease = {};
2832
+ for (const key of [
2833
+ 'id',
2834
+ 'status',
2835
+ 'parentReleaseId',
2836
+ 'manifestHash',
2837
+ 'verificationHash',
2838
+ 'protocolVersion',
2839
+ 'createdAt',
2840
+ 'updatedAt',
2841
+ 'activatedAt',
2842
+ ]) {
2843
+ if (release[key] !== undefined) compactRelease[key] = release[key];
2844
+ }
2845
+ return {
2846
+ appType: value.appType,
2847
+ activeAppReleaseId:
2848
+ value.activeAppReleaseId || release.id || null,
2849
+ ...(value.activeRuntimeReleaseId
2850
+ ? { activeRuntimeReleaseId: value.activeRuntimeReleaseId }
2851
+ : {}),
2852
+ ...(value.activePageReleaseId
2853
+ ? { activePageReleaseId: value.activePageReleaseId }
2854
+ : {}),
2855
+ ...(value.activeBackendReleaseId
2856
+ ? { activeBackendReleaseId: value.activeBackendReleaseId }
2857
+ : {}),
2858
+ release: compactRelease,
2859
+ resourceCount: resources.length,
2860
+ resourcesByKind,
2861
+ };
2862
+ }
2863
+
2045
2864
  async function runAppReleaseCommand(
2046
2865
  config,
2047
2866
  target,
@@ -2059,6 +2878,7 @@ async function runAppReleaseCommand(
2059
2878
  target.profileName,
2060
2879
  appReleaseApiPath(target, '/head')
2061
2880
  );
2881
+ if (!flags.full) data = summarizeAppReleaseHead(data);
2062
2882
  } else if (subcommand === 'app-list') {
2063
2883
  data = await requestWithAuth(
2064
2884
  config,
@@ -2236,7 +3056,9 @@ async function runAppReleaseCommand(
2236
3056
  flags,
2237
3057
  'App Release finalize'
2238
3058
  );
2239
- const capture = await fetchAppReleaseCapture(config, target, flags);
3059
+ const capture =
3060
+ readFrozenAppReleaseCapture(target, flags) ||
3061
+ (await fetchAppReleaseCapture(config, target, flags));
2240
3062
  const frozenResources = overlayAppReleaseResources(
2241
3063
  capture.resources,
2242
3064
  stagedResources
@@ -8761,6 +9583,9 @@ async function resource(args) {
8761
9583
  fail('resource publish --only/--code 不能与 --prune 同时使用,避免删除未选中的资源');
8762
9584
  }
8763
9585
  const manifest = loadWorkspaceResources({ typeFilters, codeFilters });
9586
+ if (subcommand !== 'typegen') {
9587
+ await attachLocalFormSchemas(manifest);
9588
+ }
8764
9589
  if (
8765
9590
  subcommand === 'plan' &&
8766
9591
  process.env.OPENXIANGDA_GIT_BASE_RESOURCE_PLAN === '1'
@@ -9560,12 +10385,23 @@ async function runtime(args) {
9560
10385
  }
9561
10386
 
9562
10387
  async function loadRuntimeHeadApp(config, target) {
9563
- const snapshot = await requestWithAuth(
9564
- config,
9565
- target.profileName,
9566
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/snapshot`
9567
- );
9568
- return snapshot?.app || snapshot?.application || snapshot || {};
10388
+ try {
10389
+ return (
10390
+ (await requestWithAuth(
10391
+ config,
10392
+ target.profileName,
10393
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases/head`
10394
+ )) || {}
10395
+ );
10396
+ } catch (error) {
10397
+ if (!isHttpNotFound(error)) throw error;
10398
+ const snapshot = await requestWithAuth(
10399
+ config,
10400
+ target.profileName,
10401
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/snapshot`
10402
+ );
10403
+ return snapshot?.app || snapshot?.application || snapshot || {};
10404
+ }
9569
10405
  }
9570
10406
 
9571
10407
  function assertRuntimeSourceRevisionStable(beforeBuild, afterBuild) {
@@ -10180,7 +11016,7 @@ async function commands(args) {
10180
11016
  'open-api credential list|get|create|update|rotate-secret [id]',
10181
11017
  'design gates|template|review [--topic code]',
10182
11018
  'sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive',
10183
- 'release begin|status|integration-status|renew|end|backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry|app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort',
11019
+ 'release publish|begin|status|integration-status|renew|end|backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry|app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort',
10184
11020
  'env',
10185
11021
  'workspace init|bind|plan|check|publish [--app-name] [--changed|--since|--form|--page|--only|--dry-run|--force|--resources|--skip-resources|--prune]',
10186
11022
  'app list|create|snapshot',
@@ -11646,6 +12482,84 @@ function loadWorkspaceResources(options = {}) {
11646
12482
  return manifest;
11647
12483
  }
11648
12484
 
12485
+ async function attachLocalFormSchemas(manifest) {
12486
+ if (
12487
+ Array.isArray(manifest.resourceTypeFilters) &&
12488
+ !manifest.resourceTypeFilters.includes('formSettings')
12489
+ ) {
12490
+ return manifest;
12491
+ }
12492
+ const selectors = Array.isArray(manifest.__resourceCodeSelectors)
12493
+ ? manifest.__resourceCodeSelectors.filter(selector =>
12494
+ resourceCodeSelectorAppliesToKey(selector, 'formSettings')
12495
+ )
12496
+ : [];
12497
+ const candidateCodes = unique([
12498
+ ...(manifest.formSettings || []).map(getResourceItemCode),
12499
+ ...selectors.map(selector => selector.code),
12500
+ ]).filter(Boolean);
12501
+ if (candidateCodes.length === 0) return manifest;
12502
+
12503
+ const loadedCodes = new Set();
12504
+ for (const code of candidateCodes) {
12505
+ let localSchema;
12506
+ try {
12507
+ localSchema = await loadLocalFormSchema(process.cwd(), code);
12508
+ } catch (error) {
12509
+ const wrapped = new Error(
12510
+ `FORM_SCHEMA_LOAD_FAILED: ${code}: ${error.message}`
12511
+ );
12512
+ wrapped.code = 'FORM_SCHEMA_LOAD_FAILED';
12513
+ throw wrapped;
12514
+ }
12515
+ if (!localSchema) continue;
12516
+ loadedCodes.add(code);
12517
+ const existing = (manifest.formSettings || []).find(
12518
+ item => getResourceItemCode(item) === code
12519
+ );
12520
+ const merged = {
12521
+ ...(existing || {}),
12522
+ code,
12523
+ formCode: existing?.formCode || code,
12524
+ name: existing?.name ?? localSchema.name,
12525
+ schema: localSchema.schema,
12526
+ packages: localSchema.packages,
12527
+ formType: existing?.formType ?? localSchema.formType,
12528
+ __source: existing?.__source || path.join(
12529
+ process.cwd(),
12530
+ localSchema.sourcePath
12531
+ ),
12532
+ __dir: existing?.__dir || path.dirname(
12533
+ path.join(process.cwd(), localSchema.sourcePath)
12534
+ ),
12535
+ __localSchema: {
12536
+ sourcePath: localSchema.sourcePath,
12537
+ fieldCount: localSchema.fieldCount,
12538
+ },
12539
+ };
12540
+ if (existing) {
12541
+ Object.assign(existing, merged);
12542
+ } else {
12543
+ manifest.formSettings = manifest.formSettings || [];
12544
+ manifest.formSettings.push(merged);
12545
+ }
12546
+ }
12547
+ manifest.formSettings = (manifest.formSettings || []).sort((left, right) =>
12548
+ getResourceItemCode(left).localeCompare(getResourceItemCode(right))
12549
+ );
12550
+ if (
12551
+ loadedCodes.size > 0 &&
12552
+ Array.isArray(manifest.unmatchedResourceCodeFilters)
12553
+ ) {
12554
+ manifest.unmatchedResourceCodeFilters =
12555
+ manifest.unmatchedResourceCodeFilters.filter(raw => {
12556
+ const code = String(raw || '').replace(/^.*[:/]/, '');
12557
+ return !loadedCodes.has(code);
12558
+ });
12559
+ }
12560
+ return manifest;
12561
+ }
12562
+
11649
12563
  function readResourceItems(baseDir, spec, options = {}) {
11650
12564
  const items = [];
11651
12565
  const selectorEntries = options.selectorEntries || null;
@@ -20593,6 +21507,16 @@ function getWorkspaceImpactedResourceTargets(files) {
20593
21507
  return getImpactedResourceTargets(getWorkspaceSourceDependencyIndex(), files);
20594
21508
  }
20595
21509
 
21510
+ function scopeNeedsBackendDependencyAnalysis(scope) {
21511
+ const targets = scope?.targets || {};
21512
+ return (
21513
+ Boolean(targets.resources) ||
21514
+ ['functions', 'automations', 'workflows', 'jsCodeNodes'].some(
21515
+ key => (targets[key] || []).length > 0
21516
+ )
21517
+ );
21518
+ }
21519
+
20596
21520
  function addCodeResourceTargetFromPath(targets, file, prefix, key, options = {}) {
20597
21521
  if (!file.startsWith(prefix)) return false;
20598
21522
  const rest = file.slice(prefix.length);
@@ -20890,8 +21814,11 @@ function fileBelongsToChangeScope(file, scope, runtimeMode) {
20890
21814
 
20891
21815
  function getChangeScopedWorkspaceFiles(changeScope, runtimeMode, options = {}) {
20892
21816
  const dirtyFiles = getWorkspaceChangedFiles({ since: options.since });
20893
- const dependencyIndex = getWorkspaceSourceDependencyIndex();
21817
+ const dependencyIndex = scopeNeedsBackendDependencyAnalysis(changeScope)
21818
+ ? getWorkspaceSourceDependencyIndex()
21819
+ : null;
20894
21820
  const dependencyBelongsToScope = file => {
21821
+ if (!dependencyIndex) return false;
20895
21822
  const impacted = getImpactedResourceTargets(dependencyIndex, [file]);
20896
21823
  return ['functions', 'automations', 'workflows', 'jsCodeNodes'].some(key =>
20897
21824
  (impacted[key] || []).some(code => scopeIncludesCode(changeScope.targets?.[key], code))
@@ -20986,87 +21913,6 @@ function scanWorkspaceReliabilityIssues(files) {
20986
21913
  return issues;
20987
21914
  }
20988
21915
 
20989
- function buildTargetedResourceReleaseCommands(targets, profileArg, changeId) {
20990
- const changeArg = changeId ? ` --change ${changeId}` : '';
20991
- const commands = [];
20992
- const functions = targets.functions || [];
20993
- const automations = targets.automations || [];
20994
- if (functions.length > 0 || automations.length > 0) {
20995
- const resourceTypes = [
20996
- ...(functions.length > 0 ? ['function'] : []),
20997
- ...(automations.length > 0 ? ['automation'] : []),
20998
- ];
20999
- const qualify = resourceTypes.length > 1;
21000
- const selectors = [
21001
- ...functions.map(code => (qualify ? `function:${code}` : code)),
21002
- ...automations.map(code => (qualify ? `automation:${code}` : code)),
21003
- ];
21004
- commands.push(
21005
- `openxiangda resource publish ${resourceTypes.join(',')} --only ${selectors.join(',')} --stage-only --profile ${profileArg}${changeArg}`
21006
- );
21007
- }
21008
- const workflows = targets.workflows || [];
21009
- if (workflows.length > 0) {
21010
- commands.push(
21011
- `openxiangda resource publish workflow --only ${workflows.join(',')} --profile ${profileArg}${changeArg}`
21012
- );
21013
- }
21014
- return commands;
21015
- }
21016
-
21017
- function buildWorkspaceReleaseCommands(targets, runtimeMode, profile, changeId) {
21018
- const profileArg = profile || '<profile>';
21019
- const commands = [];
21020
- const hasResourceTargets =
21021
- Boolean(targets.resources) ||
21022
- (targets.functions || []).length > 0 ||
21023
- (targets.automations || []).length > 0 ||
21024
- (targets.workflows || []).length > 0 ||
21025
- (targets.jsCodeNodes || []).length > 0;
21026
- if (runtimeMode === 'react-spa') {
21027
- if (targets.forms.length > 0) {
21028
- commands.push(
21029
- `openxiangda resource publish form-setting --only ${targets.forms.join(',')} --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21030
- );
21031
- }
21032
- if (hasResourceTargets) {
21033
- commands.push(...buildTargetedResourceReleaseCommands(targets, profileArg, changeId));
21034
- }
21035
- if (targets.runtime) {
21036
- commands.push(
21037
- `openxiangda runtime deploy --no-activate --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21038
- );
21039
- }
21040
- if (
21041
- targets.forms.length > 0 ||
21042
- (targets.functions || []).length > 0 ||
21043
- (targets.automations || []).length > 0 ||
21044
- targets.runtime
21045
- ) {
21046
- commands.push(
21047
- `openxiangda release app-finalize --staged-resources-json .openxiangda/releases/${changeId || 'change'}/staged-resources.json --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21048
- );
21049
- }
21050
- return commands;
21051
- }
21052
-
21053
- const onlyTargets = [
21054
- ...targets.forms.map(code => `forms/${code}`),
21055
- ...targets.pages.map(code => `pages/${code}`),
21056
- ];
21057
- if (onlyTargets.length > 0) {
21058
- commands.push(
21059
- `openxiangda workspace publish --profile ${profileArg} --only ${onlyTargets.join(',')} --skip-resources${
21060
- changeId ? ` --change ${changeId}` : ''
21061
- }`
21062
- );
21063
- }
21064
- if (hasResourceTargets) {
21065
- commands.push(...buildTargetedResourceReleaseCommands(targets, profileArg, changeId));
21066
- }
21067
- return commands;
21068
- }
21069
-
21070
21916
  function buildWorkspaceReleasePlan(options = {}) {
21071
21917
  const configText = readWorkspaceConfigText();
21072
21918
  const runtimeMode = getWorkspaceRuntimeModeFromConfig(configText);
@@ -21095,7 +21941,7 @@ function buildWorkspaceReleasePlan(options = {}) {
21095
21941
  baseRef: options.since || 'HEAD',
21096
21942
  });
21097
21943
  let dependencyImpact = {};
21098
- if (changeScope) {
21944
+ if (changeScope && scopeNeedsBackendDependencyAnalysis(changeScope)) {
21099
21945
  const dependencyTargets = getWorkspaceImpactedResourceTargets(files);
21100
21946
  dependencyImpact = Object.fromEntries(
21101
21947
  ['functions', 'automations', 'workflows', 'jsCodeNodes'].map(key => [
@@ -21110,9 +21956,17 @@ function buildWorkspaceReleasePlan(options = {}) {
21110
21956
  // dozens of unrelated resources. Callers may opt in when the intended
21111
21957
  // change genuinely requires the whole dependency closure.
21112
21958
  if (options.expandDependencies) {
21113
- for (const key of ['functions', 'automations', 'workflows', 'jsCodeNodes']) {
21114
- targets[key] = unique([...(targets[key] || []), ...(dependencyTargets[key] || [])]).sort();
21115
- }
21959
+ for (const key of [
21960
+ 'functions',
21961
+ 'automations',
21962
+ 'workflows',
21963
+ 'jsCodeNodes',
21964
+ ]) {
21965
+ targets[key] = unique([
21966
+ ...(targets[key] || []),
21967
+ ...(dependencyTargets[key] || []),
21968
+ ]).sort();
21969
+ }
21116
21970
  }
21117
21971
  }
21118
21972
  if (options.includeResources) targets.resources = true;
@@ -21138,6 +21992,10 @@ function buildWorkspaceReleasePlan(options = {}) {
21138
21992
  name: issue.rule,
21139
21993
  message: `${issue.file}:${issue.line} ${issue.message}`,
21140
21994
  }));
21995
+ const normalizedReleaseTargets = normalizeReleaseTargets(
21996
+ targets,
21997
+ runtimeMode
21998
+ );
21141
21999
  const generatedCommands = buildWorkspaceReleaseCommands(
21142
22000
  targets,
21143
22001
  runtimeMode,
@@ -21226,6 +22084,7 @@ function buildWorkspaceReleasePlan(options = {}) {
21226
22084
  since: options.changed ? options.since || undefined : undefined,
21227
22085
  changedFiles: changeScope || options.changed ? files : undefined,
21228
22086
  targets,
22087
+ activationTargets: normalizedReleaseTargets.activationTargets,
21229
22088
  publishOrder:
21230
22089
  runtimeMode === 'react-spa'
21231
22090
  ? ['forms', 'resources', 'runtime']