openxiangda 1.0.177 → 1.0.178
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 +7 -1
- package/lib/cli.js +299 -85
- package/lib/form-field-contract.js +79 -0
- package/lib/integration-bundle.js +23 -2
- package/lib/release-error-classification.js +41 -0
- package/lib/sdd.js +131 -0
- package/lib/source-dependencies.js +190 -4
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +2 -0
- package/openxiangda-skills/skills/openxiangda-form/SKILL.md +2 -2
- package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +8 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -58,9 +58,15 @@ User tokens are stored in `~/.openxiangda/profiles.json` with `0600` permissions
|
|
|
58
58
|
|
|
59
59
|
`resource plan` and `resource publish --dry-run` run behind a strict GET/HEAD-only HTTP guard. If a read receives HTTP 401, the command fails with `READ_ONLY_AUTH_REQUIRED` and never calls the token refresh POST from inside the plan. Run `openxiangda auth refresh --profile <name>` (or log in again) before retrying; a plan must not mutate auth state or platform resources.
|
|
60
60
|
|
|
61
|
+
For Function/Automation plans, `formFieldContracts` checks statically declared
|
|
62
|
+
Form filter/order fields against frozen online Form schemas. A missing binding
|
|
63
|
+
or field makes publish fail before lease/write; dynamic field names are checked
|
|
64
|
+
again by the platform before SQL and return `FORM_FIELD_NOT_FOUND` instead of a
|
|
65
|
+
database-column error.
|
|
66
|
+
|
|
61
67
|
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.
|
|
62
68
|
|
|
63
|
-
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. 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.
|
|
69
|
+
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.
|
|
64
70
|
|
|
65
71
|
Because promotion starts from an already-pushed authoritative mainline commit, `openxiangda release integration-status --profile <name>` should pass immediately after activation. Run it and `release end`; there is no post-release merge step.
|
|
66
72
|
|
package/lib/cli.js
CHANGED
|
@@ -82,6 +82,10 @@ const {
|
|
|
82
82
|
writeContentCache,
|
|
83
83
|
} = require('./content-cache');
|
|
84
84
|
const { explainReleaseExecution } = require('./release-explain');
|
|
85
|
+
const {
|
|
86
|
+
classifyReleaseExecutionFailure,
|
|
87
|
+
isDefinitelyPreWriteReleaseError,
|
|
88
|
+
} = require('./release-error-classification');
|
|
85
89
|
const { buildTaskStatus, findAppReleaseId } = require('./task-status');
|
|
86
90
|
const {
|
|
87
91
|
checkEngineeringPolicyMarkers,
|
|
@@ -94,6 +98,10 @@ const {
|
|
|
94
98
|
getImpactedResourceTargets,
|
|
95
99
|
validateManifestSourceBindings,
|
|
96
100
|
} = require('./source-dependencies');
|
|
101
|
+
const {
|
|
102
|
+
collectFormSnapshotFieldIds,
|
|
103
|
+
validateFormFieldContracts,
|
|
104
|
+
} = require('./form-field-contract');
|
|
97
105
|
const { getSkillStatusReport, installSkills } = require('./skills');
|
|
98
106
|
const {
|
|
99
107
|
assertOrClaimWorktreeOwner,
|
|
@@ -705,7 +713,7 @@ async function sdd(args) {
|
|
|
705
713
|
' openxiangda sdd quick tune-customer-copy --kind copy --pages customer_list --files src/pages/customer_list/index.tsx --summary "用户确认小范围文案调整"',
|
|
706
714
|
' openxiangda sdd approve add-customer-list --summary "用户确认设计"',
|
|
707
715
|
' openxiangda sdd ready add-customer-list',
|
|
708
|
-
' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log',
|
|
716
|
+
' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log [--source-base-ref <commit>]',
|
|
709
717
|
' openxiangda sdd context --change add-customer-list --json',
|
|
710
718
|
' openxiangda sdd verify --change add-customer-list --stage prepublish --json',
|
|
711
719
|
' openxiangda sdd render add-customer-list # 仅在确实需要完整文档时',
|
|
@@ -720,6 +728,7 @@ async function sdd(args) {
|
|
|
720
728
|
' - verify stage 可选 implementation、prepublish、postpublish、archive;不传保持旧版全量校验。',
|
|
721
729
|
' - 默认 streamlined 校验只把 approval、精确资源/文件范围、实际发布命令作为硬门禁;任务、证据和规格文案仅告警。配置 strictDocumentation: true 可恢复严格文档门禁。',
|
|
722
730
|
' - bundle 聚合多个 approved change;提交并推送该 bundle 后,在权威主分支执行一次 staged + atomic 发布。',
|
|
731
|
+
' - bundle 默认保留来源 change 创建时的共同 Git 基线;仅迁移旧 change 缺少 sourceBase 时使用 --source-base-ref 显式补充。',
|
|
723
732
|
' - 新 release 只能从已推送且与权威远端默认主分支一致的 clean HEAD 开始。',
|
|
724
733
|
].join('\n'));
|
|
725
734
|
return;
|
|
@@ -800,6 +809,7 @@ async function sdd(args) {
|
|
|
800
809
|
changes: flags.changes,
|
|
801
810
|
integration,
|
|
802
811
|
profile: flags.profile,
|
|
812
|
+
sourceBaseRef: flags['source-base-ref'],
|
|
803
813
|
force: Boolean(flags.force),
|
|
804
814
|
});
|
|
805
815
|
if (flags.json) return writeJson(result);
|
|
@@ -824,6 +834,7 @@ async function sdd(args) {
|
|
|
824
834
|
changeId,
|
|
825
835
|
targets: scope.targets,
|
|
826
836
|
changedFiles: scope.changedFiles,
|
|
837
|
+
sourceBase: scope.change?.sourceBase,
|
|
827
838
|
});
|
|
828
839
|
const ownerStatus = getWorktreeOwnerStatus({ cwd: process.cwd() });
|
|
829
840
|
if (
|
|
@@ -1138,30 +1149,6 @@ function releaseExecutionContextFromBegin(result, target, changeId) {
|
|
|
1138
1149
|
};
|
|
1139
1150
|
}
|
|
1140
1151
|
|
|
1141
|
-
function isDefinitelyPreWriteReleaseError(error) {
|
|
1142
|
-
const code = String(error?.code || '');
|
|
1143
|
-
if (new Set([
|
|
1144
|
-
'RELEASE_PUBLISH_REVISION_CHANGED',
|
|
1145
|
-
'RELEASE_SOURCE_DIRTY',
|
|
1146
|
-
'RELEASE_SOURCE_BRANCH_REQUIRED',
|
|
1147
|
-
'RELEASE_SOURCE_MAINLINE_REQUIRED',
|
|
1148
|
-
'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
|
|
1149
|
-
'RELEASE_SOURCE_BEHIND_MAIN',
|
|
1150
|
-
'RELEASE_GIT_AUTH_REQUIRED',
|
|
1151
|
-
'RELEASE_MAIN_BRANCH_UNRESOLVED',
|
|
1152
|
-
'RELEASE_MAIN_REF_UNVERIFIED',
|
|
1153
|
-
'SDD_PREPUBLISH_FAILED',
|
|
1154
|
-
'PUBLISH_CONTEXT_REQUIRED',
|
|
1155
|
-
'APP_RELEASE_STAGED_SCOPE_REQUIRED',
|
|
1156
|
-
'APP_RELEASE_STAGED_SCOPE_MISMATCH',
|
|
1157
|
-
]).has(code)) {
|
|
1158
|
-
return true;
|
|
1159
|
-
}
|
|
1160
|
-
return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
|
|
1161
|
-
code
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
1152
|
function parseChildJsonOutput(stdout) {
|
|
1166
1153
|
const text = String(stdout || '').trim();
|
|
1167
1154
|
if (!text) return null;
|
|
@@ -1691,11 +1678,11 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
|
|
|
1691
1678
|
execution.writeAttempted = true;
|
|
1692
1679
|
}
|
|
1693
1680
|
}
|
|
1694
|
-
execution.status =
|
|
1695
|
-
|
|
1696
|
-
: execution.
|
|
1697
|
-
|
|
1698
|
-
|
|
1681
|
+
execution.status = classifyReleaseExecutionFailure({
|
|
1682
|
+
error,
|
|
1683
|
+
stagedWriteOccurred: execution.stagedWriteOccurred,
|
|
1684
|
+
writeAttempted: execution.writeAttempted,
|
|
1685
|
+
});
|
|
1699
1686
|
execution.updatedAt = new Date().toISOString();
|
|
1700
1687
|
writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
|
|
1701
1688
|
if (!execution.writeAttempted) {
|
|
@@ -3822,9 +3809,25 @@ function resolveChangeSourceBase(changeId, flags = {}) {
|
|
|
3822
3809
|
if (!explicitBaseRef && changeId) {
|
|
3823
3810
|
try {
|
|
3824
3811
|
const scope = getSddChangeScope({ cwd: process.cwd(), changeId });
|
|
3825
|
-
const recorded =
|
|
3812
|
+
const recorded =
|
|
3813
|
+
scope?.change?.sourceBase ||
|
|
3814
|
+
scope?.release?.sourceBase;
|
|
3826
3815
|
if (recorded?.baseCommit && recorded?.treeHash) return recorded;
|
|
3827
|
-
|
|
3816
|
+
const recordedBaseRevision =
|
|
3817
|
+
scope?.change?.baseRevision ||
|
|
3818
|
+
scope?.release?.baseRevision;
|
|
3819
|
+
if (recordedBaseRevision) {
|
|
3820
|
+
return readGitSourceBase(process.cwd(), recordedBaseRevision);
|
|
3821
|
+
}
|
|
3822
|
+
if (scope?.change?.changeMode === 'mainline-bundle') {
|
|
3823
|
+
const error = new Error(
|
|
3824
|
+
`SDD_SOURCE_BASE_REQUIRED: mainline bundle ${changeId} 缺少 sourceBase/baseRevision,禁止退化为当前 HEAD`
|
|
3825
|
+
);
|
|
3826
|
+
error.code = 'SDD_SOURCE_BASE_REQUIRED';
|
|
3827
|
+
throw error;
|
|
3828
|
+
}
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
if (error?.code === 'SDD_SOURCE_BASE_REQUIRED') throw error;
|
|
3828
3831
|
// A direct release may use --change without a local SDD record. In that
|
|
3829
3832
|
// case the current Git HEAD is still a deterministic, auditable base.
|
|
3830
3833
|
}
|
|
@@ -10494,6 +10497,18 @@ async function resource(args) {
|
|
|
10494
10497
|
error.missingSecretRefs = missing;
|
|
10495
10498
|
throw error;
|
|
10496
10499
|
}
|
|
10500
|
+
if (publishPlan.formFieldContracts?.valid === false) {
|
|
10501
|
+
const violations = publishPlan.formFieldContracts.errors || [];
|
|
10502
|
+
const error = new Error(
|
|
10503
|
+
`FORM_FIELD_CONTRACT_INVALID: ${violations
|
|
10504
|
+
.slice(0, 8)
|
|
10505
|
+
.map(item => item.message)
|
|
10506
|
+
.join('; ')}`
|
|
10507
|
+
);
|
|
10508
|
+
error.code = 'FORM_FIELD_CONTRACT_INVALID';
|
|
10509
|
+
error.violations = violations;
|
|
10510
|
+
throw error;
|
|
10511
|
+
}
|
|
10497
10512
|
const pruneCandidates = flags.prune
|
|
10498
10513
|
? await telemetry.runPhase('prune-plan', async () =>
|
|
10499
10514
|
collectPruneResourceCandidates(config, target, manifest)
|
|
@@ -15053,6 +15068,18 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
|
|
|
15053
15068
|
}
|
|
15054
15069
|
const target = getWorkspaceTarget(config, profileName, {});
|
|
15055
15070
|
const plan = await buildResourcePlan(config, target, manifest);
|
|
15071
|
+
if (plan.formFieldContracts?.valid === false) {
|
|
15072
|
+
const violations = plan.formFieldContracts.errors || [];
|
|
15073
|
+
const error = new Error(
|
|
15074
|
+
`FORM_FIELD_CONTRACT_INVALID: ${violations
|
|
15075
|
+
.slice(0, 8)
|
|
15076
|
+
.map(item => item.message)
|
|
15077
|
+
.join('; ')}`
|
|
15078
|
+
);
|
|
15079
|
+
error.code = 'FORM_FIELD_CONTRACT_INVALID';
|
|
15080
|
+
error.violations = violations;
|
|
15081
|
+
throw error;
|
|
15082
|
+
}
|
|
15056
15083
|
const pruneCandidates = options.prune
|
|
15057
15084
|
? await collectPruneResourceCandidates(config, target, manifest)
|
|
15058
15085
|
: [];
|
|
@@ -15088,7 +15115,10 @@ async function buildResourcePlan(config, target, manifest) {
|
|
|
15088
15115
|
manifest
|
|
15089
15116
|
);
|
|
15090
15117
|
await prepareManifestJsCodeBundlesForPlan(manifest);
|
|
15091
|
-
const existing = await
|
|
15118
|
+
const [existing, formFieldContracts] = await Promise.all([
|
|
15119
|
+
fetchExistingResourceMaps(config, target, manifest),
|
|
15120
|
+
buildFormFieldContractPlan(config, target, manifest),
|
|
15121
|
+
]);
|
|
15092
15122
|
const actions = [];
|
|
15093
15123
|
addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
|
|
15094
15124
|
addPlanActions(actions, 'menu', manifest.menus, existing.menus, (item, current) => menuEquals(target.bound, item, current));
|
|
@@ -15128,6 +15158,7 @@ async function buildResourcePlan(config, target, manifest) {
|
|
|
15128
15158
|
...(manifest.resourceCodeFilters ? { resourceCodeFilters: manifest.resourceCodeFilters } : {}),
|
|
15129
15159
|
actions,
|
|
15130
15160
|
summary: summarizeActions(actions),
|
|
15161
|
+
formFieldContracts,
|
|
15131
15162
|
...(functionSecretRefs
|
|
15132
15163
|
? {
|
|
15133
15164
|
capabilities: {
|
|
@@ -16518,7 +16549,14 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
|
|
|
16518
16549
|
}
|
|
16519
16550
|
|
|
16520
16551
|
await publishRoleResources(config, target, manifest.roles || [], result, publishOptions);
|
|
16521
|
-
await publishFormSettingsResources(
|
|
16552
|
+
await publishFormSettingsResources(
|
|
16553
|
+
config,
|
|
16554
|
+
target,
|
|
16555
|
+
manifest.formSettings || [],
|
|
16556
|
+
manifest.formPermissionGroups || [],
|
|
16557
|
+
result,
|
|
16558
|
+
publishOptions
|
|
16559
|
+
);
|
|
16522
16560
|
await publishMenuResources(config, target, manifest.menus || [], result, publishOptions);
|
|
16523
16561
|
await publishConnectorResources(config, target, manifest.connectors || [], result, publishOptions);
|
|
16524
16562
|
await publishNotificationResources(config, target, manifest.notifications || [], result, publishOptions);
|
|
@@ -16548,7 +16586,13 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
|
|
|
16548
16586
|
await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
|
|
16549
16587
|
await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
|
|
16550
16588
|
await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
|
|
16551
|
-
await publishFormPermissionGroupResources(
|
|
16589
|
+
await publishFormPermissionGroupResources(
|
|
16590
|
+
config,
|
|
16591
|
+
target,
|
|
16592
|
+
manifest.formPermissionGroups || [],
|
|
16593
|
+
result,
|
|
16594
|
+
publishOptions
|
|
16595
|
+
);
|
|
16552
16596
|
if (options.prune) {
|
|
16553
16597
|
await pruneResourceManifest(
|
|
16554
16598
|
config,
|
|
@@ -17939,22 +17983,82 @@ async function publishFormSettingsResources(
|
|
|
17939
17983
|
config,
|
|
17940
17984
|
target,
|
|
17941
17985
|
settingsItems,
|
|
17986
|
+
permissionGroups,
|
|
17942
17987
|
result,
|
|
17943
17988
|
options = {}
|
|
17944
17989
|
) {
|
|
17990
|
+
const settingsByForm = new Map();
|
|
17945
17991
|
for (const item of settingsItems) {
|
|
17946
17992
|
const code = item.code || item.formCode || item.formUuid;
|
|
17947
|
-
|
|
17948
|
-
|
|
17993
|
+
const formUuid = resolveManifestFormUuid(target.bound, item, {
|
|
17994
|
+
fallbackToCode: true,
|
|
17995
|
+
});
|
|
17996
|
+
if (!formUuid) fail(`表单设置 ${code} 无法解析 formUuid`);
|
|
17997
|
+
settingsByForm.set(formUuid, { item, code });
|
|
17998
|
+
}
|
|
17999
|
+
const changedGroupsByForm = new Map();
|
|
18000
|
+
options.formReleaseHandledPermissionCodes =
|
|
18001
|
+
options.formReleaseHandledPermissionCodes || new Set();
|
|
18002
|
+
for (const group of permissionGroups || []) {
|
|
18003
|
+
const code = group.code || group.resourceCode;
|
|
18004
|
+
if (shouldSkipNoopResource(options, 'formPermissionGroup', code)) {
|
|
18005
|
+
recordNoopResource(result, 'formPermissionGroup', code, options);
|
|
18006
|
+
options.formReleaseHandledPermissionCodes.add(code);
|
|
17949
18007
|
continue;
|
|
17950
18008
|
}
|
|
17951
|
-
const formUuid = resolveManifestFormUuid(target.bound,
|
|
17952
|
-
if (!formUuid)
|
|
17953
|
-
|
|
18009
|
+
const formUuid = resolveManifestFormUuid(target.bound, group);
|
|
18010
|
+
if (!formUuid) {
|
|
18011
|
+
fail(`表单权限组 ${code} 无法解析 formUuid,不能进入 FormRelease`);
|
|
18012
|
+
}
|
|
18013
|
+
if (!changedGroupsByForm.has(formUuid)) {
|
|
18014
|
+
changedGroupsByForm.set(formUuid, []);
|
|
18015
|
+
}
|
|
18016
|
+
changedGroupsByForm.get(formUuid).push(group);
|
|
18017
|
+
}
|
|
18018
|
+
const formUuids = unique([
|
|
18019
|
+
...settingsByForm.keys(),
|
|
18020
|
+
...changedGroupsByForm.keys(),
|
|
18021
|
+
]).sort();
|
|
18022
|
+
for (const formUuid of formUuids) {
|
|
18023
|
+
const setting = settingsByForm.get(formUuid);
|
|
18024
|
+
const item = setting?.item || null;
|
|
18025
|
+
const groups = changedGroupsByForm.get(formUuid) || [];
|
|
18026
|
+
const code =
|
|
18027
|
+
setting?.code ||
|
|
18028
|
+
groups[0]?.formCode ||
|
|
18029
|
+
groups[0]?.form ||
|
|
18030
|
+
formUuid;
|
|
18031
|
+
const settingNoop =
|
|
18032
|
+
item && shouldSkipNoopResource(options, 'formSetting', code);
|
|
18033
|
+
if (settingNoop) {
|
|
18034
|
+
recordNoopResource(result, 'formSetting', code, options);
|
|
18035
|
+
}
|
|
18036
|
+
if (settingNoop && groups.length === 0) continue;
|
|
18037
|
+
const action = item
|
|
18038
|
+
? getPlannedResourceAction(options, 'formSetting', code)
|
|
18039
|
+
: null;
|
|
17954
18040
|
const frozen =
|
|
17955
18041
|
frozenFormConfigFromPlanAction(action) ||
|
|
17956
18042
|
(await fetchFrozenFormConfig(config, target, formUuid));
|
|
17957
|
-
const bundleBody =
|
|
18043
|
+
const bundleBody =
|
|
18044
|
+
item && !settingNoop ? buildFormSettingBundleBody(item) : {};
|
|
18045
|
+
if (groups.length > 0) {
|
|
18046
|
+
bundleBody.permissionGroups = groups.map(group => {
|
|
18047
|
+
const body = {
|
|
18048
|
+
...withoutResourceMeta(group),
|
|
18049
|
+
resourceCode: group.code || group.resourceCode,
|
|
18050
|
+
name: group.name || group.code || group.resourceCode,
|
|
18051
|
+
type: group.type || 'view',
|
|
18052
|
+
roles: group.roles || [],
|
|
18053
|
+
};
|
|
18054
|
+
delete body.code;
|
|
18055
|
+
delete body.formCode;
|
|
18056
|
+
delete body.form;
|
|
18057
|
+
delete body.formUuid;
|
|
18058
|
+
return body;
|
|
18059
|
+
});
|
|
18060
|
+
bundleBody.permissionGroupsMode = 'merge';
|
|
18061
|
+
}
|
|
17958
18062
|
const activate = options.activateFormBundles === true;
|
|
17959
18063
|
const artifactId = stableFormArtifactId(
|
|
17960
18064
|
'config-bundle',
|
|
@@ -17975,7 +18079,19 @@ async function publishFormSettingsResources(
|
|
|
17975
18079
|
},
|
|
17976
18080
|
{ frozen, artifactId }
|
|
17977
18081
|
);
|
|
17978
|
-
|
|
18082
|
+
if (item) {
|
|
18083
|
+
saveResourceEntry(target, 'formSettings', code, { formUuid });
|
|
18084
|
+
}
|
|
18085
|
+
for (const group of groups) {
|
|
18086
|
+
const groupCode = group.code || group.resourceCode;
|
|
18087
|
+
saveFormPermissionGroupResource(
|
|
18088
|
+
target,
|
|
18089
|
+
groupCode,
|
|
18090
|
+
undefined,
|
|
18091
|
+
{ formUuid }
|
|
18092
|
+
);
|
|
18093
|
+
options.formReleaseHandledPermissionCodes.add(groupCode);
|
|
18094
|
+
}
|
|
17979
18095
|
const releaseId = data?.release?.id;
|
|
17980
18096
|
const contentHash = String(
|
|
17981
18097
|
data?.release?.contentHash || data?.contentHash || ''
|
|
@@ -18024,20 +18140,36 @@ async function publishFormSettingsResources(
|
|
|
18024
18140
|
delete result.stagedResource;
|
|
18025
18141
|
}
|
|
18026
18142
|
}
|
|
18027
|
-
|
|
18028
|
-
|
|
18029
|
-
|
|
18030
|
-
|
|
18031
|
-
|
|
18032
|
-
|
|
18033
|
-
|
|
18034
|
-
|
|
18035
|
-
|
|
18036
|
-
|
|
18037
|
-
|
|
18038
|
-
|
|
18039
|
-
|
|
18040
|
-
|
|
18143
|
+
if (item && !settingNoop) {
|
|
18144
|
+
result.published.push({
|
|
18145
|
+
kind: 'formSetting',
|
|
18146
|
+
code,
|
|
18147
|
+
action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
|
|
18148
|
+
formUuid,
|
|
18149
|
+
revision: data?.revision,
|
|
18150
|
+
activeFormReleaseHead: data?.activeFormReleaseHead,
|
|
18151
|
+
staged: Boolean(data?.staged),
|
|
18152
|
+
active: Boolean(data?.active),
|
|
18153
|
+
releaseStatus: data?.releaseStatus,
|
|
18154
|
+
releaseId,
|
|
18155
|
+
contentHash: contentHash || undefined,
|
|
18156
|
+
...(stagedResource ? { stagedResource } : {}),
|
|
18157
|
+
});
|
|
18158
|
+
}
|
|
18159
|
+
for (const group of groups) {
|
|
18160
|
+
result.published.push({
|
|
18161
|
+
kind: 'formPermissionGroup',
|
|
18162
|
+
code: group.code || group.resourceCode,
|
|
18163
|
+
action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
|
|
18164
|
+
formUuid,
|
|
18165
|
+
staged: Boolean(data?.staged),
|
|
18166
|
+
active: Boolean(data?.active),
|
|
18167
|
+
releaseStatus: data?.releaseStatus,
|
|
18168
|
+
releaseId,
|
|
18169
|
+
contentHash: contentHash || undefined,
|
|
18170
|
+
...(stagedResource ? { stagedResource } : {}),
|
|
18171
|
+
});
|
|
18172
|
+
}
|
|
18041
18173
|
}
|
|
18042
18174
|
}
|
|
18043
18175
|
|
|
@@ -19158,38 +19290,24 @@ async function findExistingPagePermissionGroup(config, target, code) {
|
|
|
19158
19290
|
|
|
19159
19291
|
async function publishFormPermissionGroupResources(config, target, groups, result, options = {}) {
|
|
19160
19292
|
for (const group of groups) {
|
|
19293
|
+
if (
|
|
19294
|
+
options.formReleaseHandledPermissionCodes?.has(
|
|
19295
|
+
group.code || group.resourceCode
|
|
19296
|
+
)
|
|
19297
|
+
) {
|
|
19298
|
+
continue;
|
|
19299
|
+
}
|
|
19161
19300
|
if (shouldSkipNoopResource(options, 'formPermissionGroup', group.code)) {
|
|
19162
19301
|
recordNoopResource(result, 'formPermissionGroup', group.code, options);
|
|
19163
19302
|
continue;
|
|
19164
19303
|
}
|
|
19165
|
-
const
|
|
19166
|
-
|
|
19167
|
-
|
|
19168
|
-
|
|
19169
|
-
|
|
19170
|
-
|
|
19171
|
-
|
|
19172
|
-
type: group.type || 'view',
|
|
19173
|
-
roles: group.roles || [],
|
|
19174
|
-
};
|
|
19175
|
-
delete body.code;
|
|
19176
|
-
delete body.formCode;
|
|
19177
|
-
delete body.form;
|
|
19178
|
-
const data = existing
|
|
19179
|
-
? await requestWithAuth(
|
|
19180
|
-
config,
|
|
19181
|
-
target.profileName,
|
|
19182
|
-
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups/${encodeURIComponent(existing.id)}`,
|
|
19183
|
-
{ method: 'POST', body }
|
|
19184
|
-
)
|
|
19185
|
-
: await requestWithAuth(
|
|
19186
|
-
config,
|
|
19187
|
-
target.profileName,
|
|
19188
|
-
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups`,
|
|
19189
|
-
{ method: 'POST', body }
|
|
19190
|
-
);
|
|
19191
|
-
if (data?.id) saveFormPermissionGroupResource(target, group.code, data.id, { formUuid, name: data.name, resourceCode: group.code });
|
|
19192
|
-
result.published.push({ kind: 'formPermissionGroup', code: group.code, action: existing ? 'update' : 'create', id: data?.id });
|
|
19304
|
+
const error = new Error(
|
|
19305
|
+
`FORM_PERMISSION_GROUP_STAGE_REQUIRED: ${
|
|
19306
|
+
group.code || group.resourceCode
|
|
19307
|
+
} 未进入 FormRelease,禁止回退为直接线上写入`
|
|
19308
|
+
);
|
|
19309
|
+
error.code = 'FORM_PERMISSION_GROUP_STAGE_REQUIRED';
|
|
19310
|
+
throw error;
|
|
19193
19311
|
}
|
|
19194
19312
|
}
|
|
19195
19313
|
|
|
@@ -24233,8 +24351,104 @@ async function runWorkspaceChildCommand(command, args, options = {}) {
|
|
|
24233
24351
|
error.code = options.failureCode || 'WORKSPACE_CHILD_COMMAND_FAILED';
|
|
24234
24352
|
error.exitCode = Number.isInteger(code) ? code : 1;
|
|
24235
24353
|
reject(error);
|
|
24236
|
-
});
|
|
24237
24354
|
});
|
|
24355
|
+
});
|
|
24356
|
+
}
|
|
24357
|
+
|
|
24358
|
+
async function buildFormFieldContractPlan(config, target, manifest) {
|
|
24359
|
+
const targets = [];
|
|
24360
|
+
for (const [manifestKey, kind] of [
|
|
24361
|
+
['functions', 'Function'],
|
|
24362
|
+
['automations', 'Automation'],
|
|
24363
|
+
['workflows', 'Workflow'],
|
|
24364
|
+
['jsCodeNodes', 'JsCodeNode'],
|
|
24365
|
+
]) {
|
|
24366
|
+
for (const item of manifest[manifestKey] || []) {
|
|
24367
|
+
const analysis = item.__sourceAnalysis;
|
|
24368
|
+
if (!analysis?.formFieldReferences?.length) continue;
|
|
24369
|
+
const code =
|
|
24370
|
+
item.code ||
|
|
24371
|
+
item.functionCode ||
|
|
24372
|
+
item.resourceCode ||
|
|
24373
|
+
item.scriptCode;
|
|
24374
|
+
for (const reference of analysis.formFieldReferences) {
|
|
24375
|
+
const formUuid = resolveManifestFormBindingForContract(
|
|
24376
|
+
target,
|
|
24377
|
+
item,
|
|
24378
|
+
reference.formCode
|
|
24379
|
+
);
|
|
24380
|
+
targets.push({
|
|
24381
|
+
kind,
|
|
24382
|
+
code,
|
|
24383
|
+
formCode: reference.formCode,
|
|
24384
|
+
formUuid,
|
|
24385
|
+
field: reference.field,
|
|
24386
|
+
api: reference.api,
|
|
24387
|
+
file: reference.file,
|
|
24388
|
+
line: reference.line,
|
|
24389
|
+
});
|
|
24390
|
+
}
|
|
24391
|
+
}
|
|
24392
|
+
}
|
|
24393
|
+
if (targets.length === 0) {
|
|
24394
|
+
return {
|
|
24395
|
+
contractVersion: 'form_field_contract_v1',
|
|
24396
|
+
valid: true,
|
|
24397
|
+
checkedForms: 0,
|
|
24398
|
+
checkedReferences: 0,
|
|
24399
|
+
errors: [],
|
|
24400
|
+
};
|
|
24401
|
+
}
|
|
24402
|
+
const snapshots = new Map();
|
|
24403
|
+
const formTargets = unique(
|
|
24404
|
+
targets.map(item => item.formUuid).filter(Boolean)
|
|
24405
|
+
).sort();
|
|
24406
|
+
await Promise.all(
|
|
24407
|
+
formTargets.map(async formUuid => {
|
|
24408
|
+
const frozen = await fetchFrozenFormConfig(
|
|
24409
|
+
config,
|
|
24410
|
+
target,
|
|
24411
|
+
formUuid
|
|
24412
|
+
);
|
|
24413
|
+
snapshots.set(formUuid, collectFormSnapshotFieldIds(frozen.snapshot));
|
|
24414
|
+
})
|
|
24415
|
+
);
|
|
24416
|
+
return validateFormFieldContracts(targets, snapshots);
|
|
24417
|
+
}
|
|
24418
|
+
|
|
24419
|
+
function resolveManifestFormBindingForContract(target, item, formCode) {
|
|
24420
|
+
const containers = [
|
|
24421
|
+
item.resourceBindings,
|
|
24422
|
+
item.resources,
|
|
24423
|
+
item.definitionJson?.resourceBindings,
|
|
24424
|
+
item.definitionJson?.resources,
|
|
24425
|
+
];
|
|
24426
|
+
if (item.definitionFile) {
|
|
24427
|
+
try {
|
|
24428
|
+
const definition = JSON.parse(
|
|
24429
|
+
fs.readFileSync(
|
|
24430
|
+
path.resolve(item.__dir || process.cwd(), item.definitionFile),
|
|
24431
|
+
'utf8'
|
|
24432
|
+
)
|
|
24433
|
+
);
|
|
24434
|
+
containers.push(definition.resourceBindings, definition.resources);
|
|
24435
|
+
} catch {
|
|
24436
|
+
// Resource validation reports invalid definition files separately.
|
|
24437
|
+
}
|
|
24438
|
+
}
|
|
24439
|
+
for (const container of containers) {
|
|
24440
|
+
const binding = container?.forms?.[formCode];
|
|
24441
|
+
if (typeof binding === 'string' && binding.trim()) return binding.trim();
|
|
24442
|
+
const formUuid = String(
|
|
24443
|
+
binding?.formUuid || binding?.id || binding?.uuid || ''
|
|
24444
|
+
).trim();
|
|
24445
|
+
if (formUuid) return formUuid;
|
|
24446
|
+
}
|
|
24447
|
+
return resolveManifestFormUuid(
|
|
24448
|
+
target.bound,
|
|
24449
|
+
{ formCode },
|
|
24450
|
+
{ fallbackToCode: false }
|
|
24451
|
+
);
|
|
24238
24452
|
}
|
|
24239
24453
|
|
|
24240
24454
|
function buildWorkspacePublishEnv(profileName, profile, appType, globalEnv) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const FORM_QUERY_SYSTEM_FIELDS = new Set([
|
|
2
|
+
'approvalResult',
|
|
3
|
+
'createTime',
|
|
4
|
+
'currentApprovalNodeName',
|
|
5
|
+
'modifiedTime',
|
|
6
|
+
'originator',
|
|
7
|
+
'originatorCorp',
|
|
8
|
+
'originatorName',
|
|
9
|
+
'processInstanceId',
|
|
10
|
+
'processInstanceStatus',
|
|
11
|
+
'processInstanceTitle',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function collectFormSnapshotFieldIds(snapshot) {
|
|
15
|
+
const fields = new Set();
|
|
16
|
+
const formFields = snapshot?.form?.formFields || {};
|
|
17
|
+
for (const [key, value] of Object.entries(formFields)) {
|
|
18
|
+
if (key) fields.add(String(key));
|
|
19
|
+
for (const candidate of [value?.fieldId, value?.field_id]) {
|
|
20
|
+
if (candidate) fields.add(String(candidate));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const visit = value => {
|
|
24
|
+
if (!value || typeof value !== 'object') return;
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
value.forEach(visit);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const fieldId = value.fieldId || value.props?.fieldId;
|
|
30
|
+
if (fieldId) fields.add(String(fieldId));
|
|
31
|
+
Object.values(value).forEach(visit);
|
|
32
|
+
};
|
|
33
|
+
visit(snapshot?.form?.schema);
|
|
34
|
+
return fields;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateFormFieldContracts(targets, snapshots) {
|
|
38
|
+
const references = Array.isArray(targets) ? targets : [];
|
|
39
|
+
const snapshotFields =
|
|
40
|
+
snapshots instanceof Map ? snapshots : new Map(Object.entries(snapshots || {}));
|
|
41
|
+
const errors = [];
|
|
42
|
+
for (const item of references) {
|
|
43
|
+
const { code: resourceCode, ...detail } = item;
|
|
44
|
+
if (!item.formUuid) {
|
|
45
|
+
errors.push({
|
|
46
|
+
code: 'FORM_FIELD_CONTRACT_BINDING_MISSING',
|
|
47
|
+
resourceCode,
|
|
48
|
+
...detail,
|
|
49
|
+
message: `${item.kind}:${resourceCode} ${item.file}:${item.line} 查询 ${item.formCode}.${item.field},但无法解析该表单的 profile 绑定`,
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
FORM_QUERY_SYSTEM_FIELDS.has(item.field) ||
|
|
55
|
+
snapshotFields.get(item.formUuid)?.has(item.field)
|
|
56
|
+
) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
errors.push({
|
|
60
|
+
code: 'FORM_FIELD_CONTRACT_FIELD_MISSING',
|
|
61
|
+
resourceCode,
|
|
62
|
+
...detail,
|
|
63
|
+
message: `${item.kind}:${resourceCode} ${item.file}:${item.line} 通过 ${item.api} 查询 ${item.formCode}.${item.field},但线上 Form ${item.formUuid} 不存在该字段`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
contractVersion: 'form_field_contract_v1',
|
|
68
|
+
valid: errors.length === 0,
|
|
69
|
+
checkedForms: new Set(references.map(item => item.formUuid).filter(Boolean)).size,
|
|
70
|
+
checkedReferences: references.length,
|
|
71
|
+
errors,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
FORM_QUERY_SYSTEM_FIELDS,
|
|
77
|
+
collectFormSnapshotFieldIds,
|
|
78
|
+
validateFormFieldContracts,
|
|
79
|
+
};
|
|
@@ -2,7 +2,8 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { spawnSync } = require('child_process');
|
|
4
4
|
|
|
5
|
-
const TASK_RESULT_SCHEMA_VERSION = '
|
|
5
|
+
const TASK_RESULT_SCHEMA_VERSION = 'openxiangda_task_result_v2';
|
|
6
|
+
const LEGACY_TASK_RESULT_SCHEMA_VERSION = 'openxiangda_task_result_v1';
|
|
6
7
|
const INTEGRATION_BUNDLE_SCHEMA_VERSION = 'openxiangda_integration_bundle_v1';
|
|
7
8
|
|
|
8
9
|
function runGit(cwd, args, options = {}) {
|
|
@@ -77,6 +78,16 @@ function recordIntegrationTaskResult(options = {}) {
|
|
|
77
78
|
commit,
|
|
78
79
|
treeHash,
|
|
79
80
|
branch,
|
|
81
|
+
...(options.sourceBase?.baseCommit && options.sourceBase?.treeHash
|
|
82
|
+
? {
|
|
83
|
+
sourceBase: {
|
|
84
|
+
repo: options.sourceBase.repo,
|
|
85
|
+
baseCommit: options.sourceBase.baseCommit,
|
|
86
|
+
treeHash: options.sourceBase.treeHash,
|
|
87
|
+
},
|
|
88
|
+
baseRevision: options.sourceBase.baseCommit,
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
80
91
|
targets: options.targets || {},
|
|
81
92
|
changedFiles: Array.from(
|
|
82
93
|
new Set((options.changedFiles || []).map(String).filter(Boolean))
|
|
@@ -96,7 +107,10 @@ function readIntegrationTaskResult(cwd, changeId) {
|
|
|
96
107
|
if (!fs.existsSync(file)) return null;
|
|
97
108
|
try {
|
|
98
109
|
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
99
|
-
return
|
|
110
|
+
return [
|
|
111
|
+
TASK_RESULT_SCHEMA_VERSION,
|
|
112
|
+
LEGACY_TASK_RESULT_SCHEMA_VERSION,
|
|
113
|
+
].includes(value?.schemaVersion)
|
|
100
114
|
? { ...value, file }
|
|
101
115
|
: null;
|
|
102
116
|
} catch {
|
|
@@ -146,6 +160,13 @@ function collectIntegrationTaskResults(options = {}) {
|
|
|
146
160
|
commit: result.commit,
|
|
147
161
|
treeHash: result.treeHash,
|
|
148
162
|
sourceBranch: result.branch,
|
|
163
|
+
...(result.sourceBase?.baseCommit && result.sourceBase?.treeHash
|
|
164
|
+
? {
|
|
165
|
+
sourceBase: result.sourceBase,
|
|
166
|
+
baseRevision:
|
|
167
|
+
result.baseRevision || result.sourceBase.baseCommit,
|
|
168
|
+
}
|
|
169
|
+
: {}),
|
|
149
170
|
})),
|
|
150
171
|
verifiedAgainst: runGit(cwd, ['rev-parse', 'HEAD']).stdout,
|
|
151
172
|
createdAt: new Date().toISOString(),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const DEFINITELY_PRE_WRITE_CODES = new Set([
|
|
2
|
+
'APP_RELEASE_STAGED_SCOPE_MISMATCH',
|
|
3
|
+
'APP_RELEASE_STAGED_SCOPE_REQUIRED',
|
|
4
|
+
'BASELINE_SCOPE_MISSING',
|
|
5
|
+
'FORM_FIELD_CONTRACT_INVALID',
|
|
6
|
+
'PUBLISH_CONTEXT_REQUIRED',
|
|
7
|
+
'RELEASE_GIT_AUTH_REQUIRED',
|
|
8
|
+
'RELEASE_MAIN_BRANCH_UNRESOLVED',
|
|
9
|
+
'RELEASE_MAIN_REF_UNVERIFIED',
|
|
10
|
+
'RELEASE_PUBLISH_REVISION_CHANGED',
|
|
11
|
+
'RELEASE_SOURCE_BEHIND_MAIN',
|
|
12
|
+
'RELEASE_SOURCE_BRANCH_REQUIRED',
|
|
13
|
+
'RELEASE_SOURCE_DIRTY',
|
|
14
|
+
'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
|
|
15
|
+
'RELEASE_SOURCE_MAINLINE_REQUIRED',
|
|
16
|
+
'RESOURCE_FIELD_CONFLICT',
|
|
17
|
+
'SDD_PREPUBLISH_FAILED',
|
|
18
|
+
'SOURCE_BASE_DIVERGED',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function isDefinitelyPreWriteReleaseError(error) {
|
|
22
|
+
const code = String(error?.code || '').trim();
|
|
23
|
+
if (DEFINITELY_PRE_WRITE_CODES.has(code)) return true;
|
|
24
|
+
return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
|
|
25
|
+
code
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function classifyReleaseExecutionFailure({
|
|
30
|
+
stagedWriteOccurred = false,
|
|
31
|
+
writeAttempted = false,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (stagedWriteOccurred) return 'staged-resumable';
|
|
34
|
+
if (writeAttempted) return 'write-review-required';
|
|
35
|
+
return 'failed';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
classifyReleaseExecutionFailure,
|
|
40
|
+
isDefinitelyPreWriteReleaseError,
|
|
41
|
+
};
|
package/lib/sdd.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
4
5
|
const { readGitSourceBase } = require('./change-baseline');
|
|
5
6
|
const { assertReleaseSourceIntegrated } = require('./release-mainline');
|
|
6
7
|
const {
|
|
@@ -604,6 +605,7 @@ function createChangeMetadata(changeId, options = {}) {
|
|
|
604
605
|
baseCommit: sourceBase.baseCommit,
|
|
605
606
|
treeHash: sourceBase.treeHash,
|
|
606
607
|
};
|
|
608
|
+
metadata.baseRevision = sourceBase.baseCommit;
|
|
607
609
|
}
|
|
608
610
|
} catch {
|
|
609
611
|
// SDD can still be used before a workspace is initialized as a Git repo.
|
|
@@ -1013,6 +1015,30 @@ function createMainlineSddBundle(options = {}) {
|
|
|
1013
1015
|
if (nested) {
|
|
1014
1016
|
throw new Error(`mainline bundle 不允许嵌套: ${nested.changeId}`);
|
|
1015
1017
|
}
|
|
1018
|
+
const bundleSourceBase = resolveMainlineBundleSourceBase(
|
|
1019
|
+
sourceChanges,
|
|
1020
|
+
options
|
|
1021
|
+
);
|
|
1022
|
+
const integratedChanges = sourceChanges.map(item => {
|
|
1023
|
+
const sourceBase =
|
|
1024
|
+
normalizeSddSourceBase(item.meta.sourceBase) || bundleSourceBase;
|
|
1025
|
+
const task = options.integration?.requiredCommits?.find(
|
|
1026
|
+
candidate => candidate.changeId === item.changeId
|
|
1027
|
+
);
|
|
1028
|
+
return {
|
|
1029
|
+
changeId: item.changeId,
|
|
1030
|
+
sourceBase,
|
|
1031
|
+
baseRevision:
|
|
1032
|
+
item.meta.baseRevision || sourceBase.baseCommit,
|
|
1033
|
+
...(task
|
|
1034
|
+
? {
|
|
1035
|
+
commit: task.commit,
|
|
1036
|
+
treeHash: task.treeHash,
|
|
1037
|
+
sourceBranch: task.sourceBranch,
|
|
1038
|
+
}
|
|
1039
|
+
: {}),
|
|
1040
|
+
};
|
|
1041
|
+
});
|
|
1016
1042
|
|
|
1017
1043
|
const affected = mergeAffectedScopes(
|
|
1018
1044
|
...sourceChanges.flatMap(item => [
|
|
@@ -1041,16 +1067,19 @@ function createMainlineSddBundle(options = {}) {
|
|
|
1041
1067
|
const createdAt = nowIso();
|
|
1042
1068
|
const meta = {
|
|
1043
1069
|
...createChangeMetadata(changeId, {
|
|
1070
|
+
cwd: options.cwd,
|
|
1044
1071
|
title: options.title || `Mainline release: ${sourceIds.join(', ')}`,
|
|
1045
1072
|
affected,
|
|
1046
1073
|
changeMode: 'mainline-bundle',
|
|
1047
1074
|
riskLevel: 'medium',
|
|
1075
|
+
sourceBase: bundleSourceBase,
|
|
1048
1076
|
}),
|
|
1049
1077
|
status: 'approved',
|
|
1050
1078
|
approvedAt: createdAt,
|
|
1051
1079
|
approvedBy: 'mainline-release-coordinator',
|
|
1052
1080
|
approvalSummary: `Aggregate approved changes on authoritative mainline: ${sourceIds.join(', ')}`,
|
|
1053
1081
|
bundledChanges: sourceIds,
|
|
1082
|
+
integratedChanges,
|
|
1054
1083
|
createdAt,
|
|
1055
1084
|
updatedAt: createdAt,
|
|
1056
1085
|
};
|
|
@@ -1083,6 +1112,9 @@ function createMainlineSddBundle(options = {}) {
|
|
|
1083
1112
|
changeId,
|
|
1084
1113
|
mainlinePolicy: 'publish-from-main-v1',
|
|
1085
1114
|
bundledChanges: sourceIds,
|
|
1115
|
+
sourceBase: bundleSourceBase,
|
|
1116
|
+
baseRevision: bundleSourceBase.baseCommit,
|
|
1117
|
+
integratedChanges,
|
|
1086
1118
|
...(options.integration ? { integration: options.integration } : {}),
|
|
1087
1119
|
targets: normalizedTargets.activationTargets,
|
|
1088
1120
|
logicalTargets: normalizedTargets.logicalTargets,
|
|
@@ -1092,6 +1124,8 @@ function createMainlineSddBundle(options = {}) {
|
|
|
1092
1124
|
`openxiangda release publish --change ${changeId} --profile ${profile}`,
|
|
1093
1125
|
planHash: releasePlanHash({
|
|
1094
1126
|
changeId,
|
|
1127
|
+
baseRevision: bundleSourceBase.baseCommit,
|
|
1128
|
+
integratedChanges,
|
|
1095
1129
|
runtimeMode,
|
|
1096
1130
|
targets: normalizedTargets.activationTargets,
|
|
1097
1131
|
commands,
|
|
@@ -1137,6 +1171,103 @@ function createMainlineSddBundle(options = {}) {
|
|
|
1137
1171
|
};
|
|
1138
1172
|
}
|
|
1139
1173
|
|
|
1174
|
+
function normalizeSddSourceBase(value) {
|
|
1175
|
+
if (!value?.baseCommit || !value?.treeHash) return null;
|
|
1176
|
+
return {
|
|
1177
|
+
repo: value.repo,
|
|
1178
|
+
baseCommit: String(value.baseCommit),
|
|
1179
|
+
treeHash: String(value.treeHash),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function runSddGit(cwd, args, options = {}) {
|
|
1184
|
+
const result = spawnSync('git', args, {
|
|
1185
|
+
cwd: cwd || process.cwd(),
|
|
1186
|
+
encoding: 'utf8',
|
|
1187
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1188
|
+
});
|
|
1189
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
1190
|
+
const error = new Error(
|
|
1191
|
+
String(result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim()
|
|
1192
|
+
);
|
|
1193
|
+
error.code = 'SDD_SOURCE_BASE_GIT_FAILED';
|
|
1194
|
+
throw error;
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
ok: result.status === 0,
|
|
1198
|
+
stdout: String(result.stdout || '').trim(),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function resolveMainlineBundleSourceBase(sourceChanges, options = {}) {
|
|
1203
|
+
const cwd = options.cwd || process.cwd();
|
|
1204
|
+
if (options.sourceBaseRef) {
|
|
1205
|
+
return readGitSourceBase(cwd, options.sourceBaseRef);
|
|
1206
|
+
}
|
|
1207
|
+
const missing = sourceChanges.filter(
|
|
1208
|
+
item => !normalizeSddSourceBase(item.meta.sourceBase)
|
|
1209
|
+
);
|
|
1210
|
+
if (missing.length > 0) {
|
|
1211
|
+
const error = new Error(
|
|
1212
|
+
`SDD_SOURCE_BASE_REQUIRED: ${missing
|
|
1213
|
+
.map(item => item.changeId)
|
|
1214
|
+
.join(', ')} 缺少创建变更时冻结的 sourceBase;请用 --source-base-ref <commit> 明确指定共同基线`
|
|
1215
|
+
);
|
|
1216
|
+
error.code = 'SDD_SOURCE_BASE_REQUIRED';
|
|
1217
|
+
error.changes = missing.map(item => item.changeId);
|
|
1218
|
+
throw error;
|
|
1219
|
+
}
|
|
1220
|
+
const sourceBases = sourceChanges.map(item =>
|
|
1221
|
+
normalizeSddSourceBase(item.meta.sourceBase)
|
|
1222
|
+
);
|
|
1223
|
+
const repos = Array.from(
|
|
1224
|
+
new Set(sourceBases.map(item => item.repo).filter(Boolean))
|
|
1225
|
+
);
|
|
1226
|
+
if (repos.length > 1) {
|
|
1227
|
+
const error = new Error(
|
|
1228
|
+
'SDD_SOURCE_BASE_REPOSITORY_MISMATCH: mainline bundle 的来源变更不属于同一 Git 仓库'
|
|
1229
|
+
);
|
|
1230
|
+
error.code = 'SDD_SOURCE_BASE_REPOSITORY_MISMATCH';
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
for (const item of sourceChanges) {
|
|
1234
|
+
const task = options.integration?.requiredCommits?.find(
|
|
1235
|
+
candidate => candidate.changeId === item.changeId
|
|
1236
|
+
);
|
|
1237
|
+
if (
|
|
1238
|
+
task?.commit &&
|
|
1239
|
+
!runSddGit(cwd, [
|
|
1240
|
+
'merge-base',
|
|
1241
|
+
'--is-ancestor',
|
|
1242
|
+
item.meta.sourceBase.baseCommit,
|
|
1243
|
+
task.commit,
|
|
1244
|
+
], { allowFailure: true }).ok
|
|
1245
|
+
) {
|
|
1246
|
+
const error = new Error(
|
|
1247
|
+
`SDD_SOURCE_BASE_NOT_ANCESTOR: ${item.changeId} 的 sourceBase 不是任务提交 ${String(task.commit).slice(0, 12)} 的祖先`
|
|
1248
|
+
);
|
|
1249
|
+
error.code = 'SDD_SOURCE_BASE_NOT_ANCESTOR';
|
|
1250
|
+
error.changeId = item.changeId;
|
|
1251
|
+
throw error;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const commits = Array.from(
|
|
1255
|
+
new Set(sourceBases.map(item => item.baseCommit))
|
|
1256
|
+
);
|
|
1257
|
+
const commonCommit =
|
|
1258
|
+
commits.length === 1
|
|
1259
|
+
? commits[0]
|
|
1260
|
+
: runSddGit(cwd, ['merge-base', '--octopus', ...commits]).stdout;
|
|
1261
|
+
if (!commonCommit) {
|
|
1262
|
+
const error = new Error(
|
|
1263
|
+
'SDD_SOURCE_BASE_COMMON_ANCESTOR_REQUIRED: 来源变更没有可验证的共同 Git 基线'
|
|
1264
|
+
);
|
|
1265
|
+
error.code = 'SDD_SOURCE_BASE_COMMON_ANCESTOR_REQUIRED';
|
|
1266
|
+
throw error;
|
|
1267
|
+
}
|
|
1268
|
+
return readGitSourceBase(cwd, commonCommit);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1140
1271
|
function loadSddChange(options = {}) {
|
|
1141
1272
|
const governance = getSddGovernanceConfig(options);
|
|
1142
1273
|
const changeId = normalizeChangeId(options.changeId);
|
|
@@ -245,6 +245,122 @@ function readStaticObjectProperty(expression, propertyNames, bindings) {
|
|
|
245
245
|
return null;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
function resolveStaticExpression(expression, bindings, seen = new Set()) {
|
|
249
|
+
const value = unwrapExpression(expression);
|
|
250
|
+
if (!value || !ts.isIdentifier(value)) return value;
|
|
251
|
+
if (seen.has(value.text)) return value;
|
|
252
|
+
const initializer = bindings.get(value.text);
|
|
253
|
+
if (!initializer) return value;
|
|
254
|
+
const nextSeen = new Set(seen);
|
|
255
|
+
nextSeen.add(value.text);
|
|
256
|
+
return resolveStaticExpression(initializer, bindings, nextSeen);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readStaticObjectPropertyExpression(
|
|
260
|
+
expression,
|
|
261
|
+
propertyNames,
|
|
262
|
+
bindings
|
|
263
|
+
) {
|
|
264
|
+
const value = resolveStaticExpression(expression, bindings);
|
|
265
|
+
if (!value || !ts.isObjectLiteralExpression(value)) return null;
|
|
266
|
+
for (const property of value.properties) {
|
|
267
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
268
|
+
if (!propertyNames.includes(propertyNameText(property.name))) continue;
|
|
269
|
+
return property.initializer;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function collectStaticQueryFieldNodes(
|
|
275
|
+
expression,
|
|
276
|
+
bindings,
|
|
277
|
+
result,
|
|
278
|
+
fieldPropertyNames,
|
|
279
|
+
seen = new Set()
|
|
280
|
+
) {
|
|
281
|
+
const value = resolveStaticExpression(expression, bindings, seen);
|
|
282
|
+
if (!value || seen.has(value)) return;
|
|
283
|
+
seen.add(value);
|
|
284
|
+
if (ts.isArrayLiteralExpression(value)) {
|
|
285
|
+
for (const element of value.elements) {
|
|
286
|
+
collectStaticQueryFieldNodes(
|
|
287
|
+
element,
|
|
288
|
+
bindings,
|
|
289
|
+
result,
|
|
290
|
+
fieldPropertyNames,
|
|
291
|
+
seen
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!ts.isObjectLiteralExpression(value)) return;
|
|
297
|
+
for (const property of value.properties) {
|
|
298
|
+
if (ts.isSpreadAssignment(property)) {
|
|
299
|
+
collectStaticQueryFieldNodes(
|
|
300
|
+
property.expression,
|
|
301
|
+
bindings,
|
|
302
|
+
result,
|
|
303
|
+
fieldPropertyNames,
|
|
304
|
+
seen
|
|
305
|
+
);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
309
|
+
const name = propertyNameText(property.name);
|
|
310
|
+
if (fieldPropertyNames.has(name)) {
|
|
311
|
+
const field = readStaticString(property.initializer, bindings);
|
|
312
|
+
if (field) result.push({ field, node: property.initializer });
|
|
313
|
+
}
|
|
314
|
+
collectStaticQueryFieldNodes(
|
|
315
|
+
property.initializer,
|
|
316
|
+
bindings,
|
|
317
|
+
result,
|
|
318
|
+
fieldPropertyNames,
|
|
319
|
+
seen
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function addFormFieldReferences(
|
|
325
|
+
formFieldReferences,
|
|
326
|
+
sourceFile,
|
|
327
|
+
workspaceRoot,
|
|
328
|
+
bindings,
|
|
329
|
+
formCode,
|
|
330
|
+
inputExpression,
|
|
331
|
+
api
|
|
332
|
+
) {
|
|
333
|
+
if (!formCode || !inputExpression) return;
|
|
334
|
+
const fields = [];
|
|
335
|
+
for (const propertyName of ['filters', 'order']) {
|
|
336
|
+
const expression = readStaticObjectPropertyExpression(
|
|
337
|
+
inputExpression,
|
|
338
|
+
[propertyName],
|
|
339
|
+
bindings
|
|
340
|
+
);
|
|
341
|
+
if (expression) {
|
|
342
|
+
collectStaticQueryFieldNodes(
|
|
343
|
+
expression,
|
|
344
|
+
bindings,
|
|
345
|
+
fields,
|
|
346
|
+
new Set(
|
|
347
|
+
propertyName === 'order'
|
|
348
|
+
? ['id', 'key', 'field', 'fieldId']
|
|
349
|
+
: ['key', 'field', 'fieldId']
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const item of fields) {
|
|
355
|
+
formFieldReferences.push({
|
|
356
|
+
formCode,
|
|
357
|
+
field: item.field,
|
|
358
|
+
api,
|
|
359
|
+
...sourceLocation(sourceFile, item.node, workspaceRoot),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
248
364
|
function sourceLocation(sourceFile, node, workspaceRoot) {
|
|
249
365
|
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
250
366
|
return {
|
|
@@ -274,10 +390,46 @@ function addDynamicReferenceWarning(warnings, sourceFile, node, workspaceRoot, a
|
|
|
274
390
|
});
|
|
275
391
|
}
|
|
276
392
|
|
|
277
|
-
function inspectResourceCall(
|
|
393
|
+
function inspectResourceCall(
|
|
394
|
+
node,
|
|
395
|
+
sourceFile,
|
|
396
|
+
workspaceRoot,
|
|
397
|
+
bindings,
|
|
398
|
+
references,
|
|
399
|
+
formFieldReferences,
|
|
400
|
+
warnings
|
|
401
|
+
) {
|
|
278
402
|
if (!ts.isCallExpression(node)) return;
|
|
279
403
|
const callee = getPropertyPath(node.expression, bindings);
|
|
280
|
-
if (!callee
|
|
404
|
+
if (!callee) return;
|
|
405
|
+
if (
|
|
406
|
+
callee.length === 1 &&
|
|
407
|
+
callee[0] === 'queryAdvancedFormPage'
|
|
408
|
+
) {
|
|
409
|
+
const formCode = readStaticString(node.arguments[1], bindings);
|
|
410
|
+
if (formCode) {
|
|
411
|
+
addReference(
|
|
412
|
+
references,
|
|
413
|
+
sourceFile,
|
|
414
|
+
node,
|
|
415
|
+
workspaceRoot,
|
|
416
|
+
'forms',
|
|
417
|
+
formCode,
|
|
418
|
+
'queryAdvancedFormPage'
|
|
419
|
+
);
|
|
420
|
+
addFormFieldReferences(
|
|
421
|
+
formFieldReferences,
|
|
422
|
+
sourceFile,
|
|
423
|
+
workspaceRoot,
|
|
424
|
+
bindings,
|
|
425
|
+
formCode,
|
|
426
|
+
node.arguments[2],
|
|
427
|
+
'queryAdvancedFormPage'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (callee[0] !== 'ctx' || callee.length < 3) return;
|
|
281
433
|
const namespace = callee[1];
|
|
282
434
|
const method = callee[2];
|
|
283
435
|
const api = callee.join('.');
|
|
@@ -304,7 +456,22 @@ function inspectResourceCall(node, sourceFile, workspaceRoot, bindings, referenc
|
|
|
304
456
|
if (namespace === 'form') {
|
|
305
457
|
const code = readStaticObjectProperty(firstArgument, ['formCode', 'code'], bindings) ||
|
|
306
458
|
readStaticString(firstArgument, bindings);
|
|
307
|
-
if (code)
|
|
459
|
+
if (code) {
|
|
460
|
+
addReference(references, sourceFile, node, workspaceRoot, 'forms', code, api);
|
|
461
|
+
addFormFieldReferences(
|
|
462
|
+
formFieldReferences,
|
|
463
|
+
sourceFile,
|
|
464
|
+
workspaceRoot,
|
|
465
|
+
bindings,
|
|
466
|
+
code,
|
|
467
|
+
ts.isObjectLiteralExpression(
|
|
468
|
+
resolveStaticExpression(firstArgument, bindings)
|
|
469
|
+
)
|
|
470
|
+
? firstArgument
|
|
471
|
+
: node.arguments[1],
|
|
472
|
+
api
|
|
473
|
+
);
|
|
474
|
+
}
|
|
308
475
|
else if (firstArgument) addDynamicReferenceWarning(warnings, sourceFile, node, workspaceRoot, api, 'forms');
|
|
309
476
|
return;
|
|
310
477
|
}
|
|
@@ -396,6 +563,7 @@ function analyzeTypeScriptEntry(options = {}) {
|
|
|
396
563
|
const visited = new Set();
|
|
397
564
|
const sourceDependencies = new Set();
|
|
398
565
|
const references = [];
|
|
566
|
+
const formFieldReferences = [];
|
|
399
567
|
const warnings = [];
|
|
400
568
|
|
|
401
569
|
while (queue.length > 0) {
|
|
@@ -439,7 +607,15 @@ function analyzeTypeScriptEntry(options = {}) {
|
|
|
439
607
|
|
|
440
608
|
const bindings = collectConstInitializers(sourceFile);
|
|
441
609
|
const inspect = node => {
|
|
442
|
-
inspectResourceCall(
|
|
610
|
+
inspectResourceCall(
|
|
611
|
+
node,
|
|
612
|
+
sourceFile,
|
|
613
|
+
workspaceRoot,
|
|
614
|
+
bindings,
|
|
615
|
+
references,
|
|
616
|
+
formFieldReferences,
|
|
617
|
+
warnings
|
|
618
|
+
);
|
|
443
619
|
ts.forEachChild(node, inspect);
|
|
444
620
|
};
|
|
445
621
|
inspect(sourceFile);
|
|
@@ -486,6 +662,10 @@ function analyzeTypeScriptEntry(options = {}) {
|
|
|
486
662
|
sourceDependencies: Array.from(sourceDependencies).sort(),
|
|
487
663
|
resourceReferences,
|
|
488
664
|
resourceReferenceDetails,
|
|
665
|
+
formFieldReferences: dedupeDetails(
|
|
666
|
+
formFieldReferences,
|
|
667
|
+
['formCode', 'field', 'file', 'line', 'column', 'api']
|
|
668
|
+
),
|
|
489
669
|
warnings: dedupeDetails(warnings, ['code', 'file', 'line', 'column', 'api', 'message']),
|
|
490
670
|
};
|
|
491
671
|
}
|
|
@@ -610,11 +790,16 @@ function mergeAnalyses(workspaceRoot, entries) {
|
|
|
610
790
|
).sort(),
|
|
611
791
|
])
|
|
612
792
|
);
|
|
793
|
+
const formFieldReferences = dedupeDetails(
|
|
794
|
+
analyses.flatMap(analysis => analysis.formFieldReferences || []),
|
|
795
|
+
['formCode', 'field', 'file', 'line', 'column', 'api']
|
|
796
|
+
);
|
|
613
797
|
return {
|
|
614
798
|
entryFiles: entries.map(entry => workspaceRelativePath(workspaceRoot, entry)).sort(),
|
|
615
799
|
sourceDependencies,
|
|
616
800
|
resourceReferences,
|
|
617
801
|
resourceReferenceDetails,
|
|
802
|
+
formFieldReferences,
|
|
618
803
|
warnings: dedupeDetails(
|
|
619
804
|
analyses.flatMap(analysis => analysis.warnings),
|
|
620
805
|
['code', 'file', 'line', 'column', 'api', 'message']
|
|
@@ -712,6 +897,7 @@ function validateManifestSourceBindings(options = {}) {
|
|
|
712
897
|
entryFiles: target.entryFiles,
|
|
713
898
|
sourceDependencies: target.sourceDependencies,
|
|
714
899
|
resourceReferences: target.resourceReferences,
|
|
900
|
+
formFieldReferences: target.formFieldReferences,
|
|
715
901
|
})),
|
|
716
902
|
};
|
|
717
903
|
}
|
|
@@ -59,6 +59,8 @@ openxiangda resource publish function --only function_a,function_b --profile <na
|
|
|
59
59
|
|
|
60
60
|
Selectors apply before manifest parsing, source dependency analysis, and JS_CODE typecheck/build. A scoped Function/Automation plan must touch only the selected targets plus their transitive/shared/ambient dependencies; an unscoped plan intentionally retains full-workspace behavior. Resource commands use the canonical scoped builder packaged with the installed CLI for standard workspaces, so old checked-in builders still receive the optimization; refresh/bootstrap the workspace script only for equivalent manual `pnpm build-js-code` behavior. Nonstandard custom builders remain an explicit compatibility fallback.
|
|
61
61
|
|
|
62
|
+
Function/Automation source analysis also extracts statically declared Form filter and order fields. `resource plan` compares them with each bound Form's frozen online schema and reports `formFieldContracts`; publish fails before lease/write when a binding or field is missing. Fix the source or stage the Form schema in the same reviewed release instead of waiting for a production SQL-column error. Dynamic field names remain runtime-validated by the platform and return `FORM_FIELD_NOT_FOUND` as a configuration error.
|
|
63
|
+
|
|
62
64
|
Source-triggered Function/Automation targets use Backend Release v2 when the platform exposes that capability. One child may mix create, source-only update, and manifest replacement update through explicit per-resource `operation/mode`; the CLI freezes the current Backend Release parent plus Git/change baseline, then runs `prepare -> verify -> activate` or stops verified for `--stage-only`. Activation CAS-checks the entire set and applies all updates in one transaction. `--stage-only` and Secret-bound Function publishing fail closed when Backend Release v2 is unavailable; compatibility fallback is limited to non-staged, non-Secret publishing after an explicit Backend head 404. Existing online bindings, contracts, metadata, trigger/view configuration, and enabled/published state remain unchanged; noops do not advance versions/timestamps. A deliberate whole-definition replacement requires exact `--only/--code` and `--replace-manifest --reason "<why>"`; SDD bypass does not imply replacement authority.
|
|
63
65
|
|
|
64
66
|
Functions with a top-level `secretRefs` field use `backend_release_v2`, including an explicit empty list that removes bindings. This path never falls back to source PATCH. It requires the per-app Secret capability probe to grant `app_function_secrets_v1`, `trusted_node_v2`, `backend_release_v2`, and `atomic_staged_children_v2`; otherwise plan/publish fails closed.
|
|
@@ -84,7 +84,7 @@ For a single form edit, still keep the child staged until Root App finalize:
|
|
|
84
84
|
openxiangda resource publish form-setting --only <formCode> --change <change> --profile <name>
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
The Form resource bundle carries schema, settings, indexes, data-management, runtime-write,
|
|
87
|
+
The Form resource bundle carries schema, settings, indexes, data-management, runtime-write, public-access configuration, and changed form permission groups under one CAS parent. It returns a canonical staged FormRelease and the CLI records it in the change-scoped staged-resources file. `workspace publish --form` is limited to an intentional first-time bootstrap or isolated repair outside a governed multi-resource release; it is not the normal release path.
|
|
88
88
|
|
|
89
89
|
For Phase 6 React SPA workspaces, `app-workspace.config.ts` should declare
|
|
90
90
|
`runtimeMode: "react-spa"`. In that mode, `workspace publish --form <code>` is
|
|
@@ -110,7 +110,7 @@ Do not use `openxiangda form create` as the normal way to generate a user-facing
|
|
|
110
110
|
## Form Release / CAS Safety
|
|
111
111
|
|
|
112
112
|
- Treat all Form configuration as one immutable release stream. Freeze `revision`, `etag`, and `activeFormReleaseHead` from one snapshot before writing.
|
|
113
|
-
- Publish declared schema, packages, settings, indexes, data-management, runtime-write,
|
|
113
|
+
- Publish declared schema, packages, settings, indexes, data-management, runtime-write, public-access configuration, and `formPermissionGroups` through one Form `bundle` mutation. `resource publish form-setting,form-permission-group --only <codes> --change <change>` groups changes by `formUuid`, stages one immutable FormRelease per form, and never falls back to direct live permission-group writes. Resource Form bundles return immutable `contentHash` plus a canonical `stagedResource` (stable identity is `formUuid`); the CLI merges each entry into `.openxiangda/releases/<change>/staged-resources.json`. Root `app-finalize` atomically switches all child heads plus the App head. Use direct `--activate` only for an intentional form-only repair outside a governed multi-resource release; never use `workspace publish --form` inside an atomic React SPA release.
|
|
114
114
|
- Every mutation must carry `If-Match`, `expectedRevision`, `expectedParent`, and a stable artifact ID. Creation starts at revision `0`.
|
|
115
115
|
- A revision/head conflict is not retryable. Stop with zero later writes, re-plan, and explicitly merge the competing change; never silently adopt the newer live head.
|
|
116
116
|
- Use `openxiangda form release-head|release-list|release-detail|release-diff` for inspection and `release-activate|release-rollback|release-abort` for controlled recovery. See `docs/openxiangda-form-releases.md` in the OpenXiangda tool repository.
|
|
@@ -159,6 +159,14 @@ openxiangda permission form-group-create sales_limited \
|
|
|
159
159
|
for real backend read/write field access (`edit`, `readonly`, `hidden`) with
|
|
160
160
|
`defaultAccess` plus field-ID exceptions.
|
|
161
161
|
|
|
162
|
+
For governed application publishing, declare groups under
|
|
163
|
+
`src/resources/formPermissionGroups` and publish them through exact
|
|
164
|
+
`resource publish ... --only ... --change ...` scope. The CLI folds changed
|
|
165
|
+
groups into the owning Form's immutable FormRelease, freezes a permission-group
|
|
166
|
+
parent hash, and keeps live rows unchanged until Root App finalize. The
|
|
167
|
+
low-level `permission form-group-create/update/delete` commands are for
|
|
168
|
+
intentional maintenance and diagnostics, not an atomic multi-resource release.
|
|
169
|
+
|
|
162
170
|
## Inspection
|
|
163
171
|
|
|
164
172
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openxiangda",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.178",
|
|
4
4
|
"description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"bin": {
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"test:publish-lease-heartbeat": "node scripts/publish-lease-heartbeat-smoke.mjs",
|
|
101
101
|
"test:release-telemetry": "node scripts/release-telemetry-smoke.mjs",
|
|
102
102
|
"test:release-explain": "node scripts/release-explain-smoke.mjs",
|
|
103
|
+
"test:release-error-classification": "node scripts/release-error-classification-smoke.mjs",
|
|
103
104
|
"test:task-status": "node scripts/task-status-smoke.mjs",
|
|
104
105
|
"test:policy": "node scripts/policy-smoke.mjs",
|
|
105
106
|
"test:release-plan": "node scripts/release-plan-smoke.mjs",
|
|
@@ -111,6 +112,7 @@
|
|
|
111
112
|
"test:app-release-cli": "node scripts/app-release-cli-smoke.mjs",
|
|
112
113
|
"test:form-release-cas": "node scripts/form-release-cas-smoke.mjs",
|
|
113
114
|
"test:source-dependencies": "node scripts/source-dependencies-smoke.mjs",
|
|
115
|
+
"test:form-field-contract": "node scripts/form-field-contract-smoke.mjs",
|
|
114
116
|
"test:package-runtime-dependencies": "node scripts/package-runtime-dependencies-smoke.mjs",
|
|
115
117
|
"test:packed-cli": "node scripts/packed-cli-smoke.mjs",
|
|
116
118
|
"test:runtime-deploy": "node scripts/runtime-deploy-smoke.mjs",
|