release-skill 0.1.1
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/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release plan schema validation, digest calculation, and atomic write.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - `validatePlan(plan)` -- validates a plan object against the release-plan schema
|
|
6
|
+
* - `computePlanDigest(plan)` -- deterministic SHA-256 digest of the plan
|
|
7
|
+
* - `writePlanAtomic(planPath, plan)` -- compute digest, embed it, validate, and
|
|
8
|
+
* write the plan atomically (temp-file + rename)
|
|
9
|
+
*
|
|
10
|
+
* Schema authority: the release-plan schema is loaded from the package's
|
|
11
|
+
* `schemas/release-plan.schema.json` file at module init time. The root
|
|
12
|
+
* workspace `schemas/release-plan.schema.json` is the generation source;
|
|
13
|
+
* both copies are kept byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* @module core/plan
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
|
|
19
|
+
import { basename, dirname, join, resolve, parse, sep } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import Ajv from 'ajv';
|
|
22
|
+
import addFormats from 'ajv-formats';
|
|
23
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
24
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Schema loaded from the authoritative JSON file (single source of truth)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-plan.schema.json');
|
|
32
|
+
const RELEASE_PLAN_SCHEMA = JSON.parse(await readFile(SCHEMA_PATH, 'utf8'));
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Schema validator (compiled once at module init)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
39
|
+
addFormats(ajv);
|
|
40
|
+
const validatePlanSchema = ajv.compile(RELEASE_PLAN_SCHEMA);
|
|
41
|
+
|
|
42
|
+
async function assertNoSymlinkAncestors(directory) {
|
|
43
|
+
const absolute = resolve(directory);
|
|
44
|
+
const parsed = parse(absolute);
|
|
45
|
+
const segments = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
46
|
+
const releaseIndex = segments.lastIndexOf('.release-skill');
|
|
47
|
+
// Production authority is rooted below `.release-skill`, so inspect every
|
|
48
|
+
// project-owned component from that anchor. Non-production custom outputs
|
|
49
|
+
// inspect their target directory only; platform prefixes such as macOS
|
|
50
|
+
// `/tmp -> /private/tmp` are outside project authority and are allowed.
|
|
51
|
+
const firstChecked = releaseIndex >= 0 ? releaseIndex : Math.max(0, segments.length - 1);
|
|
52
|
+
let current = join(parsed.root, ...segments.slice(0, firstChecked));
|
|
53
|
+
for (const segment of segments.slice(firstChecked)) {
|
|
54
|
+
current = join(current, segment);
|
|
55
|
+
let stat;
|
|
56
|
+
try {
|
|
57
|
+
stat = await lstat(current);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === 'ENOENT') continue;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
63
|
+
throw new ReleaseError(
|
|
64
|
+
GATE_FAILED,
|
|
65
|
+
'release authority path contains a symlink or non-directory ancestor',
|
|
66
|
+
{ directory: absolute, unsafeAncestor: current },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function prepareAuthorityDirectory(directory) {
|
|
73
|
+
const absolute = resolve(directory);
|
|
74
|
+
await assertNoSymlinkAncestors(absolute);
|
|
75
|
+
await mkdir(absolute, { recursive: true });
|
|
76
|
+
await assertNoSymlinkAncestors(absolute);
|
|
77
|
+
return absolute;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function assertAuthorityFileTarget(filePath) {
|
|
81
|
+
const absolute = resolve(filePath);
|
|
82
|
+
await prepareAuthorityDirectory(dirname(absolute));
|
|
83
|
+
try {
|
|
84
|
+
const stat = await lstat(absolute);
|
|
85
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
86
|
+
throw new ReleaseError(
|
|
87
|
+
GATE_FAILED,
|
|
88
|
+
'release authority target must be a regular file, never a symlink or special file',
|
|
89
|
+
{ filePath: absolute },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code !== 'ENOENT') throw error;
|
|
94
|
+
}
|
|
95
|
+
return absolute;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Public API
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/** Require production consumers to use the digest-addressed plan authority. */
|
|
103
|
+
export function assertImmutablePlanAuthority(planPath, plan) {
|
|
104
|
+
if (!plan?.production) return;
|
|
105
|
+
const digest = computePlanDigest(plan);
|
|
106
|
+
const absolute = resolve(planPath);
|
|
107
|
+
if (basename(dirname(absolute)) !== 'plans' || basename(absolute) !== `${digest}.json`) {
|
|
108
|
+
throw new ReleaseError(
|
|
109
|
+
GATE_FAILED,
|
|
110
|
+
'production commands require plans/<planDigest>.json immutable authority; mutable release-plan.json aliases are not accepted',
|
|
111
|
+
{ planPath, expectedFile: `plans/${digest}.json` },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate a plan object against the embedded release-plan schema.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} plan - The plan object to validate.
|
|
120
|
+
* @throws {ReleaseError} GATE_FAILED if the plan does not match the schema.
|
|
121
|
+
*/
|
|
122
|
+
export function validatePlan(plan) {
|
|
123
|
+
const valid = validatePlanSchema(plan);
|
|
124
|
+
if (!valid) {
|
|
125
|
+
const errors = validatePlanSchema.errors ?? [];
|
|
126
|
+
const summary = errors
|
|
127
|
+
.map((e) => `${e.instancePath || '/'}: ${e.message}`)
|
|
128
|
+
.join('; ');
|
|
129
|
+
throw new ReleaseError(
|
|
130
|
+
GATE_FAILED,
|
|
131
|
+
`release plan schema validation failed: ${summary}`,
|
|
132
|
+
{ validationErrors: errors },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compute a deterministic SHA-256 digest of a plan object.
|
|
139
|
+
*
|
|
140
|
+
* The digest is computed from the canonical JSON of the plan. The `digest`
|
|
141
|
+
* field itself is excluded from the computation so that the digest is
|
|
142
|
+
* self-consistent: `computePlanDigest(plan) === plan.digest` when the plan
|
|
143
|
+
* was written by `writePlanAtomic`.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} plan - A plan object (must not include a `digest` field,
|
|
146
|
+
* or the field will be stripped before hashing).
|
|
147
|
+
* @returns {string} Lowercase 64-char hex SHA-256 digest.
|
|
148
|
+
*/
|
|
149
|
+
export function computePlanDigest(plan) {
|
|
150
|
+
// Strip the digest field if present so the hash is self-consistent.
|
|
151
|
+
const { digest: _digest, ...rest } = plan;
|
|
152
|
+
return sha256Hex(canonicalJson(rest));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Write a release plan atomically to disk.
|
|
157
|
+
*
|
|
158
|
+
* Steps:
|
|
159
|
+
* 1. Compute the plan's deterministic digest.
|
|
160
|
+
* 2. Embed the digest into the plan object.
|
|
161
|
+
* 3. Validate the augmented plan against the release-plan schema.
|
|
162
|
+
* 4. Serialise to pretty-printed JSON.
|
|
163
|
+
* 5. Write to a temporary file in the same directory.
|
|
164
|
+
* 6. Rename (atomic on POSIX) to the final path.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} planPath - Absolute path to write the plan to.
|
|
167
|
+
* @param {object} plan - The plan object (must not already include `digest`).
|
|
168
|
+
* @returns {Promise<{ planPath: string, planDigest: string }>}
|
|
169
|
+
*
|
|
170
|
+
* @throws {ReleaseError} GATE_FAILED on schema validation failure.
|
|
171
|
+
*/
|
|
172
|
+
export async function writePlanAtomic(planPath, plan) {
|
|
173
|
+
// 1. Compute digest
|
|
174
|
+
const planDigest = computePlanDigest(plan);
|
|
175
|
+
|
|
176
|
+
// 2. Embed digest
|
|
177
|
+
const augmented = { ...plan, digest: planDigest };
|
|
178
|
+
|
|
179
|
+
// 3. Validate
|
|
180
|
+
validatePlan(augmented);
|
|
181
|
+
|
|
182
|
+
// 4. Serialise
|
|
183
|
+
const json = JSON.stringify(augmented, null, 2);
|
|
184
|
+
|
|
185
|
+
// 5. Write to temp file in the same directory
|
|
186
|
+
const dir = dirname(planPath);
|
|
187
|
+
await prepareAuthorityDirectory(dir);
|
|
188
|
+
await assertAuthorityFileTarget(planPath);
|
|
189
|
+
const tmpPath = `${dir}/.release-plan-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
190
|
+
await writeFile(tmpPath, json, 'utf8');
|
|
191
|
+
|
|
192
|
+
// 6. Atomic rename
|
|
193
|
+
await rename(tmpPath, planPath);
|
|
194
|
+
|
|
195
|
+
return { planPath, planDigest };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create a digest-addressed plan authority exactly once.
|
|
200
|
+
*
|
|
201
|
+
* The target must be named by the computed digest. An existing byte-identical
|
|
202
|
+
* authority is reused; an existing divergent file fails closed. A temporary
|
|
203
|
+
* file is fsynced and atomically linked into place so concurrent prepares can
|
|
204
|
+
* never replace an authority that another process already created.
|
|
205
|
+
*/
|
|
206
|
+
export async function writePlanImmutable(planPath, plan) {
|
|
207
|
+
const planDigest = computePlanDigest(plan);
|
|
208
|
+
const augmented = { ...plan, digest: planDigest };
|
|
209
|
+
validatePlan(augmented);
|
|
210
|
+
const json = JSON.stringify(augmented, null, 2);
|
|
211
|
+
|
|
212
|
+
if (!planPath.endsWith(`${planDigest}.json`)) {
|
|
213
|
+
throw new ReleaseError(
|
|
214
|
+
GATE_FAILED,
|
|
215
|
+
'immutable plan path must be named with the plan digest',
|
|
216
|
+
{ planPath, planDigest },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const dir = dirname(planPath);
|
|
221
|
+
await prepareAuthorityDirectory(dir);
|
|
222
|
+
await assertAuthorityFileTarget(planPath);
|
|
223
|
+
const tmpPath = join(dir, `.release-plan-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
224
|
+
const handle = await open(tmpPath, 'wx', 0o600);
|
|
225
|
+
try {
|
|
226
|
+
await handle.writeFile(json, 'utf8');
|
|
227
|
+
await handle.sync();
|
|
228
|
+
} finally {
|
|
229
|
+
await handle.close();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await link(tmpPath, planPath);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error.code !== 'EEXIST') throw error;
|
|
236
|
+
const existing = await readFile(planPath, 'utf8');
|
|
237
|
+
if (existing !== json) {
|
|
238
|
+
throw new ReleaseError(
|
|
239
|
+
GATE_FAILED,
|
|
240
|
+
'immutable plan authority already exists with different bytes',
|
|
241
|
+
{ planPath, planDigest },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
await unlink(tmpPath).catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { planPath, planDigest };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Plan action completeness gate
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/** Expected adapter for each action type. */
|
|
256
|
+
const EXPECTED_ADAPTER = {
|
|
257
|
+
'push-snapshot': 'git-github',
|
|
258
|
+
'create-tag': 'git-github',
|
|
259
|
+
'github-release': 'github',
|
|
260
|
+
'npm-publish': 'npm',
|
|
261
|
+
'claude-marketplace-install': 'plugin-marketplace',
|
|
262
|
+
'codex-marketplace-install': 'plugin-marketplace',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Required action types for every unit. */
|
|
266
|
+
const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Derive the expected actions from plan units and validate completeness.
|
|
270
|
+
*
|
|
271
|
+
* For each unit the plan must contain exactly:
|
|
272
|
+
* - 1 push-snapshot (adapter: git-github)
|
|
273
|
+
* - 1 create-tag (adapter: git-github)
|
|
274
|
+
* - 1 github-release (adapter: github)
|
|
275
|
+
* - 1 npm-publish (adapter: npm) — only if the unit has an npm distribution
|
|
276
|
+
* - 1 claude-marketplace-install (adapter: plugin-marketplace) — only if the unit has a claude-plugin distribution
|
|
277
|
+
* - 1 codex-marketplace-install (adapter: plugin-marketplace) — only if the unit has a codex-plugin distribution
|
|
278
|
+
*
|
|
279
|
+
* Every action must bind to the correct unitId, correct adapter, correct
|
|
280
|
+
* version, correct publicRepo (where applicable), and correct tag derived
|
|
281
|
+
* from tagTemplate. No missing, extra, duplicate, or mismatched actions
|
|
282
|
+
* are allowed.
|
|
283
|
+
*
|
|
284
|
+
* The tag check requires exact equality between expected.tag and the
|
|
285
|
+
* tagTemplate-expanded tag. This eliminates the substring vulnerability
|
|
286
|
+
* (e.g. v0.0.10 must not match expected version 0.0.1).
|
|
287
|
+
*
|
|
288
|
+
* @param {object} plan - A validated release plan object.
|
|
289
|
+
* @returns {{ passed: boolean, details: { failures: string[], expectedCount: number, actualCount: number } }}
|
|
290
|
+
*/
|
|
291
|
+
export function validatePlanActionCompleteness(plan) {
|
|
292
|
+
const failures = [];
|
|
293
|
+
|
|
294
|
+
if (!plan || typeof plan !== 'object') {
|
|
295
|
+
return { passed: false, details: { failures: ['plan is null or not an object'], expectedCount: 0, actualCount: 0 } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const units = Array.isArray(plan.units) ? plan.units : [];
|
|
299
|
+
const actions = Array.isArray(plan.externalActions) ? plan.externalActions : [];
|
|
300
|
+
|
|
301
|
+
// --- Must have at least one action ---
|
|
302
|
+
if (actions.length === 0) {
|
|
303
|
+
failures.push('plan has no external actions; at least one is required');
|
|
304
|
+
return { passed: false, details: { failures, expectedCount: 0, actualCount: 0 } };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Validate each unit's required actions ---
|
|
308
|
+
let expectedCount = 0;
|
|
309
|
+
const seenUnitIds = new Set();
|
|
310
|
+
|
|
311
|
+
for (const unit of units) {
|
|
312
|
+
const unitId = unit.id;
|
|
313
|
+
const targetVersion = unit.targetVersion;
|
|
314
|
+
const publicRepo = unit.publicRepo;
|
|
315
|
+
const tagTemplate = unit.tagTemplate;
|
|
316
|
+
const distributions = unit.distributions ?? [];
|
|
317
|
+
const production = plan.production?.mode === 'github-npm-v1';
|
|
318
|
+
const frozen = unit.frozenSnapshot;
|
|
319
|
+
const productionConfig = unit.productionConfig ?? {};
|
|
320
|
+
const expectedTag = tagTemplate
|
|
321
|
+
? tagTemplate.replace('{version}', targetVersion ?? '')
|
|
322
|
+
: null;
|
|
323
|
+
|
|
324
|
+
// Check unit has required fields
|
|
325
|
+
if (!unitId) {
|
|
326
|
+
failures.push(`unit is missing id`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check for duplicate unit.id
|
|
331
|
+
if (seenUnitIds.has(unitId)) {
|
|
332
|
+
failures.push(`duplicate unit id "${unitId}"`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
seenUnitIds.add(unitId);
|
|
336
|
+
if (!targetVersion) {
|
|
337
|
+
failures.push(`unit "${unitId}" is missing targetVersion`);
|
|
338
|
+
}
|
|
339
|
+
if (!publicRepo) {
|
|
340
|
+
failures.push(`unit "${unitId}" is missing publicRepo`);
|
|
341
|
+
}
|
|
342
|
+
if (!tagTemplate) {
|
|
343
|
+
failures.push(`unit "${unitId}" is missing tagTemplate`);
|
|
344
|
+
}
|
|
345
|
+
if (production && !frozen) {
|
|
346
|
+
failures.push(`unit "${unitId}" is missing frozenSnapshot for production publish`);
|
|
347
|
+
}
|
|
348
|
+
if (production && frozen) {
|
|
349
|
+
const expectedBranch = (productionConfig.branchTemplate ?? 'release/{tag}')
|
|
350
|
+
.replaceAll('{tag}', expectedTag ?? '')
|
|
351
|
+
.replaceAll('{version}', targetVersion ?? '')
|
|
352
|
+
.replaceAll('{unit}', unitId);
|
|
353
|
+
if (frozen.branch !== expectedBranch) {
|
|
354
|
+
failures.push(`unit "${unitId}" frozen branch does not match productionConfig.branchTemplate`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Required actions for this unit (always required)
|
|
359
|
+
for (const actionType of REQUIRED_ACTION_TYPES) {
|
|
360
|
+
expectedCount++;
|
|
361
|
+
const expectedAdapter = EXPECTED_ADAPTER[actionType];
|
|
362
|
+
const expectedActionId = `${actionType}-${unitId}`;
|
|
363
|
+
const matchingActions = actions.filter(
|
|
364
|
+
(a) => a.unitId === unitId && a.type === actionType,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (matchingActions.length === 0) {
|
|
368
|
+
failures.push(
|
|
369
|
+
`unit "${unitId}": required action type "${actionType}" is missing (expected adapter "${expectedAdapter}")`,
|
|
370
|
+
);
|
|
371
|
+
} else if (matchingActions.length > 1) {
|
|
372
|
+
failures.push(
|
|
373
|
+
`unit "${unitId}": duplicate action type "${actionType}" (${matchingActions.length} found, expected 1)`,
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
// Validate the single matching action
|
|
377
|
+
const action = matchingActions[0];
|
|
378
|
+
|
|
379
|
+
// --- Strict common field checks ---
|
|
380
|
+
if (action.id !== expectedActionId) {
|
|
381
|
+
failures.push(
|
|
382
|
+
`unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (action.unitId !== unitId) {
|
|
386
|
+
failures.push(
|
|
387
|
+
`unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (action.adapter !== expectedAdapter) {
|
|
391
|
+
failures.push(
|
|
392
|
+
`unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "${expectedAdapter}"`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (action.status !== 'PENDING') {
|
|
396
|
+
failures.push(
|
|
397
|
+
`unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Type-specific strict parameter checks ---
|
|
402
|
+
if (actionType === 'push-snapshot') {
|
|
403
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
404
|
+
_checkRequired(action, 'parameters.source', action.parameters?.source, unit.source, unitId, failures);
|
|
405
|
+
_checkRequired(action, 'parameters.cwd', action.parameters?.cwd, unit.source, unitId, failures);
|
|
406
|
+
_checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
|
|
407
|
+
if (production) {
|
|
408
|
+
_checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
|
|
409
|
+
_checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
410
|
+
_checkRequired(action, 'parameters.gitObjectDir', action.parameters?.gitObjectDir, frozen?.gitObjectDir, unitId, failures);
|
|
411
|
+
_checkRequired(action, 'parameters.branch', action.parameters?.branch, frozen?.branch, unitId, failures);
|
|
412
|
+
_checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
|
|
413
|
+
_checkRequired(action, 'parameters.tree', action.parameters?.tree, frozen?.tree, unitId, failures);
|
|
414
|
+
_checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
|
|
415
|
+
_checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
|
|
416
|
+
_checkRequired(action, 'expected.tree', action.expected?.tree, frozen?.tree, unitId, failures);
|
|
417
|
+
_checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
418
|
+
} else {
|
|
419
|
+
_checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (actionType === 'create-tag') {
|
|
424
|
+
_checkRequired(action, 'parameters.tagTemplate', action.parameters?.tagTemplate, tagTemplate, unitId, failures);
|
|
425
|
+
_checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
|
|
426
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
427
|
+
if (production) {
|
|
428
|
+
_checkRequired(action, 'parameters.tag', action.parameters?.tag, expectedTag, unitId, failures);
|
|
429
|
+
_checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
|
|
430
|
+
_checkRequired(action, 'parameters.gitObjectDir', action.parameters?.gitObjectDir, frozen?.gitObjectDir, unitId, failures);
|
|
431
|
+
_checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
|
|
432
|
+
_checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
|
|
433
|
+
_checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (actionType === 'github-release') {
|
|
438
|
+
_checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
|
|
439
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
440
|
+
if (production) {
|
|
441
|
+
_checkRequired(action, 'parameters.tag', action.parameters?.tag, expectedTag, unitId, failures);
|
|
442
|
+
_checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
|
|
443
|
+
_checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
|
|
444
|
+
_checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
|
|
445
|
+
const expectedName = (productionConfig.releaseTitleTemplate ?? 'Release {tag}')
|
|
446
|
+
.replaceAll('{tag}', expectedTag ?? '')
|
|
447
|
+
.replaceAll('{version}', targetVersion ?? '')
|
|
448
|
+
.replaceAll('{unit}', unitId);
|
|
449
|
+
_checkRequired(action, 'parameters.name', action.parameters?.name, expectedName, unitId, failures);
|
|
450
|
+
_checkRequired(action, 'parameters.notes', action.parameters?.notes, productionConfig.releaseNotes ?? `Release ${expectedTag}`, unitId, failures);
|
|
451
|
+
_checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
|
|
452
|
+
_checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// npm-publish: only required if unit has npm distribution
|
|
459
|
+
const npmDist = distributions.find((d) => d.type === 'npm');
|
|
460
|
+
if (npmDist) {
|
|
461
|
+
// Validate npm distribution has a package name
|
|
462
|
+
if (!npmDist.package || typeof npmDist.package !== 'string') {
|
|
463
|
+
failures.push(
|
|
464
|
+
`unit "${unitId}": npm distribution is missing "package" name`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
expectedCount++;
|
|
468
|
+
const expectedPkg = npmDist.package;
|
|
469
|
+
const npmActions = actions.filter(
|
|
470
|
+
(a) => a.unitId === unitId && a.type === 'npm-publish',
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (npmActions.length === 0) {
|
|
474
|
+
failures.push(
|
|
475
|
+
`unit "${unitId}": npm distribution declared but "npm-publish" action is missing`,
|
|
476
|
+
);
|
|
477
|
+
} else if (npmActions.length > 1) {
|
|
478
|
+
failures.push(
|
|
479
|
+
`unit "${unitId}": duplicate npm-publish actions (${npmActions.length} found, expected 1)`,
|
|
480
|
+
);
|
|
481
|
+
} else {
|
|
482
|
+
const action = npmActions[0];
|
|
483
|
+
const expectedActionId = `npm-publish-${unitId}`;
|
|
484
|
+
|
|
485
|
+
// --- Strict common field checks ---
|
|
486
|
+
if (action.id !== expectedActionId) {
|
|
487
|
+
failures.push(
|
|
488
|
+
`unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (action.unitId !== unitId) {
|
|
492
|
+
failures.push(
|
|
493
|
+
`unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (action.adapter !== 'npm') {
|
|
497
|
+
failures.push(
|
|
498
|
+
`unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "npm"`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (action.status !== 'PENDING') {
|
|
502
|
+
failures.push(
|
|
503
|
+
`unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// --- Strict parameter checks ---
|
|
508
|
+
_checkRequired(action, 'parameters.package', action.parameters?.package, expectedPkg, unitId, failures);
|
|
509
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
510
|
+
_checkRequired(action, 'parameters.cwd', action.parameters?.cwd, unit.source, unitId, failures);
|
|
511
|
+
_checkRequired(action, 'expected.package', action.expected?.package, expectedPkg, unitId, failures);
|
|
512
|
+
_checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
|
|
513
|
+
|
|
514
|
+
// Registry and publisher identity checks (always required for npm)
|
|
515
|
+
const expectedRegistry = npmDist.registry;
|
|
516
|
+
const expectedPublisher = npmDist.publisher;
|
|
517
|
+
if (!expectedRegistry || typeof expectedRegistry !== 'string') {
|
|
518
|
+
failures.push(`unit "${unitId}": npm distribution is missing "registry"`);
|
|
519
|
+
} else {
|
|
520
|
+
_checkRequired(action, 'parameters.registry', action.parameters?.registry, expectedRegistry, unitId, failures);
|
|
521
|
+
_checkRequired(action, 'expected.registry', action.expected?.registry, expectedRegistry, unitId, failures);
|
|
522
|
+
}
|
|
523
|
+
if (!expectedPublisher || typeof expectedPublisher !== 'string') {
|
|
524
|
+
failures.push(`unit "${unitId}": npm distribution is missing "publisher"`);
|
|
525
|
+
} else {
|
|
526
|
+
_checkRequired(action, 'parameters.publisher', action.parameters?.publisher, expectedPublisher, unitId, failures);
|
|
527
|
+
_checkRequired(action, 'expected.publisher', action.expected?.publisher, expectedPublisher, unitId, failures);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (production) {
|
|
531
|
+
_checkRequired(action, 'parameters.tarballPath', action.parameters?.tarballPath, frozen?.npm?.tarballPath, unitId, failures);
|
|
532
|
+
_checkRequired(action, 'parameters.tarballSha256', action.parameters?.tarballSha256, frozen?.npm?.tarballSha256, unitId, failures);
|
|
533
|
+
_checkRequired(action, 'parameters.integrity', action.parameters?.integrity, frozen?.npm?.integrity, unitId, failures);
|
|
534
|
+
_checkRequired(action, 'parameters.access', action.parameters?.access, npmDist.access, unitId, failures);
|
|
535
|
+
_checkRequired(action, 'parameters.provenance', action.parameters?.provenance, npmDist.provenance === true, unitId, failures);
|
|
536
|
+
if ((action.parameters?.tag ?? null) !== (npmDist.tag ?? null)) {
|
|
537
|
+
failures.push(`unit "${unitId}", action "${action.id}": parameters.tag does not match npm distribution tag`);
|
|
538
|
+
}
|
|
539
|
+
_checkRequired(action, 'expected.integrity', action.expected?.integrity, frozen?.npm?.integrity, unitId, failures);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// claude-marketplace-install: only required if unit has claude-plugin distribution
|
|
545
|
+
const claudeDist = distributions.find((d) => d.type === 'claude-plugin');
|
|
546
|
+
if (claudeDist) {
|
|
547
|
+
const plugin = claudeDist.plugin;
|
|
548
|
+
const marketplace = claudeDist.marketplace;
|
|
549
|
+
const entrySkill = claudeDist.entrySkill;
|
|
550
|
+
if (!plugin || !marketplace || !entrySkill) {
|
|
551
|
+
failures.push(`unit "${unitId}": claude-plugin distribution requires plugin, marketplace, and entrySkill`);
|
|
552
|
+
}
|
|
553
|
+
expectedCount++;
|
|
554
|
+
const expectedActionId = `claude-marketplace-install-${unitId}`;
|
|
555
|
+
const claudeActions = actions.filter(
|
|
556
|
+
(a) => a.unitId === unitId && a.type === 'claude-marketplace-install',
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
if (claudeActions.length === 0) {
|
|
560
|
+
failures.push(
|
|
561
|
+
`unit "${unitId}": claude-plugin distribution declared but "claude-marketplace-install" action is missing`,
|
|
562
|
+
);
|
|
563
|
+
} else if (claudeActions.length > 1) {
|
|
564
|
+
failures.push(
|
|
565
|
+
`unit "${unitId}": duplicate claude-marketplace-install actions (${claudeActions.length} found, expected 1)`,
|
|
566
|
+
);
|
|
567
|
+
} else {
|
|
568
|
+
const action = claudeActions[0];
|
|
569
|
+
|
|
570
|
+
if (action.id !== expectedActionId) {
|
|
571
|
+
failures.push(
|
|
572
|
+
`unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (action.unitId !== unitId) {
|
|
576
|
+
failures.push(
|
|
577
|
+
`unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (action.adapter !== 'plugin-marketplace') {
|
|
581
|
+
failures.push(
|
|
582
|
+
`unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (action.status !== 'PENDING') {
|
|
586
|
+
failures.push(
|
|
587
|
+
`unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Parameter checks
|
|
592
|
+
_checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'claude', unitId, failures);
|
|
593
|
+
_checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
|
|
594
|
+
_checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
|
|
595
|
+
_checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
|
|
596
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
597
|
+
_checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
|
|
598
|
+
if (production) {
|
|
599
|
+
_checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
|
|
600
|
+
_checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
|
|
601
|
+
_checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Expected checks
|
|
605
|
+
_checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
|
|
606
|
+
_checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
|
|
607
|
+
_checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
|
|
608
|
+
_checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
|
|
609
|
+
_checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
|
|
610
|
+
if (production) {
|
|
611
|
+
_checkRequired(action, 'expected.consumer', action.expected?.consumer, 'claude', unitId, failures);
|
|
612
|
+
_checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
|
|
613
|
+
_checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
|
|
614
|
+
_checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
|
|
615
|
+
_checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// codex-marketplace-install: only required if unit has codex-plugin distribution
|
|
621
|
+
const codexDist = distributions.find((d) => d.type === 'codex-plugin');
|
|
622
|
+
if (codexDist) {
|
|
623
|
+
const plugin = codexDist.plugin;
|
|
624
|
+
const marketplace = codexDist.marketplace;
|
|
625
|
+
const entrySkill = codexDist.entrySkill;
|
|
626
|
+
if (!plugin || !marketplace || !entrySkill) {
|
|
627
|
+
failures.push(`unit "${unitId}": codex-plugin distribution requires plugin, marketplace, and entrySkill`);
|
|
628
|
+
}
|
|
629
|
+
expectedCount++;
|
|
630
|
+
const expectedActionId = `codex-marketplace-install-${unitId}`;
|
|
631
|
+
const codexActions = actions.filter(
|
|
632
|
+
(a) => a.unitId === unitId && a.type === 'codex-marketplace-install',
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (codexActions.length === 0) {
|
|
636
|
+
failures.push(
|
|
637
|
+
`unit "${unitId}": codex-plugin distribution declared but "codex-marketplace-install" action is missing`,
|
|
638
|
+
);
|
|
639
|
+
} else if (codexActions.length > 1) {
|
|
640
|
+
failures.push(
|
|
641
|
+
`unit "${unitId}": duplicate codex-marketplace-install actions (${codexActions.length} found, expected 1)`,
|
|
642
|
+
);
|
|
643
|
+
} else {
|
|
644
|
+
const action = codexActions[0];
|
|
645
|
+
|
|
646
|
+
if (action.id !== expectedActionId) {
|
|
647
|
+
failures.push(
|
|
648
|
+
`unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (action.unitId !== unitId) {
|
|
652
|
+
failures.push(
|
|
653
|
+
`unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (action.adapter !== 'plugin-marketplace') {
|
|
657
|
+
failures.push(
|
|
658
|
+
`unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (action.status !== 'PENDING') {
|
|
662
|
+
failures.push(
|
|
663
|
+
`unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Parameter checks
|
|
668
|
+
_checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'codex', unitId, failures);
|
|
669
|
+
_checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
|
|
670
|
+
_checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
|
|
671
|
+
_checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
|
|
672
|
+
_checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
|
|
673
|
+
_checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
|
|
674
|
+
if (production) {
|
|
675
|
+
_checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
|
|
676
|
+
_checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
|
|
677
|
+
_checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Expected checks
|
|
681
|
+
_checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
|
|
682
|
+
_checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
|
|
683
|
+
_checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
|
|
684
|
+
_checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
|
|
685
|
+
_checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
|
|
686
|
+
if (production) {
|
|
687
|
+
_checkRequired(action, 'expected.consumer', action.expected?.consumer, 'codex', unitId, failures);
|
|
688
|
+
_checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
|
|
689
|
+
_checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
|
|
690
|
+
_checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
|
|
691
|
+
_checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// --- Strict count equality: actual must exactly match expected ---
|
|
698
|
+
const actualCount = actions.length;
|
|
699
|
+
if (actualCount !== expectedCount) {
|
|
700
|
+
const diff = actualCount > expectedCount
|
|
701
|
+
? `${actualCount - expectedCount} extra action(s) detected`
|
|
702
|
+
: `${expectedCount - actualCount} action(s) missing from plan`;
|
|
703
|
+
failures.push(
|
|
704
|
+
`plan has ${actualCount} action(s) but exactly ${expectedCount} expected from unit definitions; ${diff}`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
passed: failures.length === 0,
|
|
710
|
+
details: { failures, expectedCount, actualCount },
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Check that a required field exists and equals the expected value.
|
|
716
|
+
* Missing or mismatched values both produce failures.
|
|
717
|
+
*
|
|
718
|
+
* @param {object} action - The action being checked (for error messages).
|
|
719
|
+
* @param {string} fieldPath - Human-readable field path (e.g. 'parameters.version').
|
|
720
|
+
* @param {*} actual - The actual value (may be undefined if missing).
|
|
721
|
+
* @param {*} expected - The expected value.
|
|
722
|
+
* @param {string} unitId - The unit ID (for error messages).
|
|
723
|
+
* @param {string[]} failures - Accumulator for failure messages.
|
|
724
|
+
*/
|
|
725
|
+
function _checkRequired(action, fieldPath, actual, expected, unitId, failures) {
|
|
726
|
+
if (actual === undefined || actual === null) {
|
|
727
|
+
failures.push(
|
|
728
|
+
`unit "${unitId}", action "${action.id}": ${fieldPath} is missing, expected "${expected}"`,
|
|
729
|
+
);
|
|
730
|
+
} else if (actual !== expected) {
|
|
731
|
+
failures.push(
|
|
732
|
+
`unit "${unitId}", action "${action.id}": ${fieldPath} is "${actual}", expected "${expected}"`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|