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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preset hook execution dispatcher (v0.6.3 R4, design §2.5).
|
|
3
|
+
*
|
|
4
|
+
* One dispatch seam shared by the distribute saga and the independent
|
|
5
|
+
* postVerify run: every registered preset routes to its built-in
|
|
6
|
+
* implementation here, so both phases can never drift apart.
|
|
7
|
+
*
|
|
8
|
+
* Result contract (normalized across presets):
|
|
9
|
+
* - `status`: 'EXECUTED' | 'NO_CHANGE';
|
|
10
|
+
* - `mode`: checkpoint/evidence delivery mode ('pushed' | 'local-file' |
|
|
11
|
+
* 'notify-handoff' | 'no-change' | ...);
|
|
12
|
+
* - `observation`: preset-specific observation (pushedCommit, ...);
|
|
13
|
+
* - optional `manualSyncPrompt` (proposal-inbox closure semantics and the
|
|
14
|
+
* notify-handoff checklist text), `checklist` (notify-handoff lines),
|
|
15
|
+
* `degradedToNotifyHandoff` (proposal-inbox without a target).
|
|
16
|
+
*
|
|
17
|
+
* proposal-inbox routing (targetOptional, review N-B1): without a target the
|
|
18
|
+
* preset DEGRADES to notify-handoff behavior instead of erroring; with a
|
|
19
|
+
* target the transport follows the addressing unless config.delivery pins it
|
|
20
|
+
* (remoteUrl -> git-push, workspace -> local-file).
|
|
21
|
+
*
|
|
22
|
+
* @module core/preset-executor
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
|
|
26
|
+
import { resolveProposalInboxTransport } from './presets.mjs';
|
|
27
|
+
import { executeNotifyHandoffHook } from './notify-handoff.mjs';
|
|
28
|
+
import {
|
|
29
|
+
executeProposalInboxGitPushHook,
|
|
30
|
+
executeProposalInboxLocalFileHook,
|
|
31
|
+
} from './proposal-inbox.mjs';
|
|
32
|
+
import { executeMarketplaceRegistryEntryHook } from './marketplace-registry-entry.mjs';
|
|
33
|
+
import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute one preset hook end-to-end.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} params
|
|
39
|
+
* @param {object} params.hook - Declared hook entry (preset + config).
|
|
40
|
+
* @param {object} params.contextProjection - The §2.3 context projection of
|
|
41
|
+
* the CURRENT run (notify-handoff checklists, docs/marketplace writes).
|
|
42
|
+
* @param {object} [params.proposalContextProjection] - Lineage-stable
|
|
43
|
+
* projection for proposal documents (byte-deterministic redelivery);
|
|
44
|
+
* defaults to contextProjection.
|
|
45
|
+
* @param {object} params.commitIdentity - Frozen postPublish commitIdentity.
|
|
46
|
+
* @param {string} params.root - Release workspace root (preset workspace
|
|
47
|
+
* resolution; hooks run inside the frozen tag worktree).
|
|
48
|
+
* @param {string} [params.evidencePath] - This run's evidence path
|
|
49
|
+
* (notify-handoff checklist rendering).
|
|
50
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
51
|
+
* @param {Function} [params.hookRunner] - Injectable hook runner (tests).
|
|
52
|
+
* @param {string} [params.payloadDir] - Materialized payload directory
|
|
53
|
+
* (docs-refresh mappings; distribute phase only).
|
|
54
|
+
* @returns {Promise<object>} Normalized delivery result (see module header).
|
|
55
|
+
*/
|
|
56
|
+
export async function executePresetHook(params) {
|
|
57
|
+
const {
|
|
58
|
+
hook,
|
|
59
|
+
contextProjection,
|
|
60
|
+
proposalContextProjection,
|
|
61
|
+
commitIdentity,
|
|
62
|
+
root,
|
|
63
|
+
evidencePath,
|
|
64
|
+
exec,
|
|
65
|
+
hookRunner,
|
|
66
|
+
payloadDir,
|
|
67
|
+
} = params ?? {};
|
|
68
|
+
if (!hook || typeof hook.preset !== 'string') {
|
|
69
|
+
throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, 'preset execution requires a preset hook entry');
|
|
70
|
+
}
|
|
71
|
+
const proposalProjection = proposalContextProjection ?? contextProjection;
|
|
72
|
+
|
|
73
|
+
switch (hook.preset) {
|
|
74
|
+
case 'notify-handoff': {
|
|
75
|
+
return executeNotifyHandoffHook({
|
|
76
|
+
contextProjection,
|
|
77
|
+
...(evidencePath !== undefined ? { evidencePath } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case 'proposal-inbox': {
|
|
82
|
+
const target = hook.config?.target;
|
|
83
|
+
if (!target) {
|
|
84
|
+
// targetOptional degradation (N-B1): notify-handoff behavior, never
|
|
85
|
+
// an error — evidenced as such, never silent.
|
|
86
|
+
const degraded = await executeNotifyHandoffHook({
|
|
87
|
+
contextProjection,
|
|
88
|
+
...(evidencePath !== undefined ? { evidencePath } : {}),
|
|
89
|
+
});
|
|
90
|
+
return { ...degraded, degradedToNotifyHandoff: true };
|
|
91
|
+
}
|
|
92
|
+
// Effective transport (single authority: core/presets.mjs, shared with
|
|
93
|
+
// the requiresApproval grading — R4 review M-1). Presets have NO
|
|
94
|
+
// dry-run path of their own: the command-level DRY_RUN skip contract
|
|
95
|
+
// skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints) before
|
|
96
|
+
// this dispatch is ever reached (R4 review m-3).
|
|
97
|
+
const transport = resolveProposalInboxTransport(hook.config);
|
|
98
|
+
if (transport === 'git-push') {
|
|
99
|
+
return executeProposalInboxGitPushHook({
|
|
100
|
+
hook,
|
|
101
|
+
contextProjection: proposalProjection,
|
|
102
|
+
commitIdentity,
|
|
103
|
+
...(exec !== undefined ? { exec } : {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (transport === 'local-file') {
|
|
107
|
+
return executeProposalInboxLocalFileHook({
|
|
108
|
+
hook,
|
|
109
|
+
contextProjection: proposalProjection,
|
|
110
|
+
commitIdentity,
|
|
111
|
+
root,
|
|
112
|
+
...(exec !== undefined ? { exec } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
throw new ReleaseError(
|
|
116
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
117
|
+
`proposal-inbox transport "${transport}" is unknown (expected git-push or local-file)`,
|
|
118
|
+
{ transport },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'marketplace-registry-entry': {
|
|
123
|
+
return executeMarketplaceRegistryEntryHook({
|
|
124
|
+
hook,
|
|
125
|
+
contextProjection,
|
|
126
|
+
commitIdentity,
|
|
127
|
+
root,
|
|
128
|
+
...(exec !== undefined ? { exec } : {}),
|
|
129
|
+
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'docs-refresh': {
|
|
134
|
+
return executeDocsRefreshHook({
|
|
135
|
+
hook,
|
|
136
|
+
contextProjection,
|
|
137
|
+
commitIdentity,
|
|
138
|
+
payloadDir,
|
|
139
|
+
root,
|
|
140
|
+
...(exec !== undefined ? { exec } : {}),
|
|
141
|
+
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
default: {
|
|
146
|
+
// Fail-closed: an unimplemented preset never executes. Declaration
|
|
147
|
+
// validation rejects unknown preset names, so this only guards presets
|
|
148
|
+
// registered but not yet shipped.
|
|
149
|
+
throw new ReleaseError(
|
|
150
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
151
|
+
`hook "${hook.id}" is preset "${hook.preset}"; this preset's behavior is not yet available in this release and ships in a later release`,
|
|
152
|
+
{ preset: hook.preset },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared downstream git-write lifecycle for R4 write-downstream presets
|
|
3
|
+
* (marketplace-registry-entry, docs-refresh; design §2.5/§2.6).
|
|
4
|
+
*
|
|
5
|
+
* Both presets update an EXISTING downstream repository and push the result.
|
|
6
|
+
* They share one lifecycle so the safety semantics can never drift:
|
|
7
|
+
*
|
|
8
|
+
* - addressing (§2.5 dual addressing): `target.remoteUrl` clones a fresh
|
|
9
|
+
* worktree (zero local layout assumptions); `target.workspace` reuses a
|
|
10
|
+
* local checkout in place after the §2.6 execution checks (preflight
|
|
11
|
+
* realpath, TOCTOU re-check, release-workspace/runtime exclusion) and a
|
|
12
|
+
* checked-out-branch guard;
|
|
13
|
+
* - observe-before-write: the staged tree is compared against the branch tip
|
|
14
|
+
* AFTER the preset's mutation; identical -> NO_CHANGE (no commit, no push,
|
|
15
|
+
* no gates — an idempotent repeat reports honestly);
|
|
16
|
+
* - downstream gates (argument arrays, the R1 hook runner) run inside the
|
|
17
|
+
* worktree AFTER the mutation and BEFORE any commit/push; a non-zero gate
|
|
18
|
+
* fails closed with zero remote side effects;
|
|
19
|
+
* - commit uses the frozen bot identity injected per-command (`git -c
|
|
20
|
+
* user.name=… -c user.email=…`, never touching global git config); the push
|
|
21
|
+
* is plain (NEVER --force); GIT_TERMINAL_PROMPT=0 everywhere — the host
|
|
22
|
+
* credential helper answers or delivery fails fast; credentials are never
|
|
23
|
+
* read, printed, or retried;
|
|
24
|
+
* - after a push, `git ls-remote` cross-checks the branch tip (review N-3).
|
|
25
|
+
*
|
|
26
|
+
* @module core/preset-gitwrite
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
ReleaseError,
|
|
37
|
+
GATE_FAILED,
|
|
38
|
+
REMOTE_CONFLICT,
|
|
39
|
+
REMOTE_UNAVAILABLE,
|
|
40
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
41
|
+
} from './errors.mjs';
|
|
42
|
+
import { preflightPresetWorkspace, assertPresetWorkspaceExecution } from './presets.mjs';
|
|
43
|
+
import { POSTPUBLISH_CONTEXT_ENV } from './postpublish.mjs';
|
|
44
|
+
|
|
45
|
+
const execFileAsync = promisify(execFileCb);
|
|
46
|
+
|
|
47
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
48
|
+
const TRANSFER_TIMEOUT_MS = 300_000;
|
|
49
|
+
const PROBE_TIMEOUT_MS = 30_000;
|
|
50
|
+
const TMP_PREFIX = 'release-skill-preset-write-';
|
|
51
|
+
|
|
52
|
+
const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
|
|
53
|
+
const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
54
|
+
const SHA_RE = /^[a-f0-9]{40}$/;
|
|
55
|
+
|
|
56
|
+
/** Credential hygiene: git is never allowed to prompt. */
|
|
57
|
+
const NEVER_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' };
|
|
58
|
+
|
|
59
|
+
const AUTH_FAILURE_PATTERNS = [
|
|
60
|
+
/authentication failed/i,
|
|
61
|
+
/could not read username/i,
|
|
62
|
+
/could not read password/i,
|
|
63
|
+
/terminal prompts disabled/i,
|
|
64
|
+
/invalid username or password/i,
|
|
65
|
+
/permission denied/i,
|
|
66
|
+
/access denied/i,
|
|
67
|
+
/authorization failed/i,
|
|
68
|
+
/\b403\b/,
|
|
69
|
+
/\b401\b/,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const TRANSPORT_FAILURE_PATTERNS = [
|
|
73
|
+
/could not resolve host/i,
|
|
74
|
+
/unable to access/i,
|
|
75
|
+
/connection refused/i,
|
|
76
|
+
/connection timed out/i,
|
|
77
|
+
/operation timed out/i,
|
|
78
|
+
/network is unreachable/i,
|
|
79
|
+
/ssl certificate problem/i,
|
|
80
|
+
/could not resolve proxy/i,
|
|
81
|
+
/failed to connect/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function defaultExec(command, args, options = {}) {
|
|
85
|
+
return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, ...options });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function netEnv() {
|
|
89
|
+
return { ...process.env, ...NEVER_PROMPT_ENV };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasControlChars(value) {
|
|
93
|
+
return /[\x00-\x1f\x7f]/.test(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertSafeRemoteUrl(remoteUrl) {
|
|
97
|
+
if (typeof remoteUrl !== 'string' || !SAFE_REMOTE_URL_RE.test(remoteUrl) || hasControlChars(remoteUrl)) {
|
|
98
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream remoteUrl must be an http(s)/file URL ending in .git', { remoteUrl });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertSafeBranch(branch) {
|
|
103
|
+
if (typeof branch !== 'string' || !SAFE_BRANCH_RE.test(branch)
|
|
104
|
+
|| branch.includes('..') || branch.endsWith('.') || branch.endsWith('.lock')) {
|
|
105
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream branch is not a safe Git branch name', { branch });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertCommitIdentity(identity) {
|
|
110
|
+
if (!identity || typeof identity !== 'object') {
|
|
111
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a frozen commitIdentity');
|
|
112
|
+
}
|
|
113
|
+
for (const field of ['name', 'email']) {
|
|
114
|
+
const value = identity[field];
|
|
115
|
+
if (typeof value !== 'string' || value.length === 0 || value.startsWith('-') || hasControlChars(value)) {
|
|
116
|
+
throw new ReleaseError(GATE_FAILED, `preset downstream commitIdentity.${field} is missing or unsafe`, { field });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stderrText(error) {
|
|
122
|
+
return `${error?.stderr ?? ''}\n${error?.message ?? ''}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stderrTail(error, limit = 400) {
|
|
126
|
+
const text = stderrText(error).trim();
|
|
127
|
+
return text.length > limit ? `…${text.slice(-limit)}` : text;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function classifyNetFailure(text) {
|
|
131
|
+
const output = typeof text === 'string' ? text : '';
|
|
132
|
+
if (AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'auth';
|
|
133
|
+
return 'transport';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseLsRemote(stdout) {
|
|
137
|
+
const refs = new Map();
|
|
138
|
+
for (const line of `${stdout ?? ''}`.trim().split('\n').filter(Boolean)) {
|
|
139
|
+
const tabIndex = line.indexOf('\t');
|
|
140
|
+
if (tabIndex < 0) continue;
|
|
141
|
+
const sha = line.slice(0, tabIndex);
|
|
142
|
+
const ref = line.slice(tabIndex + 1);
|
|
143
|
+
if (!SHA_RE.test(sha)) continue;
|
|
144
|
+
if (ref.endsWith('^{}')) {
|
|
145
|
+
refs.set(ref.slice(0, -3), sha);
|
|
146
|
+
} else if (!refs.has(ref)) {
|
|
147
|
+
refs.set(ref, sha);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return refs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Cross-check that a downstream branch tip equals the pushed commit
|
|
155
|
+
* (review N-3). Fails closed on mismatch or a missing ref.
|
|
156
|
+
*
|
|
157
|
+
* @param {Function} exec - Git exec (argument arrays only).
|
|
158
|
+
* @param {string} remoteUrl
|
|
159
|
+
* @param {string} branch
|
|
160
|
+
* @param {string} pushedCommit - Full 40-hex commit sha.
|
|
161
|
+
* @returns {Promise<string>} The observed branch tip.
|
|
162
|
+
*/
|
|
163
|
+
export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit) {
|
|
164
|
+
assertSafeRemoteUrl(remoteUrl);
|
|
165
|
+
assertSafeBranch(branch);
|
|
166
|
+
if (typeof pushedCommit !== 'string' || !SHA_RE.test(pushedCommit)) {
|
|
167
|
+
throw new ReleaseError(GATE_FAILED, 'cross-check requires the full 40-hex pushed commit', { pushedCommit });
|
|
168
|
+
}
|
|
169
|
+
const execFn = typeof exec === 'function' ? exec : defaultExec;
|
|
170
|
+
const { stdout } = await execFn('git', ['ls-remote', remoteUrl, `refs/heads/${branch}`], {
|
|
171
|
+
env: netEnv(),
|
|
172
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
173
|
+
shell: false,
|
|
174
|
+
});
|
|
175
|
+
const observed = parseLsRemote(stdout).get(`refs/heads/${branch}`) ?? null;
|
|
176
|
+
if (observed !== pushedCommit) {
|
|
177
|
+
throw new ReleaseError(
|
|
178
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
179
|
+
`post-push cross-check failed: downstream branch ${branch} is at ${observed ?? '<missing>'}, which disagrees with the pushed commit ${pushedCommit}`,
|
|
180
|
+
{ remoteUrl, branch, pushedCommit, observed },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return observed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Apply one preset mutation to a downstream git repository and push it.
|
|
188
|
+
*
|
|
189
|
+
* Lifecycle: resolve worktree (clone or workspace) -> checkout/verify branch
|
|
190
|
+
* -> `mutate(worktree)` -> stage + NO_CHANGE detection -> run `gates` ->
|
|
191
|
+
* commit (frozen identity) -> push (never --force) -> ls-remote cross-check.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} params
|
|
194
|
+
* @param {object} params.target - { remoteUrl? XOR workspace?, branch }.
|
|
195
|
+
* @param {object} params.commitIdentity - Frozen { name, email }.
|
|
196
|
+
* @param {string} params.commitSubject - Deterministic commit subject.
|
|
197
|
+
* @param {(worktree: string) => Promise<void>} params.mutate - Writes the
|
|
198
|
+
* preset's files into the worktree.
|
|
199
|
+
* @param {object[]} [params.gates] - Downstream gates (argument arrays).
|
|
200
|
+
* @param {object} [params.contextProjection] - Injected into gate env.
|
|
201
|
+
* @param {string} params.root - Release workspace root (workspace addressing).
|
|
202
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
203
|
+
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
204
|
+
* @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
|
|
205
|
+
* The observation carries `workspaceRealpath` for workspace addressing
|
|
206
|
+
* (§2.6 evidence, R4 review m-2) and `crossCheck: { status: 'skipped',
|
|
207
|
+
* reason }` when the post-push ls-remote cross-check could not run
|
|
208
|
+
* (R4 review m-4; a performed cross-check leaves no note).
|
|
209
|
+
*/
|
|
210
|
+
export async function applyDownstreamGitChange(params) {
|
|
211
|
+
const {
|
|
212
|
+
target,
|
|
213
|
+
commitIdentity,
|
|
214
|
+
commitSubject,
|
|
215
|
+
mutate,
|
|
216
|
+
gates = [],
|
|
217
|
+
contextProjection,
|
|
218
|
+
root,
|
|
219
|
+
exec: execOpt,
|
|
220
|
+
hookRunner,
|
|
221
|
+
} = params ?? {};
|
|
222
|
+
if (!target || typeof target !== 'object') {
|
|
223
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a config.target');
|
|
224
|
+
}
|
|
225
|
+
assertSafeBranch(target.branch);
|
|
226
|
+
assertCommitIdentity(commitIdentity);
|
|
227
|
+
if (typeof mutate !== 'function') {
|
|
228
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a mutate function');
|
|
229
|
+
}
|
|
230
|
+
const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
|
|
231
|
+
const runGate = typeof hookRunner === 'function' ? hookRunner : defaultRunGate;
|
|
232
|
+
|
|
233
|
+
const hasRemoteUrl = typeof target.remoteUrl === 'string';
|
|
234
|
+
const hasWorkspace = typeof target.workspace === 'string';
|
|
235
|
+
if (hasRemoteUrl === hasWorkspace) {
|
|
236
|
+
throw new ReleaseError(GATE_FAILED, 'preset downstream target must declare exactly one of remoteUrl or workspace');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let worktree = null;
|
|
240
|
+
let isClone = false;
|
|
241
|
+
let previousHead = null;
|
|
242
|
+
// §2.6 execution realpath (workspace addressing only): returned in the
|
|
243
|
+
// observation so the saga evidence records WHERE the write landed
|
|
244
|
+
// (R4 review m-2). Clone addressing uses a fresh tmpdir with no meaning
|
|
245
|
+
// after cleanup, so it stays null there.
|
|
246
|
+
let workspaceRealpath = null;
|
|
247
|
+
|
|
248
|
+
if (hasRemoteUrl) {
|
|
249
|
+
assertSafeRemoteUrl(target.remoteUrl);
|
|
250
|
+
worktree = await mkdtemp(join(tmpdir(), TMP_PREFIX));
|
|
251
|
+
isClone = true;
|
|
252
|
+
try {
|
|
253
|
+
await exec('git', ['clone', '--quiet', target.remoteUrl, worktree], {
|
|
254
|
+
env: netEnv(),
|
|
255
|
+
timeout: TRANSFER_TIMEOUT_MS,
|
|
256
|
+
shell: false,
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
await rm(worktree, { recursive: true, force: true }).catch(() => {});
|
|
260
|
+
const classification = classifyNetFailure(stderrText(error));
|
|
261
|
+
throw new ReleaseError(
|
|
262
|
+
classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
|
|
263
|
+
classification === 'auth'
|
|
264
|
+
? `preset downstream remote refused authentication for ${target.remoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
|
|
265
|
+
: `cannot reach preset downstream remote ${target.remoteUrl} — start VPN / check network; delivery fails closed and never retries with credentials`,
|
|
266
|
+
{ remoteUrl: target.remoteUrl, stderrTail: stderrTail(error) },
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
// Continue the declared branch when it exists; start a root commit otherwise.
|
|
270
|
+
try {
|
|
271
|
+
const { stdout } = await exec('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${target.branch}`], { cwd: worktree, shell: false });
|
|
272
|
+
previousHead = stdout.trim() || null;
|
|
273
|
+
} catch {
|
|
274
|
+
previousHead = null;
|
|
275
|
+
}
|
|
276
|
+
if (previousHead) {
|
|
277
|
+
await exec('git', ['checkout', '--quiet', '-B', target.branch, previousHead], { cwd: worktree, shell: false });
|
|
278
|
+
} else {
|
|
279
|
+
await exec('git', ['checkout', '--quiet', '--orphan', target.branch], { cwd: worktree, shell: false });
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Workspace addressing: §2.6 execution checks + a branch guard. The
|
|
283
|
+
// checkout is used in place (no clone); the branch is never switched.
|
|
284
|
+
const preflight = await preflightPresetWorkspace(target.workspace, { root });
|
|
285
|
+
const execution = await assertPresetWorkspaceExecution(target.workspace, {
|
|
286
|
+
root,
|
|
287
|
+
preflightRealpath: preflight.realpath,
|
|
288
|
+
});
|
|
289
|
+
worktree = execution.realpath;
|
|
290
|
+
workspaceRealpath = execution.realpath;
|
|
291
|
+
let headRef = '';
|
|
292
|
+
try {
|
|
293
|
+
const { stdout } = await exec('git', ['symbolic-ref', '--quiet', 'HEAD'], { cwd: worktree, shell: false });
|
|
294
|
+
headRef = `${stdout}`.trim();
|
|
295
|
+
} catch {
|
|
296
|
+
headRef = '';
|
|
297
|
+
}
|
|
298
|
+
if (headRef !== `refs/heads/${target.branch}`) {
|
|
299
|
+
throw new ReleaseError(
|
|
300
|
+
GATE_FAILED,
|
|
301
|
+
`preset downstream workspace must be checked out on branch "${target.branch}", but HEAD is at "${headRef || '<detached>'}"; check out the branch and rerun`,
|
|
302
|
+
{ workspace: worktree, branch: target.branch, headRef: headRef || null },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const { stdout } = await exec('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: worktree, shell: false });
|
|
307
|
+
previousHead = stdout.trim() || null;
|
|
308
|
+
} catch {
|
|
309
|
+
previousHead = null;
|
|
310
|
+
}
|
|
311
|
+
// Clean-tree requirement: the preset stages with `git add -A`, so a dirty
|
|
312
|
+
// checkout would sweep unrelated user work into the downstream commit.
|
|
313
|
+
// Fail closed for human decision instead.
|
|
314
|
+
const { stdout: dirtyOut } = await exec('git', ['status', '--porcelain'], { cwd: worktree, shell: false });
|
|
315
|
+
if (`${dirtyOut}`.trim().length > 0) {
|
|
316
|
+
throw new ReleaseError(
|
|
317
|
+
GATE_FAILED,
|
|
318
|
+
'preset downstream workspace has uncommitted changes; the preset would have to stage them alongside its own write — commit or stash the local work and rerun',
|
|
319
|
+
{ workspace: worktree },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const git = (args, options = {}) => exec('git', args, { cwd: worktree, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
|
|
326
|
+
|
|
327
|
+
// Preset mutation.
|
|
328
|
+
await mutate(worktree);
|
|
329
|
+
|
|
330
|
+
// Stage and detect NO_CHANGE against the branch tip.
|
|
331
|
+
await git(['add', '-A']);
|
|
332
|
+
const noChange = await git(['status', '--porcelain'])
|
|
333
|
+
.then(({ stdout }) => stdout.trim().length === 0)
|
|
334
|
+
.catch(() => false);
|
|
335
|
+
if (noChange) {
|
|
336
|
+
return {
|
|
337
|
+
status: 'NO_CHANGE',
|
|
338
|
+
observation: {
|
|
339
|
+
mode: 'no-change',
|
|
340
|
+
previousHead,
|
|
341
|
+
branchTip: previousHead,
|
|
342
|
+
...(workspaceRealpath ? { workspaceRealpath } : {}),
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Downstream gates run BEFORE any commit/push; a failure leaves zero
|
|
348
|
+
// remote side effects (the staged change is discarded with the clone or
|
|
349
|
+
// reset in the workspace).
|
|
350
|
+
for (const [index, gate] of gates.entries()) {
|
|
351
|
+
let gateResult;
|
|
352
|
+
try {
|
|
353
|
+
gateResult = await runGate(
|
|
354
|
+
{
|
|
355
|
+
command: gate.command,
|
|
356
|
+
...(gate.cwd ? { cwd: gate.cwd } : {}),
|
|
357
|
+
...(gate.timeoutMs !== undefined ? { timeoutMs: gate.timeoutMs } : {}),
|
|
358
|
+
...(gate.envAllowlist ? { envAllowlist: gate.envAllowlist } : {}),
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
root: worktree,
|
|
362
|
+
env: process.env,
|
|
363
|
+
injectEnv: contextProjection !== undefined
|
|
364
|
+
? { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(contextProjection) }
|
|
365
|
+
: {},
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
// Reset the staged change so a workspace is left clean on failure.
|
|
370
|
+
await git(['reset', '--quiet']).catch(() => {});
|
|
371
|
+
const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : GATE_FAILED;
|
|
372
|
+
throw new ReleaseError(
|
|
373
|
+
code === 'HOOK_TIMEOUT' ? GATE_FAILED : code,
|
|
374
|
+
`downstream gate ${index + 1} timed out or could not run: ${err?.message ?? err}`,
|
|
375
|
+
{ gateIndex: index },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (gateResult.exitCode !== 0) {
|
|
379
|
+
await git(['reset', '--quiet']).catch(() => {});
|
|
380
|
+
throw new ReleaseError(
|
|
381
|
+
GATE_FAILED,
|
|
382
|
+
`downstream gate ${index + 1} failed with exit code ${gateResult.exitCode}; the downstream change was not committed or pushed`,
|
|
383
|
+
{ gateIndex: index, exitCode: gateResult.exitCode },
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Commit with the frozen bot identity, then push (NEVER --force).
|
|
389
|
+
await git([
|
|
390
|
+
'-c', `user.name=${commitIdentity.name}`,
|
|
391
|
+
'-c', `user.email=${commitIdentity.email}`,
|
|
392
|
+
'commit', '--quiet', '-m', commitSubject,
|
|
393
|
+
]);
|
|
394
|
+
const { stdout: headOut } = await git(['rev-parse', 'HEAD']);
|
|
395
|
+
const localCommit = headOut.trim();
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await exec('git', ['push', '--quiet', 'origin', target.branch], {
|
|
399
|
+
cwd: worktree,
|
|
400
|
+
env: netEnv(),
|
|
401
|
+
timeout: TRANSFER_TIMEOUT_MS,
|
|
402
|
+
shell: false,
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const classification = classifyNetFailure(stderrText(error));
|
|
406
|
+
throw new ReleaseError(
|
|
407
|
+
classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
|
|
408
|
+
classification === 'auth'
|
|
409
|
+
? `preset downstream push refused authentication for ${target.remoteUrl ?? target.workspace}; the host keychain credential is reused and never prompted, read, or retried`
|
|
410
|
+
: `preset downstream push to ${target.remoteUrl ?? target.workspace} failed — start VPN / check network; delivery fails closed and never retries with credentials`,
|
|
411
|
+
{ branch: target.branch, stderrTail: stderrTail(error) },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Cross-check the pushed branch tip (review N-3). Workspace addressing
|
|
416
|
+
// resolves the checkout's origin URL; clone addressing uses the target.
|
|
417
|
+
// A skip is NEVER silent (R4 review m-4): an unresolvable or
|
|
418
|
+
// non-http(s)/file origin (SSH etc.) records crossCheck skipped + reason
|
|
419
|
+
// in the observation, so the evidence shows the verification gap.
|
|
420
|
+
let crossCheckUrl = target.remoteUrl;
|
|
421
|
+
let crossCheckSkipReason = null;
|
|
422
|
+
if (!hasRemoteUrl) {
|
|
423
|
+
try {
|
|
424
|
+
const { stdout } = await git(['remote', 'get-url', 'origin']);
|
|
425
|
+
crossCheckUrl = stdout.trim();
|
|
426
|
+
} catch {
|
|
427
|
+
crossCheckUrl = null;
|
|
428
|
+
}
|
|
429
|
+
if (!crossCheckUrl) {
|
|
430
|
+
crossCheckSkipReason =
|
|
431
|
+
'workspace checkout declares no origin remote; the post-push ls-remote cross-check needs a remote URL';
|
|
432
|
+
} else if (!SAFE_REMOTE_URL_RE.test(crossCheckUrl)) {
|
|
433
|
+
crossCheckSkipReason =
|
|
434
|
+
'workspace origin URL is not an http(s)/file URL (SSH and other transports are outside the ls-remote cross-check policy); post-push cross-check skipped';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (crossCheckUrl && SAFE_REMOTE_URL_RE.test(crossCheckUrl)) {
|
|
438
|
+
await crossCheckBranchTip(exec, crossCheckUrl, target.branch, localCommit);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
status: 'EXECUTED',
|
|
443
|
+
observation: {
|
|
444
|
+
mode: 'pushed',
|
|
445
|
+
pushedCommit: localCommit,
|
|
446
|
+
previousHead,
|
|
447
|
+
branchTip: localCommit,
|
|
448
|
+
...(workspaceRealpath ? { workspaceRealpath } : {}),
|
|
449
|
+
...(crossCheckSkipReason ? { crossCheck: { status: 'skipped', reason: crossCheckSkipReason } } : {}),
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
} finally {
|
|
453
|
+
if (isClone) {
|
|
454
|
+
await rm(worktree, { recursive: true, force: true }).catch(() => {});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Default gate runner: delegates to the R1 hook runner (core/hooks.mjs). */
|
|
460
|
+
async function defaultRunGate(gate, context) {
|
|
461
|
+
const { runHook } = await import('./hooks.mjs');
|
|
462
|
+
return runHook(gate, context);
|
|
463
|
+
}
|