release-skill 0.6.2 → 0.6.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +126 -9
- package/README.zh-CN.md +108 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +65 -1
- package/adapters/claude/schemas/release-project.schema.json +73 -1
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +65 -1
- package/adapters/codex/schemas/release-project.schema.json +73 -1
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +65 -1
- package/adapters/kimi/schemas/release-project.schema.json +73 -1
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
- package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/release-skill-cli.mjs +181 -3
- package/bin/release-skill.bundle.mjs +6408 -1654
- package/package.json +2 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +65 -1
- package/schemas/release-project.schema.json +73 -1
- package/schemas/release-run.schema.json +11 -6
- package/src/commands/approve.mjs +167 -1
- package/src/commands/distribute.mjs +411 -33
- package/src/commands/postverify.mjs +734 -0
- package/src/commands/prepare.mjs +280 -42
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +167 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +174 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +110 -0
- package/src/core/postpublish.mjs +424 -7
- package/src/core/preset-executor.mjs +156 -0
- package/src/core/preset-gitwrite.mjs +463 -0
- package/src/core/presets.mjs +706 -0
- package/src/core/proposal-inbox.mjs +630 -0
- package/src/core/run.mjs +91 -6
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version-sensitive derived-artifact fast pre-gates (O1, 2026-08-18
|
|
3
|
+
* release-cycle investigation §3.2).
|
|
4
|
+
*
|
|
5
|
+
* In the 0.6.1 cycle two prepares burned the full ~80s test hook before
|
|
6
|
+
* surfacing drift that a sub-second check could have caught: adapter trees
|
|
7
|
+
* out of sync with skills-src/, and self-bootstrap fact pins still bound to
|
|
8
|
+
* the previous version. This module promotes those two exact checks to
|
|
9
|
+
* prepare's earliest stage, alongside the bundle freshness gate:
|
|
10
|
+
*
|
|
11
|
+
* - adapter gate: runs `scripts/build-adapters.mjs --check` (drift list,
|
|
12
|
+
* exit 1 on drift — the same supported check the scripts surface offers);
|
|
13
|
+
* - fact-pin gate: runs the version fact pins of
|
|
14
|
+
* `test/release-docs-self-bootstrap.test.mjs` — exactly its hermetic
|
|
15
|
+
* section 1 (`[self-bootstrap 1*]`: byte-level version assertions plus the
|
|
16
|
+
* in-process read-only planner), scoped via `--test-name-pattern`.
|
|
17
|
+
*
|
|
18
|
+
* Both promote the CANONICAL check logic — the gate and the full pipeline
|
|
19
|
+
* can never disagree about what "in sync" means. The facts gate is scoped to
|
|
20
|
+
* the suite's hermetic fact-pin section deliberately: the suite's remaining
|
|
21
|
+
* sections shell out to npm/git and drive fixture prepares, which would make
|
|
22
|
+
* a "fast pre-gate" slow, recursive, and brittle under toolchain-shimming
|
|
23
|
+
* fixtures (a prepare invoked with a shimmed `npm` would crash the gate
|
|
24
|
+
* child and false-report drift). Those sections remain the full test hooks'
|
|
25
|
+
* job — this gate never replaces them. Every failure message therefore
|
|
26
|
+
* states the boundary explicitly: this is a fast pre-gate and does NOT
|
|
27
|
+
* replace the full test hooks(快速前置,不替代全量测试).
|
|
28
|
+
*
|
|
29
|
+
* Spawn hygiene: the child environment drops NODE_TEST_CONTEXT. When prepare
|
|
30
|
+
* itself runs inside a node:test harness, the runner exports that variable,
|
|
31
|
+
* and an inheriting `node --test` child then prints "run() is being called
|
|
32
|
+
* recursively ... skipping running files" and exits 0 — a false pass this
|
|
33
|
+
* gate must never report as fresh. Production prepare runs in a plain shell
|
|
34
|
+
* where the variable is absent, so the sanitization is a pure hardening.
|
|
35
|
+
*
|
|
36
|
+
* Recursion guard: the facts gate spawns the very suite whose fixture
|
|
37
|
+
* prepares call prepareRelease — an unguarded child would re-enter the gate
|
|
38
|
+
* and recurse without bound (every level waits on its own child until the
|
|
39
|
+
* 300s timeouts cascade). Gate children therefore carry
|
|
40
|
+
* RELEASE_SKILL_FACTS_GATE_ACTIVE; a facts gate running under the marker
|
|
41
|
+
* records not-applicable (reason nested-gate-run) and spawns nothing. Only
|
|
42
|
+
* the facts gate needs the guard: the adapter child (build-adapters --check)
|
|
43
|
+
* never calls prepareRelease. The outermost prepare still enforces both
|
|
44
|
+
* gates; only the verification run itself is exempt.
|
|
45
|
+
*
|
|
46
|
+
* Applicability mirrors bundle-freshness: installed distributions ship
|
|
47
|
+
* neither the build scripts nor the test file, so the gates record
|
|
48
|
+
* not-applicable there; a source checkout is always gated.
|
|
49
|
+
*
|
|
50
|
+
* @module core/derived-artifact-gates
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { lstat } from 'node:fs/promises';
|
|
54
|
+
import { join } from 'node:path';
|
|
55
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
56
|
+
import { promisify } from 'node:util';
|
|
57
|
+
|
|
58
|
+
import { ReleaseError, DERIVED_ARTIFACT_STALE } from './errors.mjs';
|
|
59
|
+
import { boundedOutputTail } from './bounded-output.mjs';
|
|
60
|
+
|
|
61
|
+
const defaultExecFile = promisify(execFileCb);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Exact bilingual note every pre-gate failure carries: the gates are a fast
|
|
65
|
+
* front line and never a substitute for the full test hooks.
|
|
66
|
+
*/
|
|
67
|
+
export const DERIVED_ARTIFACT_PREGATE_NOTE =
|
|
68
|
+
'This is a fast pre-gate and does not replace the full test hooks(快速前置,不替代全量测试).';
|
|
69
|
+
|
|
70
|
+
/** One-click derived-artifact sync suggested by every drift remediation (O2). */
|
|
71
|
+
export const DERIVED_SYNC_COMMAND = 'node scripts/sync-derived-artifacts.mjs';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Marker every gate child carries. A facts gate running under it is part of
|
|
75
|
+
* the verification run itself and records not-applicable instead of
|
|
76
|
+
* re-spawning the suite (recursion guard, see module docs).
|
|
77
|
+
*/
|
|
78
|
+
export const FACTS_GATE_NESTED_ENV = 'RELEASE_SKILL_FACTS_GATE_ACTIVE';
|
|
79
|
+
|
|
80
|
+
/** Gate descriptors: marker file (applicability), argv, remediation text. */
|
|
81
|
+
const GATES = Object.freeze({
|
|
82
|
+
adapters: Object.freeze({
|
|
83
|
+
artifact: 'adapters',
|
|
84
|
+
marker: join('scripts', 'build-adapters.mjs'),
|
|
85
|
+
argv: (pkgRoot) => [join(pkgRoot, 'scripts', 'build-adapters.mjs'), '--check'],
|
|
86
|
+
timeoutMs: 120000,
|
|
87
|
+
remediation:
|
|
88
|
+
'Rebuild the existing adapters with: node scripts/build-adapters.mjs --apply ' +
|
|
89
|
+
`(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
|
|
90
|
+
}),
|
|
91
|
+
'self-bootstrap-facts': Object.freeze({
|
|
92
|
+
artifact: 'self-bootstrap-facts',
|
|
93
|
+
marker: join('test', 'release-docs-self-bootstrap.test.mjs'),
|
|
94
|
+
// Hermetic fact-pin section only (see module docs): byte-level version
|
|
95
|
+
// facts + in-process planner — no npm/git, no fixture prepares.
|
|
96
|
+
argv: (pkgRoot) => [
|
|
97
|
+
'--test',
|
|
98
|
+
'--test-name-pattern',
|
|
99
|
+
'\\[self-bootstrap 1',
|
|
100
|
+
join(pkgRoot, 'test', 'release-docs-self-bootstrap.test.mjs'),
|
|
101
|
+
],
|
|
102
|
+
timeoutMs: 120000,
|
|
103
|
+
remediation:
|
|
104
|
+
'Refresh the derived documents and version points first ' +
|
|
105
|
+
`(workspace root: ${DERIVED_SYNC_COMMAND}); if the pins still fail, update the fact pins deliberately — ` +
|
|
106
|
+
'the gate never edits sources or test pins itself.',
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function isFile(path) {
|
|
111
|
+
try {
|
|
112
|
+
return (await lstat(path)).isFile();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run one derived-artifact check (pure decision, never throws).
|
|
120
|
+
*
|
|
121
|
+
* @param {'adapters' | 'self-bootstrap-facts'} kind - Gate to run.
|
|
122
|
+
* @param {string} pkgRoot - Absolute package root of the running checkout.
|
|
123
|
+
* @param {object} [options]
|
|
124
|
+
* @param {Function} [options.execFileFn] - execFile seam (tests).
|
|
125
|
+
* @param {number} [options.timeoutMs] - Child timeout override.
|
|
126
|
+
* @returns {Promise<{
|
|
127
|
+
* applicable: boolean,
|
|
128
|
+
* fresh?: boolean,
|
|
129
|
+
* reason?: string,
|
|
130
|
+
* artifact?: string,
|
|
131
|
+
* exitCode?: number | null,
|
|
132
|
+
* stdoutTail?: string,
|
|
133
|
+
* stderrTail?: string,
|
|
134
|
+
* durationMs?: number,
|
|
135
|
+
* }>} stdoutTail/stderrTail are bounded and present on BOTH outcomes —
|
|
136
|
+
* on the fresh path they prove the child really executed (e.g. the facts
|
|
137
|
+
* gate carries the child suite's own `ℹ tests N` summary).
|
|
138
|
+
*/
|
|
139
|
+
export async function checkDerivedArtifactGate(kind, pkgRoot, options = {}) {
|
|
140
|
+
const gate = GATES[kind];
|
|
141
|
+
if (!gate) {
|
|
142
|
+
throw new ReleaseError(DERIVED_ARTIFACT_STALE, `unknown derived-artifact gate: ${kind}`, { kind });
|
|
143
|
+
}
|
|
144
|
+
const execFileFn = options.execFileFn ?? defaultExecFile;
|
|
145
|
+
|
|
146
|
+
if (kind === 'self-bootstrap-facts' && process.env[FACTS_GATE_NESTED_ENV]) {
|
|
147
|
+
// Recursion guard: this prepare runs inside a gate child (the suite the
|
|
148
|
+
// facts gate itself spawns). Re-spawning would recurse without bound;
|
|
149
|
+
// the outermost prepare already enforces the gate for this checkout.
|
|
150
|
+
return { applicable: false, reason: 'nested-gate-run', artifact: gate.artifact };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!(await isFile(join(pkgRoot, gate.marker)))) {
|
|
154
|
+
// Installed distributions ship neither the build scripts nor the test
|
|
155
|
+
// file; drift is a source-checkout concern only (bundle-freshness rule).
|
|
156
|
+
return { applicable: false, reason: 'installed-layout', artifact: gate.artifact };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
// Never hand the child a test-runner context: under a nested node:test
|
|
161
|
+
// harness NODE_TEST_CONTEXT makes `node --test` skip all files and exit 0,
|
|
162
|
+
// which this gate must not report as fresh (false pass).
|
|
163
|
+
const childEnv = { ...process.env };
|
|
164
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
165
|
+
childEnv[FACTS_GATE_NESTED_ENV] = '1';
|
|
166
|
+
try {
|
|
167
|
+
const { stdout, stderr } = await execFileFn(process.execPath, gate.argv(pkgRoot), {
|
|
168
|
+
cwd: pkgRoot,
|
|
169
|
+
shell: false,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
timeout: options.timeoutMs ?? gate.timeoutMs,
|
|
172
|
+
env: childEnv,
|
|
173
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
applicable: true,
|
|
177
|
+
fresh: true,
|
|
178
|
+
artifact: gate.artifact,
|
|
179
|
+
stdoutTail: boundedOutputTail(stdout ?? ''),
|
|
180
|
+
stderrTail: boundedOutputTail(stderr ?? ''),
|
|
181
|
+
durationMs: Date.now() - startedAt,
|
|
182
|
+
};
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Timeout / spawn failure fail closed too: an undecidable gate is drift.
|
|
185
|
+
const stdoutTail = boundedOutputTail(err?.stdout ?? '');
|
|
186
|
+
const stderrTail = boundedOutputTail(err?.stderr ?? err?.message ?? '');
|
|
187
|
+
return {
|
|
188
|
+
applicable: true,
|
|
189
|
+
fresh: false,
|
|
190
|
+
reason: err?.killed || err?.code === 'ETIMEDOUT' ? 'timeout' : 'drift',
|
|
191
|
+
artifact: gate.artifact,
|
|
192
|
+
exitCode: typeof err?.code === 'number' ? err.code : null,
|
|
193
|
+
stdoutTail,
|
|
194
|
+
stderrTail,
|
|
195
|
+
durationMs: Date.now() - startedAt,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Adapter freshness decision (build-adapters --check). */
|
|
201
|
+
export function checkAdapterFreshness(pkgRoot, options = {}) {
|
|
202
|
+
return checkDerivedArtifactGate('adapters', pkgRoot, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Self-bootstrap fact-pin decision (single-file test). */
|
|
206
|
+
export function checkSelfBootstrapFacts(pkgRoot, options = {}) {
|
|
207
|
+
return checkDerivedArtifactGate('self-bootstrap-facts', pkgRoot, options);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function assertGate(kind, pkgRoot, options = {}) {
|
|
211
|
+
const result = await checkDerivedArtifactGate(kind, pkgRoot, options);
|
|
212
|
+
if (!result.applicable || result.fresh) {
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
const gate = GATES[kind];
|
|
216
|
+
const subject = kind === 'adapters'
|
|
217
|
+
? 'adapters/ is out of sync with its sources (build-adapters --check reported drift)'
|
|
218
|
+
: 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
|
|
219
|
+
throw new ReleaseError(
|
|
220
|
+
DERIVED_ARTIFACT_STALE,
|
|
221
|
+
`${subject}. ${DERIVED_ARTIFACT_PREGATE_NOTE} ${gate.remediation}`,
|
|
222
|
+
{
|
|
223
|
+
artifact: result.artifact,
|
|
224
|
+
reason: result.reason,
|
|
225
|
+
exitCode: result.exitCode,
|
|
226
|
+
stdoutTail: result.stdoutTail,
|
|
227
|
+
stderrTail: result.stderrTail,
|
|
228
|
+
durationMs: result.durationMs,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Fail-closed adapter pre-gate used by prepare's earliest stage.
|
|
235
|
+
* Not-applicable layouts return quietly; drift throws DERIVED_ARTIFACT_STALE.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} pkgRoot - Absolute package root.
|
|
238
|
+
* @param {object} [options] - execFileFn/timeoutMs seams (tests).
|
|
239
|
+
* @returns {Promise<object>} The gate decision.
|
|
240
|
+
* @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
|
|
241
|
+
*/
|
|
242
|
+
export function assertAdapterFreshness(pkgRoot, options = {}) {
|
|
243
|
+
return assertGate('adapters', pkgRoot, options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fail-closed self-bootstrap fact-pin pre-gate used by prepare's earliest
|
|
248
|
+
* stage. Not-applicable layouts return quietly; drift throws
|
|
249
|
+
* DERIVED_ARTIFACT_STALE.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} pkgRoot - Absolute package root.
|
|
252
|
+
* @param {object} [options] - execFileFn/timeoutMs seams (tests).
|
|
253
|
+
* @returns {Promise<object>} The gate decision.
|
|
254
|
+
* @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
|
|
255
|
+
*/
|
|
256
|
+
export function assertSelfBootstrapFacts(pkgRoot, options = {}) {
|
|
257
|
+
return assertGate('self-bootstrap-facts', pkgRoot, options);
|
|
258
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs-refresh preset: refresh one or more independent docs repositories
|
|
3
|
+
* (v0.6.3 R4, design §2.5).
|
|
4
|
+
*
|
|
5
|
+
* GitHub-Pages-style docs sites live in their own repositories. One
|
|
6
|
+
* declaration refreshes ALL of them (config.repositories array) from the
|
|
7
|
+
* frozen release payload:
|
|
8
|
+
*
|
|
9
|
+
* - config.mappings copies payload files into the docs repository
|
|
10
|
+
* (from = payload-relative source, to = repository-relative destination);
|
|
11
|
+
* an optional per-mapping versionMarker placeholder is replaced with the
|
|
12
|
+
* frozen release version while writing (version-marker replacement);
|
|
13
|
+
* - config.gates (argument arrays, R1 hook runner) run inside each docs
|
|
14
|
+
* repository AFTER the write and BEFORE any commit/push — the docs build
|
|
15
|
+
* gate; a failing gate leaves zero remote side effects;
|
|
16
|
+
* - every repository is committed with the frozen bot identity and pushed
|
|
17
|
+
* (never --force); byte-identical content -> NO_CHANGE (idempotent).
|
|
18
|
+
*
|
|
19
|
+
* Payload requirement: mappings read the materialized payload, which only
|
|
20
|
+
* exists in the distribute phase (§2.3 contexts carry payloadDir there); a
|
|
21
|
+
* postVerify-phase declaration fails closed with a clear message.
|
|
22
|
+
*
|
|
23
|
+
* @module core/docs-refresh-preset
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
30
|
+
import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Assert `from` stays inside the materialized payload directory (declaration
|
|
34
|
+
* validation guarantees a safe relative shape; this is the execution-time
|
|
35
|
+
* re-check).
|
|
36
|
+
*/
|
|
37
|
+
function resolvePayloadSource(payloadDir, from) {
|
|
38
|
+
const sourcePath = resolve(payloadDir, from);
|
|
39
|
+
const rel = relative(payloadDir, sourcePath);
|
|
40
|
+
if (rel === '' || isAbsolute(rel) || rel === '..'
|
|
41
|
+
|| rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
42
|
+
throw new ReleaseError(
|
|
43
|
+
GATE_FAILED,
|
|
44
|
+
`docs-refresh mapping source "${from}" escapes the payload directory`,
|
|
45
|
+
{ from },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return sourcePath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Execute one docs-refresh preset hook end-to-end: for every declared docs
|
|
53
|
+
* repository, copy the mapped payload files (version-marker replacement),
|
|
54
|
+
* run the docs build gates, and push. Shared by distribute and postVerify
|
|
55
|
+
* (payload requirement effectively binds it to the distribute phase).
|
|
56
|
+
*
|
|
57
|
+
* @param {object} params
|
|
58
|
+
* @param {object} params.hook - Declared hook entry (config bound).
|
|
59
|
+
* @param {object} params.contextProjection - The §2.3 context projection.
|
|
60
|
+
* @param {object} params.commitIdentity - Frozen commitIdentity.
|
|
61
|
+
* @param {string} params.payloadDir - Materialized payload directory
|
|
62
|
+
* (distribute phase; postVerify contexts never carry it).
|
|
63
|
+
* @param {string} params.root - Release workspace root.
|
|
64
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
65
|
+
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
66
|
+
* @returns {Promise<{ status: string, observation: object,
|
|
67
|
+
* observations: object[], mode: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function executeDocsRefreshHook(params) {
|
|
70
|
+
const { hook, contextProjection, commitIdentity, payloadDir, root, exec, hookRunner } = params ?? {};
|
|
71
|
+
const config = hook?.config;
|
|
72
|
+
const repositories = config?.repositories;
|
|
73
|
+
if (!Array.isArray(repositories) || repositories.length === 0) {
|
|
74
|
+
throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.repositories array');
|
|
75
|
+
}
|
|
76
|
+
const mappings = config?.mappings;
|
|
77
|
+
if (!Array.isArray(mappings) || mappings.length === 0) {
|
|
78
|
+
throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.mappings array');
|
|
79
|
+
}
|
|
80
|
+
if (typeof payloadDir !== 'string' || payloadDir.length === 0) {
|
|
81
|
+
throw new ReleaseError(
|
|
82
|
+
GATE_FAILED,
|
|
83
|
+
'docs-refresh copies files from the materialized payload, which only exists in the distribute phase; declare phase: distribute (the default) instead of postVerify',
|
|
84
|
+
{},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unitId = contextProjection?.unitId ?? 'unknown';
|
|
89
|
+
const version = contextProjection?.version ?? 'unknown';
|
|
90
|
+
const gates = config?.gates ?? [];
|
|
91
|
+
|
|
92
|
+
// Deterministic per-mapping write. Binary-safe copy; the version-marker
|
|
93
|
+
// replacement (when declared) treats the file as UTF-8 text.
|
|
94
|
+
const mutate = async (worktree) => {
|
|
95
|
+
for (const mapping of mappings) {
|
|
96
|
+
const sourcePath = resolvePayloadSource(payloadDir, mapping.from);
|
|
97
|
+
let content = await readFile(sourcePath).catch(() => null);
|
|
98
|
+
if (content === null) {
|
|
99
|
+
throw new ReleaseError(
|
|
100
|
+
GATE_FAILED,
|
|
101
|
+
`docs-refresh mapping source "${mapping.from}" is missing from the materialized payload`,
|
|
102
|
+
{ from: mapping.from },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (typeof mapping.versionMarker === 'string' && mapping.versionMarker.length > 0) {
|
|
106
|
+
const text = content.toString('utf8');
|
|
107
|
+
content = Buffer.from(text.split(mapping.versionMarker).join(version), 'utf8');
|
|
108
|
+
}
|
|
109
|
+
const destination = join(worktree, mapping.to);
|
|
110
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
111
|
+
await writeFile(destination, content);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const observations = [];
|
|
116
|
+
let anyChange = false;
|
|
117
|
+
for (const [index, target] of repositories.entries()) {
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await applyDownstreamGitChange({
|
|
121
|
+
target,
|
|
122
|
+
commitIdentity,
|
|
123
|
+
commitSubject: `release-skill docs-refresh ${unitId} ${version}`,
|
|
124
|
+
mutate,
|
|
125
|
+
gates,
|
|
126
|
+
contextProjection,
|
|
127
|
+
root,
|
|
128
|
+
...(exec !== undefined ? { exec } : {}),
|
|
129
|
+
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new ReleaseError(
|
|
133
|
+
err?.code ?? GATE_FAILED,
|
|
134
|
+
`docs-refresh repository ${index + 1} of ${repositories.length} failed: ${err?.message ?? err}`,
|
|
135
|
+
{ repositoryIndex: index, ...(err?.details ?? {}) },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
observations.push({
|
|
139
|
+
repositoryIndex: index,
|
|
140
|
+
...(typeof target.remoteUrl === 'string' ? { remoteUrl: target.remoteUrl } : {}),
|
|
141
|
+
...(typeof target.workspace === 'string' ? { workspace: target.workspace } : {}),
|
|
142
|
+
branch: target.branch,
|
|
143
|
+
...(result.observation ?? {}),
|
|
144
|
+
});
|
|
145
|
+
if (result.status === 'EXECUTED') anyChange = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!anyChange) {
|
|
149
|
+
return {
|
|
150
|
+
status: 'NO_CHANGE',
|
|
151
|
+
mode: 'no-change',
|
|
152
|
+
observation: { mode: 'no-change' },
|
|
153
|
+
observations,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const firstPushed = observations.find((entry) => entry.mode === 'pushed');
|
|
157
|
+
return {
|
|
158
|
+
status: 'EXECUTED',
|
|
159
|
+
mode: 'pushed',
|
|
160
|
+
observation: {
|
|
161
|
+
mode: 'pushed',
|
|
162
|
+
...(firstPushed?.pushedCommit ? { pushedCommit: firstPushed.pushedCommit } : {}),
|
|
163
|
+
repositoryCount: repositories.length,
|
|
164
|
+
},
|
|
165
|
+
observations,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/core/errors.mjs
CHANGED
|
@@ -95,6 +95,8 @@ const EXIT_CODE_MAP = Object.freeze({
|
|
|
95
95
|
CONTENT_MISMATCH: 52,
|
|
96
96
|
DIRTY_SOURCE_INPUT: 53,
|
|
97
97
|
BUNDLE_STALE: 54,
|
|
98
|
+
POSTPUBLISH_HOOK_INVALID: 55,
|
|
99
|
+
DERIVED_ARTIFACT_STALE: 56,
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
// ---- Error code constants ----
|
|
@@ -144,6 +146,8 @@ export const NOT_DEFAULT = 'NOT_DEFAULT';
|
|
|
144
146
|
export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
|
|
145
147
|
export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
|
|
146
148
|
export const BUNDLE_STALE = 'BUNDLE_STALE';
|
|
149
|
+
export const POSTPUBLISH_HOOK_INVALID = 'POSTPUBLISH_HOOK_INVALID';
|
|
150
|
+
export const DERIVED_ARTIFACT_STALE = 'DERIVED_ARTIFACT_STALE';
|
|
147
151
|
|
|
148
152
|
/**
|
|
149
153
|
* Typed error for release-skill operations.
|
package/src/core/hooks.mjs
CHANGED
|
@@ -197,6 +197,10 @@ function buildFilteredEnv(envAllowlist, contextEnv) {
|
|
|
197
197
|
* @param {Object} context
|
|
198
198
|
* @param {string} context.root - Absolute project root.
|
|
199
199
|
* @param {Record<string, string>} [context.env] - Extra env variables.
|
|
200
|
+
* @param {Record<string, string>} [context.injectEnv] - Always-injected
|
|
201
|
+
* contract variables (merged AFTER allowlist filtering; used by the
|
|
202
|
+
* postPublish hook context contract RELEASE_SKILL_POSTPUBLISH_CONTEXT).
|
|
203
|
+
* Keys must match /^[A-Z_][A-Z0-9_]*$/, values must be strings.
|
|
200
204
|
*
|
|
201
205
|
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
202
206
|
*
|
|
@@ -233,6 +237,30 @@ export async function runHook(hook, context) {
|
|
|
233
237
|
// --- Build safe environment ---
|
|
234
238
|
const env = buildFilteredEnv(envAllowlist, context.env);
|
|
235
239
|
|
|
240
|
+
// --- Always-injected contract variables (postPublish context, v0.6.3 R1).
|
|
241
|
+
// Merged after allowlist filtering: declarations cannot be mutated to carry
|
|
242
|
+
// the context variable, and the runner must not depend on envAllowlist.
|
|
243
|
+
if (context.injectEnv !== undefined) {
|
|
244
|
+
if (!context.injectEnv || typeof context.injectEnv !== 'object' || Array.isArray(context.injectEnv)) {
|
|
245
|
+
throw new ReleaseError('INVALID_HOOK', 'context.injectEnv must be a plain object');
|
|
246
|
+
}
|
|
247
|
+
for (const [key, value] of Object.entries(context.injectEnv)) {
|
|
248
|
+
if (!ENV_KEY_PATTERN.test(key)) {
|
|
249
|
+
throw new ReleaseError(
|
|
250
|
+
'INVALID_HOOK',
|
|
251
|
+
`context.injectEnv key "${key}" must match /^[A-Z_][A-Z0-9_]*$/`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (typeof value !== 'string') {
|
|
255
|
+
throw new ReleaseError(
|
|
256
|
+
'INVALID_HOOK',
|
|
257
|
+
`context.injectEnv value for "${key}" must be a string`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
env[key] = value;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
// --- Set up timeout ---
|
|
237
265
|
const executable = command[0];
|
|
238
266
|
const args = command.slice(1);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* marketplace-registry-entry preset: direct-edit downstream registry entry
|
|
3
|
+
* update (v0.6.3 R4, design §2.5).
|
|
4
|
+
*
|
|
5
|
+
* For downstream marketplaces WITHOUT their own governance/render pipeline:
|
|
6
|
+
* locate the registry entry by config.entryKey inside config.registryPath,
|
|
7
|
+
* update the declared fieldsFromPlan from the FROZEN plan values (§2.3
|
|
8
|
+
* context projection), run the declared downstream gates (argument arrays via
|
|
9
|
+
* the R1 hook runner), then push. Hubs with their own governance use
|
|
10
|
+
* proposal-inbox instead.
|
|
11
|
+
*
|
|
12
|
+
* Registry document shape (canonical, validated fail-closed):
|
|
13
|
+
* { "entries": [ { "key": "<entryKey>", ...fields } ] }
|
|
14
|
+
* - a missing registry file, a missing/malformed `entries` array, or a
|
|
15
|
+
* missing entry key is REMOTE_CONFLICT: the downstream state disagrees
|
|
16
|
+
* with the declaration and a human decides (nothing is ever invented);
|
|
17
|
+
* - the updated document is serialized deterministically (2-space indent +
|
|
18
|
+
* trailing newline): byte-identical output -> NO_CHANGE (idempotent);
|
|
19
|
+
* - every other entry and field is preserved untouched.
|
|
20
|
+
*
|
|
21
|
+
* fieldsFromPlan maps entry field -> §2.3 context field; only frozen plan
|
|
22
|
+
* values are ever written (version/tag/commit/tree/manifestDigest/planDigest/
|
|
23
|
+
* publishedAt/unitId). A source value absent from the frozen plan fails
|
|
24
|
+
* closed before any write.
|
|
25
|
+
*
|
|
26
|
+
* @module core/marketplace-registry-entry
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
import { ReleaseError, GATE_FAILED, REMOTE_CONFLICT } from './errors.mjs';
|
|
33
|
+
import { FIELDS_FROM_PLAN_SOURCES } from './presets.mjs';
|
|
34
|
+
import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
|
|
35
|
+
|
|
36
|
+
export { FIELDS_FROM_PLAN_SOURCES };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply the frozen-plan field update to one registry document. Pure and
|
|
40
|
+
* deterministic: returns the updated document, or throws when the document
|
|
41
|
+
* shape or the entry disagrees with the declaration.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} registry - Parsed registry document.
|
|
44
|
+
* @param {object} params - { entryKey, fieldsFromPlan, contextProjection }.
|
|
45
|
+
* @returns {object} The updated registry document (new object).
|
|
46
|
+
* @throws {ReleaseError} REMOTE_CONFLICT when the entry cannot be located;
|
|
47
|
+
* GATE_FAILED when a frozen source value is missing.
|
|
48
|
+
*/
|
|
49
|
+
export function updateRegistryEntry(registry, params) {
|
|
50
|
+
const { entryKey, fieldsFromPlan, contextProjection } = params ?? {};
|
|
51
|
+
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
|
|
52
|
+
throw new ReleaseError(REMOTE_CONFLICT, 'marketplace registry file is not a JSON object; human decision required', {});
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(registry.entries)) {
|
|
55
|
+
throw new ReleaseError(
|
|
56
|
+
REMOTE_CONFLICT,
|
|
57
|
+
'marketplace registry file carries no "entries" array; the marketplace-registry-entry preset expects { "entries": [ { "key": ... } ] }',
|
|
58
|
+
{},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const entryIndex = registry.entries.findIndex(
|
|
62
|
+
(entry) => entry && typeof entry === 'object' && !Array.isArray(entry) && entry.key === entryKey,
|
|
63
|
+
);
|
|
64
|
+
if (entryIndex < 0) {
|
|
65
|
+
throw new ReleaseError(
|
|
66
|
+
REMOTE_CONFLICT,
|
|
67
|
+
`marketplace registry entry "${entryKey}" not found; registering a new entry requires a human decision`,
|
|
68
|
+
{ entryKey },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const updated = JSON.parse(JSON.stringify(registry)); // deep, order-stable copy
|
|
73
|
+
const entry = updated.entries[entryIndex];
|
|
74
|
+
for (const [entryField, sourceField] of Object.entries(fieldsFromPlan)) {
|
|
75
|
+
const value = contextProjection?.[sourceField];
|
|
76
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
77
|
+
throw new ReleaseError(
|
|
78
|
+
GATE_FAILED,
|
|
79
|
+
`fieldsFromPlan."${entryField}" maps to context field "${sourceField}" which the frozen plan does not provide`,
|
|
80
|
+
{ entryField, sourceField },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
entry[entryField] = value;
|
|
84
|
+
}
|
|
85
|
+
return updated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deterministic registry serialization (byte-stable NO_CHANGE detection). */
|
|
89
|
+
export function serializeRegistry(registry) {
|
|
90
|
+
return `${JSON.stringify(registry, null, 2)}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Execute one marketplace-registry-entry preset hook end-to-end: read the
|
|
95
|
+
* downstream registry, apply the frozen-plan update, run the downstream
|
|
96
|
+
* gates, and push (never --force). Shared by distribute and postVerify.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} params
|
|
99
|
+
* @param {object} params.hook - Declared hook entry (config bound).
|
|
100
|
+
* @param {object} params.contextProjection - The §2.3 context projection.
|
|
101
|
+
* @param {object} params.commitIdentity - Frozen commitIdentity.
|
|
102
|
+
* @param {string} params.root - Release workspace root.
|
|
103
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
104
|
+
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
105
|
+
* @returns {Promise<{ status: string, observation: object, registryPath: string }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function executeMarketplaceRegistryEntryHook(params) {
|
|
108
|
+
const { hook, contextProjection, commitIdentity, root, exec, hookRunner } = params ?? {};
|
|
109
|
+
const config = hook?.config;
|
|
110
|
+
const target = config?.target;
|
|
111
|
+
if (!target || typeof target.branch !== 'string') {
|
|
112
|
+
throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.target with a branch');
|
|
113
|
+
}
|
|
114
|
+
const registryPath = config?.registryPath ?? 'registry.json';
|
|
115
|
+
const entryKey = config?.entryKey;
|
|
116
|
+
const fieldsFromPlan = config?.fieldsFromPlan;
|
|
117
|
+
if (typeof entryKey !== 'string' || entryKey.length === 0) {
|
|
118
|
+
throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.entryKey');
|
|
119
|
+
}
|
|
120
|
+
if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Object.keys(fieldsFromPlan).length === 0) {
|
|
121
|
+
throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires a non-empty config.fieldsFromPlan');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unitId = contextProjection?.unitId ?? 'unknown';
|
|
125
|
+
const version = contextProjection?.version ?? 'unknown';
|
|
126
|
+
|
|
127
|
+
let currentText = null;
|
|
128
|
+
const mutate = async (worktree) => {
|
|
129
|
+
const absoluteRegistry = join(worktree, registryPath);
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = await readFile(absoluteRegistry, 'utf8');
|
|
133
|
+
} catch {
|
|
134
|
+
throw new ReleaseError(
|
|
135
|
+
REMOTE_CONFLICT,
|
|
136
|
+
`marketplace registry file "${registryPath}" is missing in the downstream repository; creating it requires a human decision`,
|
|
137
|
+
{ registryPath },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
currentText = raw;
|
|
141
|
+
let registry;
|
|
142
|
+
try {
|
|
143
|
+
registry = JSON.parse(raw);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new ReleaseError(
|
|
146
|
+
REMOTE_CONFLICT,
|
|
147
|
+
`marketplace registry file "${registryPath}" is not valid JSON; human decision required`,
|
|
148
|
+
{ registryPath },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const updated = updateRegistryEntry(registry, { entryKey, fieldsFromPlan, contextProjection });
|
|
152
|
+
const serialized = serializeRegistry(updated);
|
|
153
|
+
if (serialized === currentText) {
|
|
154
|
+
// Leave the file untouched: the staged tree stays equal to the tip and
|
|
155
|
+
// the shared lifecycle reports NO_CHANGE.
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const { writeFile } = await import('node:fs/promises');
|
|
159
|
+
await writeFile(absoluteRegistry, serialized);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const result = await applyDownstreamGitChange({
|
|
163
|
+
target,
|
|
164
|
+
commitIdentity,
|
|
165
|
+
commitSubject: `release-skill marketplace-registry-entry ${unitId} ${version} (${entryKey})`,
|
|
166
|
+
mutate,
|
|
167
|
+
gates: config?.gates ?? [],
|
|
168
|
+
contextProjection,
|
|
169
|
+
root,
|
|
170
|
+
...(exec !== undefined ? { exec } : {}),
|
|
171
|
+
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
172
|
+
});
|
|
173
|
+
return { ...result, registryPath };
|
|
174
|
+
}
|