openxiangda 1.0.242 → 1.0.244

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/README.md CHANGED
@@ -131,6 +131,10 @@ instead of a database-column error.
131
131
 
132
132
  React SPA workspaces publish their frontend with `openxiangda runtime deploy`. Every finalized Runtime release is built from a clean, committed Git `HEAD`; the CLI freezes `sourceRevision`, the current active release, and its source revision before any build/upload. Deploy fails with `RUNTIME_SOURCE_BASE_DIVERGED` when its `HEAD` does not descend from the online Runtime source, including with `--no-activate`, so an old isolated worktree cannot stage and later activate a silent rollback. `.openxiangda/`, `openspec/`, `dist/`, and other pure generated/governance/state paths do not make the source dirty, but `--no-build` cannot bypass the lineage gate. An intentional rollback requires `--allow-runtime-rollback --reason "<at least 8 characters>"`, which is persisted for audit and never bypasses dirty/non-Git checks.
133
133
 
134
+ Environment-managed `release ship`, recovery `release deploy`, and `release promote` accept the same audited Runtime rollback pair. It is forwarded only to `runtime-stage`, never to resource stages or `app-finalize`. Ship freezes the intent in its private journal so production confirmation can inherit it automatically; without the explicit pair, lineage divergence remains blocked.
135
+
136
+ Before publishing the OpenXiangda npm package, run `npm run test:release` once on the final clean version commit. It writes hash-bound evidence under `.openxiangda/evidence/tests/<HEAD>/release.json`. `npm publish` validates that evidence against the exact Git commit, package version, Node version, and complete passing result, then runs the mainline guard; it does not execute the full release suite a second time. Any commit, version, runtime, result, or evidence-hash drift remains fail closed.
137
+
134
138
  Parallel tasks develop and test in isolated worktrees, but feature worktrees do not publish. After approved commits are merged and pushed, `sdd bundle <release-change> --changes ...` unions their exact structured scope and preserves the source changes' common Git baseline instead of adopting the post-merge `HEAD`. Old imported changes that predate source-base metadata must pass `--source-base-ref <commit>` explicitly. Commit/push the bundle, then run `release publish --change <release-change> --profile <name>` from a clean local main/master whose commit exactly equals the authoritative remote tip. It verifies without rewriting reviewed SDD files, waits for the app lease, freezes one authoritative App capture, executes exact Form/Backend/Runtime staged steps, atomically finalizes the Root App release, and records a resumable local execution journal. Runtime checks use the narrow head endpoint; immutable Git-base artifact hashes are reused across plans.
135
139
 
136
140
  Because promotion starts from an already-pushed authoritative mainline commit, `openxiangda release integration-status --change <change> --profile <name>` should pass immediately after activation. After a managed ship has ended, `--change` recovers lineage directly from the private `ship.json` or follows its production/preproduction deployment IDs to the matching `execution.json`; a recovery failure names the missing file or field. Run it and `release end`; there is no post-release merge step.
@@ -1012,6 +1012,14 @@ function releaseExecutionPath(changeId, deploymentId, cwd = process.cwd()) {
1012
1012
 
1013
1013
  function withManagedReleaseForwardedFlags(stepId, args = [], flags = {}) {
1014
1014
  const forwarded = [...args];
1015
+ if (stepId === 'runtime-stage' && flags['allow-runtime-rollback']) {
1016
+ forwarded.push('--allow-runtime-rollback');
1017
+ const rollbackReason = String(flags.reason || '').trim();
1018
+ if (rollbackReason) {
1019
+ forwarded.push('--reason', rollbackReason);
1020
+ }
1021
+ return forwarded;
1022
+ }
1015
1023
  const immutableResourceStages = new Set([
1016
1024
  'backend-stage',
1017
1025
  'workflow-stage',
package/lib/cli.js CHANGED
@@ -336,7 +336,7 @@ Usage:
336
336
  openxiangda retry <runId> [--environment preproduction|production] [--json]
337
337
  openxiangda rollback <preproduction|production> --to <appReleaseId> [--json]
338
338
  openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--watch] [--json]
339
- openxiangda release ship|candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--replace-manifest --reason text] [--adopt-online-baseline --adoption-reason text] [--json]
339
+ openxiangda release ship|candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--replace-manifest] [--allow-runtime-rollback --reason text] [--adopt-online-baseline --adoption-reason text] [--json]
340
340
  openxiangda task status --change <id> [--profile name] [--watch] [--json]
341
341
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
342
342
  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] [--break-glass-adopt-verified-root --reason text] [--force-activate-without-validation] [--profile name] [--json]
@@ -1177,9 +1177,12 @@ function writeManagedShip(changeId, value) {
1177
1177
  }
1178
1178
 
1179
1179
  function normalizeManagedShipIntent(value = {}) {
1180
+ const enabled = Boolean(value.enabled);
1180
1181
  return {
1181
- enabled: Boolean(value.enabled),
1182
- reason: String(value.reason || '').trim() || null,
1182
+ enabled,
1183
+ reason: enabled
1184
+ ? String(value.reason || '').trim() || null
1185
+ : null,
1183
1186
  };
1184
1187
  }
1185
1188
 
@@ -1201,8 +1204,13 @@ function resolveManagedShipIntents(flags = {}, existingShip = null) {
1201
1204
  enabled: flags['adopt-online-baseline'],
1202
1205
  reason: flags['adoption-reason'],
1203
1206
  });
1207
+ const requestedRuntimeRollback = normalizeManagedShipIntent({
1208
+ enabled: flags['allow-runtime-rollback'],
1209
+ reason: flags.reason,
1210
+ });
1204
1211
  const hasFrozenCandidate = Boolean(existingShip?.candidateId);
1205
1212
  let baselineAdoption = requestedBaselineAdoption;
1213
+ let runtimeRollback = requestedRuntimeRollback;
1206
1214
 
1207
1215
  if (hasFrozenCandidate) {
1208
1216
  const recordedReplacement = normalizeManagedShipIntent(
@@ -1239,6 +1247,34 @@ function resolveManagedShipIntents(flags = {}, existingShip = null) {
1239
1247
  baselineAdoption = adoptionExplicitlySupplied
1240
1248
  ? requestedBaselineAdoption
1241
1249
  : recordedAdoption;
1250
+
1251
+ const hasRecordedRuntimeRollback = Object.prototype.hasOwnProperty.call(
1252
+ existingShip,
1253
+ 'runtimeRollback'
1254
+ );
1255
+ const recordedRuntimeRollback = normalizeManagedShipIntent(
1256
+ existingShip.runtimeRollback
1257
+ );
1258
+ const runtimeRollbackExplicitlySupplied =
1259
+ Object.prototype.hasOwnProperty.call(
1260
+ flags,
1261
+ 'allow-runtime-rollback'
1262
+ );
1263
+ if (
1264
+ hasRecordedRuntimeRollback &&
1265
+ runtimeRollbackExplicitlySupplied &&
1266
+ !managedShipIntentMatches(
1267
+ recordedRuntimeRollback,
1268
+ requestedRuntimeRollback
1269
+ )
1270
+ ) {
1271
+ fail(
1272
+ 'RELEASE_SHIP_RUNTIME_ROLLBACK_INTENT_MISMATCH: --allow-runtime-rollback/--reason 与该 candidate 的预发 Runtime 发布意图不一致;省略这两个参数可自动复用 ship.json 中的已冻结意图'
1273
+ );
1274
+ }
1275
+ runtimeRollback = runtimeRollbackExplicitlySupplied
1276
+ ? requestedRuntimeRollback
1277
+ : recordedRuntimeRollback;
1242
1278
  }
1243
1279
 
1244
1280
  const effectiveFlags = { ...flags };
@@ -1249,10 +1285,23 @@ function resolveManagedShipIntents(flags = {}, existingShip = null) {
1249
1285
  delete effectiveFlags['adopt-online-baseline'];
1250
1286
  delete effectiveFlags['adoption-reason'];
1251
1287
  }
1288
+ if (runtimeRollback.enabled) {
1289
+ const existingReason = String(effectiveFlags.reason || '').trim();
1290
+ if (existingReason && existingReason !== runtimeRollback.reason) {
1291
+ fail(
1292
+ 'RELEASE_SHIP_RUNTIME_ROLLBACK_REASON_CONFLICT: Runtime rollback 与 manifest replacement 共用 --reason 时必须使用同一审计原因'
1293
+ );
1294
+ }
1295
+ effectiveFlags['allow-runtime-rollback'] = true;
1296
+ effectiveFlags.reason = runtimeRollback.reason;
1297
+ } else {
1298
+ delete effectiveFlags['allow-runtime-rollback'];
1299
+ }
1252
1300
 
1253
1301
  return {
1254
1302
  manifestReplacement,
1255
1303
  baselineAdoption,
1304
+ runtimeRollback,
1256
1305
  effectiveFlags,
1257
1306
  };
1258
1307
  }
@@ -1271,12 +1320,17 @@ function buildManagedShipCommand(
1271
1320
  JSON.stringify(profileName),
1272
1321
  ];
1273
1322
  if (options.confirmProduction) args.push('--confirm-production');
1323
+ let sharedReason = null;
1274
1324
  if (intents.manifestReplacement.enabled) {
1275
- args.push(
1276
- '--replace-manifest',
1277
- '--reason',
1278
- JSON.stringify(intents.manifestReplacement.reason)
1279
- );
1325
+ args.push('--replace-manifest');
1326
+ sharedReason = intents.manifestReplacement.reason;
1327
+ }
1328
+ if (intents.runtimeRollback.enabled) {
1329
+ args.push('--allow-runtime-rollback');
1330
+ sharedReason = intents.runtimeRollback.reason;
1331
+ }
1332
+ if (sharedReason) {
1333
+ args.push('--reason', JSON.stringify(sharedReason));
1280
1334
  }
1281
1335
  if (intents.baselineAdoption.enabled) {
1282
1336
  args.push(
@@ -1972,6 +2026,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1972
2026
  if (!changeId) {
1973
2027
  fail('用法: openxiangda release publish --change <id> --profile <name>');
1974
2028
  }
2029
+ assertManagedRuntimeRollbackFlags(flags);
1975
2030
  const releaseCandidate = flags.__verifiedCandidate
1976
2031
  ? assertLocalCandidate(flags.__verifiedCandidate)
1977
2032
  : readReleaseCandidate(candidateId);
@@ -4028,6 +4083,281 @@ async function readManagedDeployment(
4028
4083
  return deployment;
4029
4084
  }
4030
4085
 
4086
+ function inspectSucceededPreproductionDeployment(
4087
+ deployment,
4088
+ context,
4089
+ now = Date.now()
4090
+ ) {
4091
+ const candidate = context?.candidate || {};
4092
+ const target = context?.target || {};
4093
+ const currentAppReleaseId = String(
4094
+ context?.currentAppReleaseId || ''
4095
+ ).trim();
4096
+ const evidence = deployment?.evidenceSummary;
4097
+ const reasons = [];
4098
+ if (deployment?.status !== 'succeeded') {
4099
+ reasons.push('status_not_succeeded');
4100
+ }
4101
+ if (deployment?.kind !== 'deploy') {
4102
+ reasons.push('kind_not_preproduction_deploy');
4103
+ }
4104
+ if (deployment?.candidateId !== candidate.id) {
4105
+ reasons.push('candidate_mismatch');
4106
+ }
4107
+ if (deployment?.targetEnvironmentId !== target.environmentId) {
4108
+ reasons.push('target_environment_mismatch');
4109
+ }
4110
+ if (!currentAppReleaseId) reasons.push('current_app_release_missing');
4111
+ if (deployment?.targetAppReleaseId !== currentAppReleaseId) {
4112
+ reasons.push('active_app_release_mismatch');
4113
+ }
4114
+ if (!deployment?.evidenceHash) reasons.push('evidence_hash_missing');
4115
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
4116
+ reasons.push('evidence_summary_missing');
4117
+ } else {
4118
+ if (sha256Canonical(evidence) !== deployment.evidenceHash) {
4119
+ reasons.push('evidence_hash_mismatch');
4120
+ }
4121
+ if (evidence.outcome !== 'passed') reasons.push('evidence_not_passed');
4122
+ if (evidence.deploymentId !== deployment.id) {
4123
+ reasons.push('evidence_deployment_mismatch');
4124
+ }
4125
+ if (evidence.candidateId !== candidate.id) {
4126
+ reasons.push('evidence_candidate_mismatch');
4127
+ }
4128
+ if (evidence.candidateHash !== candidate.candidateHash) {
4129
+ reasons.push('evidence_candidate_hash_mismatch');
4130
+ }
4131
+ if ((evidence.testPlanHash || null) !== (candidate.testPlanHash || null)) {
4132
+ reasons.push('evidence_test_plan_hash_mismatch');
4133
+ }
4134
+ if (evidence.environmentId !== target.environmentId) {
4135
+ reasons.push('evidence_environment_mismatch');
4136
+ }
4137
+ if (evidence.environmentKind !== 'preproduction') {
4138
+ reasons.push('evidence_environment_kind_mismatch');
4139
+ }
4140
+ if (evidence.appType !== target.appType) {
4141
+ reasons.push('evidence_app_type_mismatch');
4142
+ }
4143
+ if (evidence.appReleaseId !== deployment.targetAppReleaseId) {
4144
+ reasons.push('evidence_app_release_mismatch');
4145
+ }
4146
+ const validUntil = Date.parse(evidence.validUntil || '');
4147
+ if (!Number.isFinite(validUntil) || validUntil <= now) {
4148
+ reasons.push('evidence_expired');
4149
+ }
4150
+ }
4151
+ return { accepted: reasons.length === 0, reasons };
4152
+ }
4153
+
4154
+ function selectSucceededPreproductionDeployment(
4155
+ deployments,
4156
+ context,
4157
+ now = Date.now()
4158
+ ) {
4159
+ const ordered = [...(deployments || [])].sort((left, right) => {
4160
+ const rightCreated =
4161
+ Date.parse(right?.createdAt || right?.finishedAt || '') || 0;
4162
+ const leftCreated =
4163
+ Date.parse(left?.createdAt || left?.finishedAt || '') || 0;
4164
+ return rightCreated - leftCreated;
4165
+ });
4166
+ for (const deployment of ordered) {
4167
+ const validation = inspectSucceededPreproductionDeployment(
4168
+ deployment,
4169
+ context,
4170
+ now
4171
+ );
4172
+ if (validation.accepted) return deployment;
4173
+ }
4174
+ return null;
4175
+ }
4176
+
4177
+ function managedPreproductionEvidenceLineage(
4178
+ deployment,
4179
+ candidate,
4180
+ target
4181
+ ) {
4182
+ return {
4183
+ deploymentId: deployment.id,
4184
+ candidateId: candidate.id,
4185
+ candidateHash: candidate.candidateHash,
4186
+ testPlanHash: candidate.testPlanHash || null,
4187
+ environmentId: target.environmentId,
4188
+ environmentKind: target.environmentKind,
4189
+ appType: target.appType,
4190
+ appReleaseId: deployment.targetAppReleaseId,
4191
+ evidenceHash: deployment.evidenceHash || null,
4192
+ };
4193
+ }
4194
+
4195
+ function recoveredPreproductionShipPatch(
4196
+ resolution,
4197
+ candidate,
4198
+ target,
4199
+ recoveredAt = new Date().toISOString()
4200
+ ) {
4201
+ const deployment = resolution.deployment;
4202
+ return {
4203
+ status: 'awaiting_production_confirmation',
4204
+ preproductionDeploymentId: deployment.id,
4205
+ preproductionAppReleaseId: deployment.targetAppReleaseId,
4206
+ preproductionStatus: deployment.status,
4207
+ preproductionEvidenceHash: deployment.evidenceHash,
4208
+ preproductionEvidenceLineage: managedPreproductionEvidenceLineage(
4209
+ deployment,
4210
+ candidate,
4211
+ target
4212
+ ),
4213
+ preproductionRecovery: {
4214
+ status: 'recovered_from_candidate_history',
4215
+ replacedDeploymentId: resolution.replacedDeploymentId,
4216
+ replacedDeploymentStatus: resolution.replacedDeploymentStatus,
4217
+ selectedDeploymentId: deployment.id,
4218
+ recoveredAt,
4219
+ },
4220
+ failure: null,
4221
+ };
4222
+ }
4223
+
4224
+ async function resolvePreproductionDeploymentForAcceptance(
4225
+ config,
4226
+ target,
4227
+ logicalAppCode,
4228
+ originalDeploymentId,
4229
+ candidate
4230
+ ) {
4231
+ await assertRemoteCandidateMatches(
4232
+ config,
4233
+ target,
4234
+ logicalAppCode,
4235
+ candidate
4236
+ );
4237
+ const status = await requestWithAuth(
4238
+ config,
4239
+ target.profileName,
4240
+ environmentSetApiPath(logicalAppCode, '/status')
4241
+ );
4242
+ const targetStatus = (status?.environments || []).find(
4243
+ environment =>
4244
+ environment?.id === target.environmentId &&
4245
+ environment?.kind === 'preproduction' &&
4246
+ environment?.appType === target.appType
4247
+ );
4248
+ const currentAppReleaseId = String(
4249
+ targetStatus?.heads?.appReleaseId || ''
4250
+ ).trim();
4251
+ if (!targetStatus || !currentAppReleaseId) {
4252
+ fail(
4253
+ `PREPRODUCTION_ACTIVE_HEAD_REQUIRED: 预发环境 ${target.environmentId} 缺少可验证的当前 AppRelease head`
4254
+ );
4255
+ }
4256
+
4257
+ let originalDeployment = null;
4258
+ let originalReadError = null;
4259
+ try {
4260
+ originalDeployment = await readManagedDeployment(
4261
+ config,
4262
+ target,
4263
+ logicalAppCode,
4264
+ originalDeploymentId,
4265
+ { full: true }
4266
+ );
4267
+ } catch (error) {
4268
+ originalReadError = error;
4269
+ }
4270
+ const context = { candidate, target, currentAppReleaseId };
4271
+ if (originalDeployment?.status === 'deployed') {
4272
+ const identityMatches =
4273
+ originalDeployment.kind === 'deploy' &&
4274
+ originalDeployment.candidateId === candidate.id &&
4275
+ originalDeployment.targetEnvironmentId === target.environmentId &&
4276
+ originalDeployment.targetAppReleaseId === currentAppReleaseId;
4277
+ if (identityMatches) {
4278
+ return {
4279
+ deployment: originalDeployment,
4280
+ recovered: false,
4281
+ currentAppReleaseId,
4282
+ };
4283
+ }
4284
+ }
4285
+ if (originalDeployment?.status === 'succeeded') {
4286
+ const validation = inspectSucceededPreproductionDeployment(
4287
+ originalDeployment,
4288
+ context
4289
+ );
4290
+ if (validation.accepted) {
4291
+ return {
4292
+ deployment: originalDeployment,
4293
+ recovered: false,
4294
+ currentAppReleaseId,
4295
+ };
4296
+ }
4297
+ }
4298
+
4299
+ const history = await requestWithAuth(
4300
+ config,
4301
+ target.profileName,
4302
+ `${applicationDeploymentApiPath(logicalAppCode)}?limit=200`
4303
+ );
4304
+ const summaries = (Array.isArray(history) ? history : [])
4305
+ .filter(
4306
+ deployment =>
4307
+ deployment?.id !== originalDeploymentId &&
4308
+ deployment?.status === 'succeeded' &&
4309
+ deployment?.kind === 'deploy' &&
4310
+ deployment?.candidateId === candidate.id &&
4311
+ deployment?.targetEnvironmentId === target.environmentId &&
4312
+ deployment?.targetAppReleaseId === currentAppReleaseId
4313
+ )
4314
+ .sort((left, right) => {
4315
+ const rightCreated = Date.parse(right?.createdAt || '') || 0;
4316
+ const leftCreated = Date.parse(left?.createdAt || '') || 0;
4317
+ return rightCreated - leftCreated;
4318
+ });
4319
+ const details = [];
4320
+ for (const summary of summaries) {
4321
+ const detail = await requestWithAuth(
4322
+ config,
4323
+ target.profileName,
4324
+ `${applicationDeploymentApiPath(
4325
+ logicalAppCode,
4326
+ summary.id
4327
+ )}?full=true`
4328
+ );
4329
+ details.push(detail);
4330
+ }
4331
+ const replacement = selectSucceededPreproductionDeployment(
4332
+ details,
4333
+ context
4334
+ );
4335
+ if (!replacement) {
4336
+ const originalStatus = originalDeployment?.status || 'unreadable';
4337
+ const error = new Error(
4338
+ `PREPRODUCTION_NOT_READY_FOR_ACCEPTANCE: 预发 deployment ${originalDeploymentId} 当前状态为 ${originalStatus},且未找到同 candidate、同环境、当前 AppRelease 和有效测试证据完全一致的 succeeded replacement`
4339
+ );
4340
+ error.code = 'PREPRODUCTION_NOT_READY_FOR_ACCEPTANCE';
4341
+ error.data = {
4342
+ originalDeploymentId,
4343
+ originalStatus,
4344
+ originalReadErrorCode: originalReadError?.code || null,
4345
+ candidateId: candidate.id,
4346
+ targetEnvironmentId: target.environmentId,
4347
+ currentAppReleaseId,
4348
+ };
4349
+ throw error;
4350
+ }
4351
+ rememberTargetDeployment(target, replacement);
4352
+ return {
4353
+ deployment: replacement,
4354
+ recovered: true,
4355
+ replacedDeploymentId: originalDeploymentId,
4356
+ replacedDeploymentStatus: originalDeployment?.status || 'unreadable',
4357
+ currentAppReleaseId,
4358
+ };
4359
+ }
4360
+
4031
4361
  async function completeManagedDeployment(
4032
4362
  config,
4033
4363
  target,
@@ -4115,6 +4445,7 @@ const RELEASE_SHIP_SUPPORTED_FLAGS = new Set([
4115
4445
  'since',
4116
4446
  'expand-dependencies',
4117
4447
  'replace-manifest',
4448
+ 'allow-runtime-rollback',
4118
4449
  'reason',
4119
4450
  'adopt-online-baseline',
4120
4451
  'adoption-reason',
@@ -4139,12 +4470,19 @@ function assertReleaseShipFlags(flags = {}) {
4139
4470
  throw error;
4140
4471
  }
4141
4472
  const replacementRequested = Boolean(flags['replace-manifest']);
4473
+ const runtimeRollbackRequested = Boolean(
4474
+ flags['allow-runtime-rollback']
4475
+ );
4142
4476
  const replacementReason = String(flags.reason || '').trim();
4143
- if (!replacementRequested && replacementReason) {
4477
+ if (
4478
+ !replacementRequested &&
4479
+ !runtimeRollbackRequested &&
4480
+ replacementReason
4481
+ ) {
4144
4482
  const error = new Error(
4145
- 'RELEASE_SHIP_REPLACE_MANIFEST_FLAG_REQUIRED: --reason 必须与 --replace-manifest 一起使用;命令已在任何 candidate/deployment 写入前终止'
4483
+ 'RELEASE_SHIP_SCOPED_REASON_FLAG_REQUIRED: --reason 必须与 --replace-manifest 或 --allow-runtime-rollback 一起使用;命令已在任何 candidate/deployment 写入前终止'
4146
4484
  );
4147
- error.code = 'RELEASE_SHIP_REPLACE_MANIFEST_FLAG_REQUIRED';
4485
+ error.code = 'RELEASE_SHIP_SCOPED_REASON_FLAG_REQUIRED';
4148
4486
  throw error;
4149
4487
  }
4150
4488
  if (replacementRequested && replacementReason.length < 8) {
@@ -4154,6 +4492,13 @@ function assertReleaseShipFlags(flags = {}) {
4154
4492
  error.code = 'RELEASE_SHIP_REPLACE_MANIFEST_REASON_REQUIRED';
4155
4493
  throw error;
4156
4494
  }
4495
+ if (runtimeRollbackRequested && replacementReason.length < 8) {
4496
+ const error = new Error(
4497
+ 'RELEASE_SHIP_RUNTIME_ROLLBACK_REASON_REQUIRED: --allow-runtime-rollback 必须提供至少 8 个字符的 --reason;命令已在任何 candidate/deployment 写入前终止'
4498
+ );
4499
+ error.code = 'RELEASE_SHIP_RUNTIME_ROLLBACK_REASON_REQUIRED';
4500
+ throw error;
4501
+ }
4157
4502
  const adoptionRequested = Boolean(flags['adopt-online-baseline']);
4158
4503
  const adoptionReason = String(flags['adoption-reason'] || '').trim();
4159
4504
  if (!adoptionRequested && adoptionReason) {
@@ -4172,6 +4517,13 @@ function assertReleaseShipFlags(flags = {}) {
4172
4517
  }
4173
4518
  }
4174
4519
 
4520
+ function assertManagedRuntimeRollbackFlags(flags = {}) {
4521
+ return resolveRuntimeRollbackBypass({
4522
+ allowRollback: flags['allow-runtime-rollback'],
4523
+ reason: flags.reason,
4524
+ });
4525
+ }
4526
+
4175
4527
  async function runApplicationShipCommand(
4176
4528
  config,
4177
4529
  initialTarget,
@@ -4183,7 +4535,7 @@ async function runApplicationShipCommand(
4183
4535
  readStringFlag(flags, 'change') || positional[0];
4184
4536
  if (!changeId) {
4185
4537
  fail(
4186
- '用法: openxiangda release ship --change <id> [--replace-manifest --reason "..."] [--adopt-online-baseline --adoption-reason "..."] [--confirm-production] [--acceptance-note "..."]'
4538
+ '用法: openxiangda release ship --change <id> [--replace-manifest] [--allow-runtime-rollback] [--reason "..."] [--adopt-online-baseline --adoption-reason "..."] [--confirm-production] [--acceptance-note "..."]'
4187
4539
  );
4188
4540
  }
4189
4541
  const logicalApp = requireLogicalApplication(initialTarget);
@@ -4203,6 +4555,7 @@ async function runApplicationShipCommand(
4203
4555
  const {
4204
4556
  manifestReplacement,
4205
4557
  baselineAdoption,
4558
+ runtimeRollback,
4206
4559
  effectiveFlags,
4207
4560
  } = intents;
4208
4561
 
@@ -4251,6 +4604,7 @@ async function runApplicationShipCommand(
4251
4604
  candidateFile: created.candidateFile,
4252
4605
  manifestReplacement,
4253
4606
  baselineAdoption,
4607
+ runtimeRollback,
4254
4608
  });
4255
4609
  } else {
4256
4610
  assertLocalCandidate(candidate);
@@ -4288,6 +4642,7 @@ async function runApplicationShipCommand(
4288
4642
  preproductionStatus: failure.deploymentStatus,
4289
4643
  manifestReplacement,
4290
4644
  baselineAdoption,
4645
+ runtimeRollback,
4291
4646
  failure,
4292
4647
  nextCommand: buildManagedShipCommand(
4293
4648
  changeId,
@@ -4310,6 +4665,7 @@ async function runApplicationShipCommand(
4310
4665
  preproductionStatus: deployment.status,
4311
4666
  manifestReplacement,
4312
4667
  baselineAdoption,
4668
+ runtimeRollback,
4313
4669
  failure: null,
4314
4670
  preparedAt:
4315
4671
  existingShip?.preparedAt ||
@@ -4340,21 +4696,25 @@ async function runApplicationShipCommand(
4340
4696
  );
4341
4697
  const candidate = readCandidate(existingShip.candidateId);
4342
4698
  assertLocalCandidate(candidate);
4343
- let preproductionDeployment = await readManagedDeployment(
4699
+ const preproductionResolution = await resolvePreproductionDeploymentForAcceptance(
4344
4700
  config,
4345
4701
  preproduction,
4346
4702
  logicalApp.code,
4347
4703
  existingShip.preproductionDeploymentId,
4348
- { full: true }
4704
+ candidate
4349
4705
  );
4350
- if (
4351
- !['deployed', 'succeeded'].includes(
4352
- preproductionDeployment.status
4353
- ) ||
4354
- !preproductionDeployment.targetAppReleaseId
4355
- ) {
4356
- fail(
4357
- `PREPRODUCTION_NOT_READY_FOR_ACCEPTANCE: 预发 deployment ${preproductionDeployment.id} 当前状态为 ${preproductionDeployment.status}`
4706
+ let preproductionDeployment = preproductionResolution.deployment;
4707
+ if (preproductionResolution.recovered) {
4708
+ writeManagedShip(
4709
+ changeId,
4710
+ recoveredPreproductionShipPatch(
4711
+ preproductionResolution,
4712
+ candidate,
4713
+ preproduction
4714
+ )
4715
+ );
4716
+ warn(
4717
+ `预发验收已从同 candidate 的成功部署恢复: ${preproductionResolution.replacedDeploymentId} -> ${preproductionDeployment.id}`
4358
4718
  );
4359
4719
  }
4360
4720
 
@@ -4427,6 +4787,15 @@ async function runApplicationShipCommand(
4427
4787
  const promotionStartedAt = new Date().toISOString();
4428
4788
  writeManagedShip(changeId, {
4429
4789
  status: 'confirmed_for_production',
4790
+ preproductionDeploymentId: preproductionDeployment.id,
4791
+ preproductionAppReleaseId: preproductionDeployment.targetAppReleaseId,
4792
+ preproductionStatus: preproductionDeployment.status,
4793
+ preproductionEvidenceHash: preproductionDeployment.evidenceHash,
4794
+ preproductionEvidenceLineage: managedPreproductionEvidenceLineage(
4795
+ preproductionDeployment,
4796
+ candidate,
4797
+ preproduction
4798
+ ),
4430
4799
  productionConfirmation: {
4431
4800
  status: 'confirmed',
4432
4801
  confirmedAt,
@@ -4464,6 +4833,7 @@ async function runApplicationShipCommand(
4464
4833
  productionStatus: failure.deploymentStatus,
4465
4834
  manifestReplacement,
4466
4835
  baselineAdoption,
4836
+ runtimeRollback,
4467
4837
  failure,
4468
4838
  productionPromotion: {
4469
4839
  status: 'failed',
@@ -4528,6 +4898,7 @@ async function runApplicationShipCommand(
4528
4898
  productionStatus: promoted.deployment.status,
4529
4899
  manifestReplacement,
4530
4900
  baselineAdoption,
4901
+ runtimeRollback,
4531
4902
  failure,
4532
4903
  productionPromotion: {
4533
4904
  status: 'evidence_failed',
@@ -4635,6 +5006,10 @@ async function runApplicationEnvironmentReleaseCommand(
4635
5006
  return { deployment: reconciled, reconciled: true };
4636
5007
  }
4637
5008
 
5009
+ if (['deploy', 'promote'].includes(subcommand)) {
5010
+ assertManagedRuntimeRollbackFlags(flags);
5011
+ }
5012
+
4638
5013
  const candidateId =
4639
5014
  readStringFlag(flags, 'candidate') ||
4640
5015
  readStringFlag(flags, 'candidate-id') ||
@@ -4756,13 +5131,14 @@ async function release(args) {
4756
5131
  if (wantsSubcommandHelp(subcommand, flags)) {
4757
5132
  print([
4758
5133
  '用法: openxiangda release publish|begin|status|explain|integration-status|renew|end [--change id] [--profile name] [--json]',
4759
- ' openxiangda release ship|candidate|deploy|reconcile|test|promote|rollback [--candidate id] [--environment target] [--replace-manifest --reason text] [--adopt-online-baseline --adoption-reason text] [--confirm-production] [--json]',
5134
+ ' openxiangda release ship|candidate|deploy|reconcile|test|promote|rollback [--candidate id] [--environment target] [--replace-manifest] [--allow-runtime-rollback --reason text] [--adopt-online-baseline --adoption-reason text] [--confirm-production] [--json]',
4760
5135
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
4761
5136
  ' openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-post-commit|app-retry|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--break-glass-adopt-verified-root --reason text] [--force-activate-without-validation] [--profile name] [--json]',
4762
5137
  '常用流程:',
4763
5138
  ' openxiangda release ship --change <id> --profile <name>',
4764
5139
  ' openxiangda release ship --change <id> --profile <name> --adopt-online-baseline --adoption-reason "已审计目标来自多次历史发布"',
4765
5140
  ' openxiangda release ship --change <id> --profile <name> --replace-manifest --reason "已审查精确 Function manifest"',
5141
+ ' openxiangda release ship --change <id> --profile <name> --allow-runtime-rollback --reason "已审计并批准 Runtime 源码基线回退"',
4766
5142
  ' # 确认预发结果后;可选 --acceptance-note 留下人工验收记录:',
4767
5143
  ' openxiangda release ship --change <id> --profile <name> --confirm-production',
4768
5144
  ' openxiangda release publish --change <id> --profile <name>',
@@ -4780,6 +5156,7 @@ async function release(args) {
4780
5156
  ' - candidate 封存环境/资源绑定和两目标 Runtime 哈希产物;预发与正式部署均不现场重建,并以 deployment succeeded 闭环。',
4781
5157
  ' - 已合入主线但线上来自多次历史 lineage 的精确非删除恢复可显式使用 --adopt-online-baseline 与 --adoption-reason;预发冻结后,正式确认会从 ship.json 自动复用同一意图,仍保留冻结 Head、CAS、租约、staged children 和单次 App finalize。',
4782
5158
  ' - ship 的 --replace-manifest 与 --reason 必须成对,只透传给精确 Backend Function/Automation stage;正式确认必须复用与预发完全相同的参数。',
5159
+ ' - --allow-runtime-rollback 与 --reason 必须成对,只透传给 runtime-stage;ship 会冻结并在正式确认自动复用该审计意图,app-finalize 不会收到这些参数。',
4783
5160
  ' - ship 当前不支持 --dry-run;任何未声明参数都会在 candidate/deployment 写入前 fail-closed。需要只读检查时使用 environment status/diff 与 release status。',
4784
5161
  ' - publish 默认等待租约并按私有执行日志恢复;上次写结果不确定时必须只读核对后显式 --resume-after-review。',
4785
5162
  ' - app-head 默认只输出紧凑 head 摘要;需要完整 manifest 时显式追加 --full。',
@@ -30146,8 +30523,11 @@ module.exports = {
30146
30523
  buildScopeGrantSourceSyncDecision,
30147
30524
  deploymentEvidencePendingError,
30148
30525
  deploymentQueueWaitDecision,
30526
+ inspectSucceededPreproductionDeployment,
30149
30527
  main,
30528
+ recoveredPreproductionShipPatch,
30150
30529
  resolveReleaseCommandScopedFiles,
30530
+ selectSucceededPreproductionDeployment,
30151
30531
  resolveDeliveryV2BackendManifestReplacementIntent,
30152
30532
  resolveDataViewSourceFormCodes,
30153
30533
  satisfiedFormCodesFromContext,
@@ -135,6 +135,8 @@ For an audited catch-up whose exact non-delete targets are already merged but wh
135
135
 
136
136
  When an environment-managed release intentionally replaces complete Function/Automation manifests, `release ship` may add the inseparable `--replace-manifest --reason "..."` pair. The reason must be at least 8 characters; only an exact Backend selector receives it, and the production confirmation must repeat the exact preproduction pair. It never widens Form, Workflow, Runtime, configuration, wildcard, or app-wide stages.
137
137
 
138
+ When the sealed Runtime source intentionally does not descend from the active Runtime lineage, managed `release ship`, recovery `release deploy`, and `release promote` may add `--allow-runtime-rollback --reason "..."`. The reason must be at least 8 characters. The CLI forwards the pair only to `runtime-stage`, never to Backend/Form/Workflow/configuration or `app-finalize`; ship freezes the pair in `ship.json`, and production confirmation automatically inherits it. Without the explicit pair, Runtime lineage remains fail closed.
139
+
138
140
  `resource plan` and publish dry-runs are strictly GET/HEAD-only. `READ_ONLY_AUTH_REQUIRED` means the access token expired; run `openxiangda auth refresh --profile <name>` or log in again before retrying. Never add an automatic refresh POST inside a plan.
139
141
 
140
142
  `release publish` is the default promotion entrypoint only for legacy unmanaged workspaces. It verifies without rewriting reviewed `change.json`/`release.json`, waits for the app lease, freezes the App capture after ownership is acquired, executes deterministic exact staged steps, resumes from `.openxiangda/releases/<change>/execution.json`, atomically finalizes, verifies mainline integration, and releases the lease. Environment-managed applications use the two-phase `release ship`; candidate/deploy/test/promote, `release begin`, and child commands remain recovery/diagnostic primitives.
@@ -138,6 +138,8 @@ When a reviewed catch-up contains exact non-delete targets that are already on a
138
138
 
139
139
  An environment-managed release may intentionally replace complete Function/Automation manifests with `release ship --replace-manifest --reason "..."`. The two flags are inseparable, the reason is at least 8 characters, only an exact Backend stage receives them, and production confirmation must repeat the exact preproduction intent. They never widen Form, Workflow, Runtime, configuration, wildcard, or app-wide stages.
140
140
 
141
+ For an audited Runtime source rollback, managed `release ship`, recovery `release deploy`, and `release promote` accept `--allow-runtime-rollback --reason "..."` with a reason of at least 8 characters. This pair is scoped only to `runtime-stage` and never reaches resource stages or `app-finalize`. Ship freezes it in `ship.json`; production confirmation inherits it automatically. The default remains fail closed.
142
+
141
143
  `release publish` is the normal whole-app entrypoint for legacy unmanaged workspaces: it verifies SDD without mutating reviewed files, waits for the promotion lease, freezes one App capture, stages the exact Form/Backend/Runtime children, resumes from a private execution journal, finalizes once, and releases the lease. Managed applications use two-phase `release ship` and its deployment-scoped journal. Individual candidate/deploy/test/promote commands are recovery/diagnostic primitives.
142
144
 
143
145
  Reviewed bundle commands may retain `<profile>` as a template. The explicit real `release publish --profile <name>` value is bound to actual child argv without rewriting tracked SDD. React SPA page codes remain logical coverage targets and activate through the single Runtime child; they do not require PageRelease. If local lease state disappears, `release end --change <id>` reconciles a self-owned remote lease from the private execution journal and never reports inactive while a remote lease is active.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.242",
3
+ "version": "1.0.244",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -80,7 +80,7 @@
80
80
  "test:dependency-capsule": "node scripts/dependency-capsule-smoke.mjs",
81
81
  "test:typed-resource-plan": "node scripts/typed-resource-plan-smoke.mjs",
82
82
  "test:integration-bundle": "node scripts/integration-bundle-smoke.mjs",
83
- "prepublishOnly": "npm run test:critical && node scripts/release-mainline-guard.mjs",
83
+ "prepublishOnly": "npm run release:evidence:verify && node scripts/release-mainline-guard.mjs",
84
84
  "prepack": "npm run source:admin-list && npm run build:sdk",
85
85
  "test:profile-isolation": "bash scripts/profile-isolation-smoke.sh",
86
86
  "test:resource-plan": "node scripts/resource-plan-smoke.mjs",
@@ -108,6 +108,8 @@
108
108
  "test:release-telemetry": "node scripts/release-telemetry-smoke.mjs",
109
109
  "test:release-explain": "node scripts/release-explain-smoke.mjs",
110
110
  "test:release-error-classification": "node scripts/release-error-classification-smoke.mjs",
111
+ "test:release-evidence": "node scripts/release-evidence-guard-smoke.mjs",
112
+ "release:evidence:verify": "node scripts/release-evidence-guard.mjs",
111
113
  "test:delivery-v2-package": "node scripts/delivery-v2-package-smoke.mjs",
112
114
  "test:delivery-v2-executor": "node scripts/delivery-v2-executor-smoke.mjs",
113
115
  "test:task-status": "node scripts/task-status-smoke.mjs",
@@ -66,6 +66,8 @@ openxiangda commands --json
66
66
 
67
67
  环境托管发布需要完整替换 Function/Automation manifest 时,`release ship` 可成对增加 `--replace-manifest --reason "..."`;reason 至少 8 字符,只会进入精确 Backend selector,正式确认必须复用与预发完全相同的参数,绝不扩散到表单、流程、Runtime、配置资源或全量范围。
68
68
 
69
+ 环境托管发布确需审计式 Runtime 源码血缘回退时,`release ship`、恢复命令 `release deploy` 和 `release promote` 可成对增加 `--allow-runtime-rollback --reason "..."`;reason 至少 8 字符,只进入 `runtime-stage`,不进入资源阶段或 `app-finalize`。ship 会冻结该意图,正式确认自动复用;省略时继续失败关闭。
70
+
69
71
  受支持的配置资源(如 `publicAccessPolicies`)以精确 `resourceSelectors` 为发布边界;历史 `resources=true` 类别标记会被精确 selector 收窄。缺失 selector、未知类型、通配符 `*`、删除和真正全量资源仍必须 fail closed。
70
72
 
71
73
  `resource plan` 与 publish dry-run 严格只允许 GET/HEAD。遇到 `READ_ONLY_AUTH_REQUIRED` 时,先执行 `openxiangda auth refresh --profile <name>` 或重新登录再重试;不得在 plan 内自动 POST 刷新 token。
@@ -68,6 +68,7 @@ Delivery V2 自动从期望状态计算精确资源范围,封存内容寻址 A
68
68
  - ✅ 托管发布完成后运行 `release integration-status --change <change> --profile <name> --check`。CLI 会从私有 `ship.json` 恢复血缘,必要时自动沿 production/preproduction deployment ID 查找对应 `execution.json`;失败信息必须指出实际缺失的日志或字段。
69
69
  - ✅ 只有已审计目标早已进入权威主线、线上却由多次历史 lineage 组成且无法对应单一 Git 基线时,第一次 `release ship` 才可增加 `--adopt-online-baseline --adoption-reason "..."`;该意图冻结进私有 `ship.json` 并由后续 `--confirm-production` 自动复用。仅允许精确非删除 selectors,冻结 Head、change/lease、服务端 CAS、staged children 与单次 App finalize 仍是硬门禁。
70
70
  - ✅ 环境托管发布需要完整替换 Function/Automation manifest 时,`release ship` 可成对增加 `--replace-manifest --reason "..."`;reason 至少 8 字符,只透传精确 Backend selector,正式确认必须复用同一对参数,不扩散到 Form/Workflow/Runtime/配置或全量范围。
71
+ - ✅ 环境托管发布确需审计式 Runtime 源码血缘回退时,`release ship`、恢复命令 `release deploy` 和 `release promote` 可成对增加 `--allow-runtime-rollback --reason "..."`;reason 至少 8 字符,只透传 `runtime-stage`,不进入资源阶段或 `app-finalize`。ship 冻结该意图并由正式确认自动复用;默认仍失败关闭。
71
72
  - ✅ 受支持的配置资源(如 `publicAccessPolicies`)以精确 `resourceSelectors` 为边界;历史 `resources=true` 类别标记会被精确 selector 收窄。缺失 selector、未知类型、通配符 `*`、删除和真正全量资源仍 fail closed。
72
73
  - ✅ 本地开发者可运行 `openxiangda studio` 查看两套环境、差异、候选、部署和测试证据;该页面只监听回环地址且只暴露注册动作,生产操作仍需显式确认。
73
74