@zq-silk/yui 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { runUpdate } from "../cli/updateOrchestrator.js";
|
|
9
|
+
import { activatedControllerEntrypoint } from "../cli/updatePorts.js";
|
|
10
|
+
import { restartFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
|
|
11
|
+
import { runProjectCommand } from "../commands/projectCommands.js";
|
|
12
|
+
import { createFileReleaseIdempotencyStore } from "./releaseIdempotencyStore.js";
|
|
13
|
+
import { isConcreteVersion } from "../domain/validation.js";
|
|
14
|
+
import { resolveProject } from "../repository/project.js";
|
|
15
|
+
import { resolveVerificationGate } from "../verification/verificationGateService.js";
|
|
16
|
+
import { findL2ArtifactForCommit } from "../verification/gateArtifactStore.js";
|
|
17
|
+
const defaultRunCommand = async (command, args, cwd, env) => {
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
try {
|
|
20
|
+
const { stdout, stderr } = await exec(command, args, {
|
|
21
|
+
cwd,
|
|
22
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
23
|
+
...(env === undefined ? {} : { env: { ...process.env, ...env } })
|
|
24
|
+
});
|
|
25
|
+
return { code: 0, stdout, stderr };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const failure = error;
|
|
29
|
+
return {
|
|
30
|
+
code: typeof failure.code === "number" ? failure.code : 1,
|
|
31
|
+
stdout: failure.stdout ?? "",
|
|
32
|
+
stderr: failure.stderr ?? failure.message ?? String(error)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The external commands the production adapter shells out to. Each one is
|
|
38
|
+
* resolved to an absolute path once, at adapter construction time, so a PATH
|
|
39
|
+
* change mid-run cannot swap `gh`/`git`/`npm`/`tar`/`sh` for another binary
|
|
40
|
+
* (P1-2, rr22). The pinning is per-process at construction time, so a resume
|
|
41
|
+
* process re-resolves from its own PATH.
|
|
42
|
+
*/
|
|
43
|
+
const PINNED_EXTERNAL_COMMANDS = ["gh", "git", "npm", "tar", "sh"];
|
|
44
|
+
/**
|
|
45
|
+
* Resolve an external command to its absolute executable path by walking the
|
|
46
|
+
* given PATH. A command that is already absolute is returned unchanged. A
|
|
47
|
+
* command that cannot be resolved returns undefined. Callers must fail closed
|
|
48
|
+
* without invoking a mutable PATH fallback.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveExecutable(command, environmentPath) {
|
|
51
|
+
if (isAbsolute(command))
|
|
52
|
+
return command;
|
|
53
|
+
const pathValue = environmentPath ?? "";
|
|
54
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
55
|
+
if (directory.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
const candidate = resolve(directory, command);
|
|
58
|
+
try {
|
|
59
|
+
accessSync(candidate, constants.X_OK);
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Keep walking PATH.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wrap the default runner so every external command is pinned to the
|
|
70
|
+
* absolute path resolved at construction. Injected runners (tests) are used
|
|
71
|
+
* as-is: the pinning is a production-adapter guarantee.
|
|
72
|
+
*
|
|
73
|
+
* Exported for deterministic tests (P2, rr24): the negative cache must
|
|
74
|
+
* survive a later PATH addition, so a command missing at construction stays
|
|
75
|
+
* unresolvable for the adapter's lifetime.
|
|
76
|
+
*/
|
|
77
|
+
export function createPinnedRunner(base) {
|
|
78
|
+
const pinned = new Map();
|
|
79
|
+
for (const name of PINNED_EXTERNAL_COMMANDS) {
|
|
80
|
+
pinned.set(name, resolveExecutable(name, process.env.PATH));
|
|
81
|
+
}
|
|
82
|
+
return (command, args, cwd, env) => {
|
|
83
|
+
// P2 (rr24): `Map.get` returns undefined both for a missing key and for
|
|
84
|
+
// a key whose resolved value is undefined (a command that was not on
|
|
85
|
+
// PATH at construction). Use `Map.has` to distinguish them so the
|
|
86
|
+
// negative cache holds: a command resolved as missing stays missing even
|
|
87
|
+
// if it appears on PATH later in the adapter's lifetime.
|
|
88
|
+
let resolved;
|
|
89
|
+
if (pinned.has(command)) {
|
|
90
|
+
resolved = pinned.get(command);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
resolved = isAbsolute(command) ? command : resolveExecutable(command, process.env.PATH);
|
|
94
|
+
pinned.set(command, resolved);
|
|
95
|
+
}
|
|
96
|
+
if (resolved === undefined) {
|
|
97
|
+
return Promise.resolve({
|
|
98
|
+
code: 127,
|
|
99
|
+
stdout: "",
|
|
100
|
+
stderr: `Unable to resolve trusted executable: ${command}`
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return base(resolved, args, cwd, env);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The real adapter. It is NEVER exercised by the deterministic test suite
|
|
108
|
+
* (fakes are); every branch dispatches to an existing atomic operation.
|
|
109
|
+
*/
|
|
110
|
+
export function createReleaseWorkflowPorts(deps) {
|
|
111
|
+
const run = deps.runCommand !== undefined
|
|
112
|
+
? deps.runCommand
|
|
113
|
+
: createPinnedRunner(defaultRunCommand);
|
|
114
|
+
// Capture the same npm resolution used by the production pinned runner at
|
|
115
|
+
// adapter construction. The effect target must never be re-resolved from
|
|
116
|
+
// a later PATH, which may belong to a replacement process or environment.
|
|
117
|
+
const pinnedNpmPath = resolveExecutable("npm", process.env.PATH);
|
|
118
|
+
const idempotency = deps.idempotencyStore ?? createFileReleaseIdempotencyStore(deps.home);
|
|
119
|
+
async function executeStepOnce({ step, idempotencyKey, source, params }) {
|
|
120
|
+
switch (step.kind) {
|
|
121
|
+
case "cli-update": {
|
|
122
|
+
// A cli-update is an irreversible global binary/Home change. It must
|
|
123
|
+
// be bound to a concrete frozen version: a plan without one would
|
|
124
|
+
// stage and activate whatever `latest` resolves to, which can move
|
|
125
|
+
// between the plan and the activation. Fail closed before staging.
|
|
126
|
+
const frozenVersion = params.version;
|
|
127
|
+
if (frozenVersion === undefined || !isConcreteVersion(frozenVersion)) {
|
|
128
|
+
return {
|
|
129
|
+
outcome: "failed",
|
|
130
|
+
error: frozenVersion === undefined
|
|
131
|
+
? "cli-update requires params.version: a concrete frozen version must be staged and activated."
|
|
132
|
+
: `cli-update: params.version ${frozenVersion} is not a concrete version; refusing to stage a moving tag.`,
|
|
133
|
+
logs: ["cli-update: refusing to update without a concrete frozen version"]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Stage the exact frozen version side-by-side and inspect it BEFORE
|
|
137
|
+
// any activation: a registry that resolved a different version must
|
|
138
|
+
// fail with the active CLI untouched.
|
|
139
|
+
let staged;
|
|
140
|
+
try {
|
|
141
|
+
staged = deps.updatePorts.stage(frozenVersion);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
outcome: "failed",
|
|
146
|
+
error: `cli-update: staging failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
147
|
+
logs: ["cli-update: staging failed before any activation"]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (staged.version !== frozenVersion) {
|
|
151
|
+
try {
|
|
152
|
+
deps.updatePorts.cleanup(staged);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Best-effort cleanup; staging is side-by-side so the live
|
|
156
|
+
// install is untouched either way.
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
outcome: "failed",
|
|
160
|
+
error: `cli-update: staged version ${staged.version} does not match frozen version ${frozenVersion}`,
|
|
161
|
+
logs: [`cli-update: refusing to activate a version other than the frozen ${frozenVersion}`]
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Reuse the inspected staged package so runUpdate activates exactly
|
|
165
|
+
// what was inspected rather than staging a second artifact.
|
|
166
|
+
const inspectedPorts = {
|
|
167
|
+
...deps.updatePorts,
|
|
168
|
+
stage: () => staged
|
|
169
|
+
};
|
|
170
|
+
// P1-2 (rr23): persist the exact activation target (Home + global
|
|
171
|
+
// npm prefix) to a durable file BEFORE the irreversible effect. A
|
|
172
|
+
// hard-exit recovery query reads this file instead of deriving the
|
|
173
|
+
// target from the resume caller's npm/PATH environment. Best-effort:
|
|
174
|
+
// an unpinnable target or a failed write must not block the update.
|
|
175
|
+
try {
|
|
176
|
+
await persistCliUpdateIdentity(run, deps.home, idempotencyKey);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Best-effort persistence; the effect may still succeed.
|
|
180
|
+
}
|
|
181
|
+
const result = runUpdate(inspectedPorts, { home: deps.home });
|
|
182
|
+
if (result.outcome === "updated" || result.outcome === "already-current") {
|
|
183
|
+
return {
|
|
184
|
+
outcome: "succeeded",
|
|
185
|
+
externalId: result.version,
|
|
186
|
+
logs: [`cli-update: ${result.outcome} (${result.version})`]
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (result.outcome === "ambiguous" || (result.outcome === "aborted" && !result.recoverable)) {
|
|
190
|
+
// P1-2 (rr20): An aborted activation with recoverable=false means
|
|
191
|
+
// npm activation began but its outcome is unknowable — the global
|
|
192
|
+
// install may or may not have landed. Treat it as ambiguous:
|
|
193
|
+
// persist a queryable identity so the engine never re-submits
|
|
194
|
+
// blindly. Only recoverable:true aborts (old binary and Home
|
|
195
|
+
// provably intact) are safe to retry as plain failures.
|
|
196
|
+
//
|
|
197
|
+
// P1 (rr21): A Controller ownership/handoff failure
|
|
198
|
+
// (controllerOwnershipUnknown) is different: the replacement
|
|
199
|
+
// Controller's identity could not be authenticated, so the
|
|
200
|
+
// Controller lifecycle handoff is unresolved. A controller-home
|
|
201
|
+
// query would confirm the global binary but NOT prove the
|
|
202
|
+
// Controller handoff completed. Return timeout WITHOUT
|
|
203
|
+
// externalIdentity so the engine marks the step as `unknown`
|
|
204
|
+
// and stops — resume must not continue until the expected
|
|
205
|
+
// Controller lifecycle/identity handoff is proven complete.
|
|
206
|
+
if (result.outcome === "aborted" && result.controllerOwnershipUnknown === true) {
|
|
207
|
+
return {
|
|
208
|
+
outcome: "timeout",
|
|
209
|
+
logs: [`cli-update Controller ownership unknown at phase ${result.phase}: ${result.message}`]
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// P1-2 (rr22): persist the EXACT activation target (the resolved
|
|
213
|
+
// global prefix) alongside the Home, so a resume query checks the
|
|
214
|
+
// same installation instead of re-deriving the target from the
|
|
215
|
+
// caller's npm/PATH environment.
|
|
216
|
+
const identity = await controllerHomeIdentity(run, deps.home);
|
|
217
|
+
return {
|
|
218
|
+
outcome: "timeout",
|
|
219
|
+
...(identity === undefined ? {} : { externalIdentity: identity }),
|
|
220
|
+
logs: [`cli-update ambiguous at phase ${result.phase}: ${result.message}`]
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
outcome: "failed",
|
|
225
|
+
error: `cli-update aborted at phase ${result.phase}: ${result.message}`,
|
|
226
|
+
logs: [`cli-update: ${result.message}`]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
case "controller-replace": {
|
|
230
|
+
try {
|
|
231
|
+
await stopFileTaskController(deps.home, deps.controllerOptions);
|
|
232
|
+
const restarted = await restartFileTaskController(deps.home, deps.controllerOptions);
|
|
233
|
+
const externalId = restarted.pid === undefined ? "restarted" : `pid:${restarted.pid}`;
|
|
234
|
+
return {
|
|
235
|
+
outcome: "succeeded",
|
|
236
|
+
externalId,
|
|
237
|
+
logs: [`controller-replace: previous pid ${restarted.previousPid ?? "-"} -> ${externalId}`]
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return {
|
|
242
|
+
outcome: "failed",
|
|
243
|
+
error: error instanceof Error ? error.message : String(error)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
case "project-migrate": {
|
|
248
|
+
const project = params.project;
|
|
249
|
+
if (project === undefined) {
|
|
250
|
+
return { outcome: "failed", error: "project-migrate requires params.project." };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const result = await runProjectCommand(["migrate", project], deps.projectStore, deps.projectOptions);
|
|
254
|
+
return {
|
|
255
|
+
outcome: "succeeded",
|
|
256
|
+
externalId: project,
|
|
257
|
+
logs: [`project-migrate: ${result.output.trim()}`]
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return {
|
|
262
|
+
outcome: "failed",
|
|
263
|
+
error: error instanceof Error ? error.message : String(error)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
case "pr-create-or-reuse": {
|
|
268
|
+
// A commit SHA is not a valid head branch. Derive a proper branch
|
|
269
|
+
// name when the caller does not supply one.
|
|
270
|
+
const head = params.head ?? `release/${source.commit.slice(0, 12)}`;
|
|
271
|
+
const repo = `${source.repository.owner}/${source.repository.name}`;
|
|
272
|
+
const lookup = await findHeadPullRequest(run, head, repo);
|
|
273
|
+
if (lookup.status === "failed") {
|
|
274
|
+
// A failed lookup is fail-closed: never create a PR when the
|
|
275
|
+
// reuse check could not complete.
|
|
276
|
+
return {
|
|
277
|
+
outcome: "failed",
|
|
278
|
+
error: `pr-create-or-reuse: PR lookup failed: ${lookup.error}`,
|
|
279
|
+
logs: ["pr-create-or-reuse: lookup failed; not creating a PR"]
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (lookup.status === "found") {
|
|
283
|
+
// A PR for the head branch is only reusable when its head is the
|
|
284
|
+
// exact frozen source commit: a branch that moved, or a PR opened
|
|
285
|
+
// from an older push, must not be reused for this release.
|
|
286
|
+
if (lookup.headSha !== source.commit) {
|
|
287
|
+
return {
|
|
288
|
+
outcome: "failed",
|
|
289
|
+
error: `pr-create-or-reuse: PR #${lookup.prNumber} head ${lookup.headSha} does not match frozen source commit ${source.commit}`,
|
|
290
|
+
logs: ["pr-create-or-reuse: refusing to reuse a PR whose head is not the frozen source commit"]
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
outcome: "succeeded",
|
|
295
|
+
externalId: `pr:${lookup.prNumber}`,
|
|
296
|
+
logs: [`pr-create-or-reuse: reused PR #${lookup.prNumber}`]
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// P2-2: Authoritatively resolve the proposed remote head before
|
|
300
|
+
// creating a PR. A moved or missing head would create an external
|
|
301
|
+
// PR for the wrong commit; fail closed before any external effect.
|
|
302
|
+
const remoteHead = await resolveRemoteHead(run, repo, head);
|
|
303
|
+
if (remoteHead === undefined) {
|
|
304
|
+
return {
|
|
305
|
+
outcome: "failed",
|
|
306
|
+
error: `pr-create-or-reuse: cannot resolve remote head ${head} in ${repo}`,
|
|
307
|
+
logs: ["pr-create-or-reuse: refusing to create a PR for an unresolvable head"]
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (remoteHead !== source.commit) {
|
|
311
|
+
return {
|
|
312
|
+
outcome: "failed",
|
|
313
|
+
error: `pr-create-or-reuse: remote head ${head} is ${remoteHead}, not the frozen source commit ${source.commit}`,
|
|
314
|
+
logs: ["pr-create-or-reuse: refusing to create a PR for a head that moved"]
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const created = await run("gh", [
|
|
318
|
+
"pr", "create", "--head", head,
|
|
319
|
+
"--repo", repo,
|
|
320
|
+
"--title", params.title ?? `Release ${head}`,
|
|
321
|
+
"--body", params.body ?? `Automated release workflow ${idempotencyKey}`
|
|
322
|
+
]);
|
|
323
|
+
if (created.code !== 0) {
|
|
324
|
+
// A race may have opened the PR between our query and create.
|
|
325
|
+
if (/already exists/i.test(created.stderr)) {
|
|
326
|
+
const raced = await findHeadPullRequest(run, head, repo);
|
|
327
|
+
if (raced.status === "found" && raced.headSha === source.commit) {
|
|
328
|
+
return {
|
|
329
|
+
outcome: "succeeded",
|
|
330
|
+
externalId: `pr:${raced.prNumber}`,
|
|
331
|
+
logs: [`pr-create-or-reuse: reused PR #${raced.prNumber} after create race`]
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { outcome: "failed", error: created.stderr.trim() || "gh pr create failed" };
|
|
336
|
+
}
|
|
337
|
+
const match = created.stdout.match(/pull\/(\d+)/);
|
|
338
|
+
const prNumber = match === null ? created.stdout.trim() : match[1];
|
|
339
|
+
// P2-2: Verify the created PR's head SHA matches the frozen source
|
|
340
|
+
// commit. A race or a branch move between create and now could
|
|
341
|
+
// leave a PR pointing at a different commit.
|
|
342
|
+
const verified = await run("gh", [
|
|
343
|
+
"pr", "view", prNumber,
|
|
344
|
+
"--repo", repo,
|
|
345
|
+
"--json", "number,headRefOid"
|
|
346
|
+
]);
|
|
347
|
+
if (verified.code !== 0) {
|
|
348
|
+
return {
|
|
349
|
+
outcome: "failed",
|
|
350
|
+
error: `pr-create-or-reuse: created PR #${prNumber} but cannot verify its head: ${verified.stderr.trim() || "gh pr view failed"}`,
|
|
351
|
+
logs: ["pr-create-or-reuse: created PR but head verification failed"]
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const headInfo = parsePrHeadEntry(verified.stdout);
|
|
355
|
+
if (headInfo === undefined || headInfo.headSha !== source.commit) {
|
|
356
|
+
return {
|
|
357
|
+
outcome: "failed",
|
|
358
|
+
error: `pr-create-or-reuse: created PR #${prNumber} head ${headInfo?.headSha ?? "unknown"} does not match frozen source commit ${source.commit}`,
|
|
359
|
+
logs: ["pr-create-or-reuse: refusing to accept a PR whose head is not the frozen source commit"]
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
outcome: "succeeded",
|
|
364
|
+
externalId: `pr:${prNumber}`,
|
|
365
|
+
logs: ["pr-create-or-reuse: created PR"]
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
case "ci-confirm": {
|
|
369
|
+
// Issue 08: when the step binds a Project with a VerificationPlan,
|
|
370
|
+
// a local hermetic L2 GateArtifact for the exact frozen commit is
|
|
371
|
+
// first-class release evidence. It is recorded as local evidence
|
|
372
|
+
// (never disguised as CI); when absent or unverifiable, the step
|
|
373
|
+
// falls back to the predeclared CI query below.
|
|
374
|
+
const gateProjectId = params.projectId;
|
|
375
|
+
if (gateProjectId !== undefined && gateProjectId !== "" && !gateProjectId.startsWith("-")) {
|
|
376
|
+
const project = resolveProject(deps.projectStore.listProjects(), gateProjectId);
|
|
377
|
+
if (project !== null) {
|
|
378
|
+
const gate = resolveVerificationGate(project, process.env);
|
|
379
|
+
if (gate !== undefined) {
|
|
380
|
+
const artifact = await findL2ArtifactForCommit(deps.projectStore, {
|
|
381
|
+
projectId: project.id,
|
|
382
|
+
commit: source.commit,
|
|
383
|
+
planDigest: gate.planDigest,
|
|
384
|
+
toolchainDigest: gate.toolchainDigest,
|
|
385
|
+
targetRef: params.targetRef ?? "master"
|
|
386
|
+
});
|
|
387
|
+
if (artifact !== null) {
|
|
388
|
+
return {
|
|
389
|
+
outcome: "succeeded",
|
|
390
|
+
externalId: `gate:${artifact.key}`,
|
|
391
|
+
logs: [
|
|
392
|
+
`ci-confirm: local hermetic L2 gate artifact ${artifact.key} `
|
|
393
|
+
+ `for ${source.commit} (plan ${gate.plan.id}@${gate.plan.version}); `
|
|
394
|
+
+ `not CI evidence`
|
|
395
|
+
]
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Bind the CI query to the exact frozen source commit AND a
|
|
402
|
+
// predeclared workflow + branch, so an unrelated successful
|
|
403
|
+
// workflow run on the same SHA cannot satisfy the release gate.
|
|
404
|
+
const ciWorkflow = params.workflow;
|
|
405
|
+
if (ciWorkflow === undefined || ciWorkflow === "") {
|
|
406
|
+
return {
|
|
407
|
+
outcome: "failed",
|
|
408
|
+
error: "ci-confirm requires params.workflow (the CI workflow filename, e.g. ci.yml)",
|
|
409
|
+
logs: ["ci-confirm: refusing to query CI without a predeclared workflow binding"]
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (ciWorkflow.startsWith("-")) {
|
|
413
|
+
return {
|
|
414
|
+
outcome: "failed",
|
|
415
|
+
error: `ci-confirm: workflow must not start with '-': ${ciWorkflow}`,
|
|
416
|
+
logs: ["ci-confirm: refusing an option-looking workflow name"]
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const ciBranch = params.branch;
|
|
420
|
+
if (ciBranch === undefined || ciBranch === "") {
|
|
421
|
+
return {
|
|
422
|
+
outcome: "failed",
|
|
423
|
+
error: "ci-confirm requires params.branch (the branch the CI workflow runs on)",
|
|
424
|
+
logs: ["ci-confirm: refusing to query CI without a predeclared branch binding"]
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (ciBranch.startsWith("-")) {
|
|
428
|
+
return {
|
|
429
|
+
outcome: "failed",
|
|
430
|
+
error: `ci-confirm: branch must not start with '-': ${ciBranch}`,
|
|
431
|
+
logs: ["ci-confirm: refusing an option-looking branch name"]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const status = await run("gh", [
|
|
435
|
+
"run", "list",
|
|
436
|
+
"--commit", source.commit,
|
|
437
|
+
"--workflow", ciWorkflow,
|
|
438
|
+
"--branch", ciBranch,
|
|
439
|
+
"--limit", "1",
|
|
440
|
+
"--repo", `${source.repository.owner}/${source.repository.name}`,
|
|
441
|
+
"--json", "conclusion,status",
|
|
442
|
+
"--jq", ".[0] | {conclusion,status}"
|
|
443
|
+
]);
|
|
444
|
+
if (status.code !== 0) {
|
|
445
|
+
return { outcome: "failed", error: status.stderr.trim() || "gh run list failed" };
|
|
446
|
+
}
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(status.stdout.trim());
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return { outcome: "failed", error: `ci-confirm: cannot parse gh run list output: ${status.stdout.trim()}` };
|
|
453
|
+
}
|
|
454
|
+
if (parsed === undefined || parsed === null) {
|
|
455
|
+
return { outcome: "failed", error: `ci-confirm: no ${ciWorkflow} run found on ${ciBranch} for ${source.commit}` };
|
|
456
|
+
}
|
|
457
|
+
if (parsed.status !== "completed") {
|
|
458
|
+
return { outcome: "failed", error: `CI not finished: ${parsed.status ?? "unknown"}` };
|
|
459
|
+
}
|
|
460
|
+
if (parsed.conclusion === "success") {
|
|
461
|
+
return { outcome: "succeeded", externalId: `ci:${source.commit.slice(0, 7)}`, logs: [`ci-confirm: ${ciWorkflow} on ${ciBranch} @ ${source.commit}: ${parsed.conclusion}`] };
|
|
462
|
+
}
|
|
463
|
+
return { outcome: "failed", error: `CI conclusion: ${parsed.conclusion ?? "unknown"}` };
|
|
464
|
+
}
|
|
465
|
+
case "merge": {
|
|
466
|
+
return mergePullRequest(run, source, params.pr, params);
|
|
467
|
+
}
|
|
468
|
+
case "version-tag": {
|
|
469
|
+
const tag = params.tag;
|
|
470
|
+
if (tag === undefined) {
|
|
471
|
+
return { outcome: "failed", error: "version-tag requires params.tag." };
|
|
472
|
+
}
|
|
473
|
+
// P1-3: A tag that starts with '-' would be interpreted as a git
|
|
474
|
+
// option. Reject it before any command runs.
|
|
475
|
+
if (tag.startsWith("-")) {
|
|
476
|
+
return {
|
|
477
|
+
outcome: "failed",
|
|
478
|
+
error: `version-tag: tag ${tag} must not start with '-' (would be interpreted as a git option)`,
|
|
479
|
+
logs: ["version-tag: refusing to create a tag that looks like an option"]
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// The tag is created and pushed from inside the exact source
|
|
483
|
+
// repository checkout, never the Controller's cwd.
|
|
484
|
+
const cwd = params.repositoryPath;
|
|
485
|
+
if (cwd === undefined) {
|
|
486
|
+
return { outcome: "failed", error: "version-tag requires params.repositoryPath." };
|
|
487
|
+
}
|
|
488
|
+
// The checkout must be the granted source repository. An arbitrary
|
|
489
|
+
// params.repositoryPath would push the release tag to that clone's
|
|
490
|
+
// origin, so verify the remote before any local effect.
|
|
491
|
+
// P1-5: Verify both the fetch URL and the push URL. `git push` uses
|
|
492
|
+
// the push URL, which can differ from the fetch URL when pushurl is
|
|
493
|
+
// configured. A mismatch would send the tag to the wrong remote.
|
|
494
|
+
const fetchUrl = await run("git", ["remote", "get-url", "origin"], cwd);
|
|
495
|
+
if (fetchUrl.code !== 0) {
|
|
496
|
+
return {
|
|
497
|
+
outcome: "failed",
|
|
498
|
+
error: `version-tag: cannot read origin of ${cwd}: ${fetchUrl.stderr.trim() || "git remote get-url failed"}`,
|
|
499
|
+
logs: ["version-tag: refusing to tag a checkout whose origin cannot be verified"]
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (!remoteMatchesRepository(fetchUrl.stdout, source.repository)) {
|
|
503
|
+
return {
|
|
504
|
+
outcome: "failed",
|
|
505
|
+
error: `version-tag: origin ${fetchUrl.stdout.trim()} is not the granted repository ${source.repository.owner}/${source.repository.name}`,
|
|
506
|
+
logs: ["version-tag: refusing to tag a checkout whose origin is not the granted repository"]
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// P1-5: Verify ALL configured push URLs. `git push` writes to every
|
|
510
|
+
// pushurl, so a matching first URL plus a foreign second URL would
|
|
511
|
+
// still send the release tag to an unauthorized target.
|
|
512
|
+
const pushUrls = await run("git", ["remote", "get-url", "--push", "--all", "origin"], cwd);
|
|
513
|
+
if (pushUrls.code !== 0) {
|
|
514
|
+
return {
|
|
515
|
+
outcome: "failed",
|
|
516
|
+
error: `version-tag: cannot read push URLs of ${cwd}: ${pushUrls.stderr.trim() || "git remote get-url --push --all failed"}`,
|
|
517
|
+
logs: ["version-tag: refusing to tag a checkout whose push URLs cannot be verified"]
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const effectivePushUrls = pushUrls.stdout
|
|
521
|
+
.split(/\r?\n/)
|
|
522
|
+
.map((url) => url.trim())
|
|
523
|
+
.filter((url) => url.length > 0);
|
|
524
|
+
if (effectivePushUrls.length === 0) {
|
|
525
|
+
return {
|
|
526
|
+
outcome: "failed",
|
|
527
|
+
error: `version-tag: origin of ${cwd} has no push URL`,
|
|
528
|
+
logs: ["version-tag: refusing to tag a checkout with no push URL"]
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const foreignPushUrl = effectivePushUrls.find((url) => !remoteMatchesRepository(url, source.repository));
|
|
532
|
+
if (foreignPushUrl !== undefined) {
|
|
533
|
+
return {
|
|
534
|
+
outcome: "failed",
|
|
535
|
+
error: `version-tag: push URL ${foreignPushUrl} is not the granted repository ${source.repository.owner}/${source.repository.name}`,
|
|
536
|
+
logs: ["version-tag: refusing to tag a checkout with a foreign push URL"]
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
// Recovery convergence: a previous attempt may have created the
|
|
540
|
+
// local tag before the push failed. Re-creating it would fail
|
|
541
|
+
// ("tag already exists") and strand the recovery, so verify an
|
|
542
|
+
// existing tag points at the frozen commit and proceed to push;
|
|
543
|
+
// only create the tag when it is absent.
|
|
544
|
+
// P1-3: Validate the tag is a well-formed ref name so it can never
|
|
545
|
+
// be interpreted as an option or an unexpected revision.
|
|
546
|
+
const refCheck = await run("git", ["check-ref-format", `refs/tags/${tag}`], cwd);
|
|
547
|
+
if (refCheck.code !== 0) {
|
|
548
|
+
return {
|
|
549
|
+
outcome: "failed",
|
|
550
|
+
error: `version-tag: tag ${tag} is not a valid git ref name`,
|
|
551
|
+
logs: ["version-tag: refusing to create a tag that is not a valid ref"]
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
// Resolve the explicit refs/tags/<tag> revision WITHOUT a `--`
|
|
555
|
+
// separator: `--` would make git parse the revision as a path and
|
|
556
|
+
// report a real existing tag as absent.
|
|
557
|
+
const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], cwd);
|
|
558
|
+
if (existing.code === 0) {
|
|
559
|
+
if (existing.stdout.trim() !== source.commit) {
|
|
560
|
+
return {
|
|
561
|
+
outcome: "failed",
|
|
562
|
+
error: `version-tag: local tag ${tag} points at ${existing.stdout.trim()}, not the frozen commit ${source.commit}`,
|
|
563
|
+
logs: ["version-tag: refusing to push a local tag that does not name the frozen commit"]
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// Place -m and its value before the `--` separator so the tag
|
|
569
|
+
// name and commit are the only operands; `--` after options keeps
|
|
570
|
+
// the tag from ever being parsed as an option.
|
|
571
|
+
const tagged = await run("git", ["tag", "-a", "-m", `Release ${tag}`, "--", tag, source.commit], cwd);
|
|
572
|
+
if (tagged.code !== 0) {
|
|
573
|
+
return { outcome: "failed", error: tagged.stderr.trim() || "git tag failed" };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// P1-3: Use an explicit refspec so the tag is pushed to the exact
|
|
577
|
+
// refs/tags/<tag> path, never interpreted as an option or a ref.
|
|
578
|
+
const sent = await run("git", ["push", "origin", `refs/tags/${tag}:refs/tags/${tag}`], cwd);
|
|
579
|
+
if (sent.code !== 0) {
|
|
580
|
+
return { outcome: "timeout", externalIdentity: { kind: "git-tag", value: tag }, error: sent.stderr.trim() };
|
|
581
|
+
}
|
|
582
|
+
return { outcome: "succeeded", externalId: `tag:${tag}`, logs: [`version-tag: ${tag} @ ${source.commit}`] };
|
|
583
|
+
}
|
|
584
|
+
case "npm-publish": {
|
|
585
|
+
const tarball = params.tarball;
|
|
586
|
+
if (tarball === undefined) {
|
|
587
|
+
return { outcome: "failed", error: "npm-publish requires params.tarball." };
|
|
588
|
+
}
|
|
589
|
+
// P1-6: Require a content-addressed source artifact for every
|
|
590
|
+
// npm-publish. Without an artifact, the tarball is unbound: any
|
|
591
|
+
// same-named file could be published, violating the exact-source
|
|
592
|
+
// guarantee. The artifact's sha512 is verified before publishing.
|
|
593
|
+
if (source.artifact === undefined) {
|
|
594
|
+
return {
|
|
595
|
+
outcome: "failed",
|
|
596
|
+
error: "npm-publish: source.artifact is required (content-addressed tarball with sha512)",
|
|
597
|
+
logs: ["npm-publish: refusing to publish without a frozen artifact"]
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const tarballName = tarball.split("/").pop() ?? tarball;
|
|
601
|
+
if (tarballName !== source.artifact.name) {
|
|
602
|
+
return {
|
|
603
|
+
outcome: "failed",
|
|
604
|
+
error: `npm-publish: tarball ${tarballName} does not match frozen artifact ${source.artifact.name}`,
|
|
605
|
+
logs: ["npm-publish: refusing to publish a tarball that is not the frozen artifact"]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// The frozen artifact is content-addressed: a same-named tarball
|
|
609
|
+
// with different bytes would still publish the wrong package.
|
|
610
|
+
// Verify the tarball's sha512 against the recorded integrity
|
|
611
|
+
// before publishing, and fail closed when the file cannot be read.
|
|
612
|
+
let bytes;
|
|
613
|
+
try {
|
|
614
|
+
bytes = await readFile(tarball);
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
return {
|
|
618
|
+
outcome: "failed",
|
|
619
|
+
error: `npm-publish: cannot read tarball ${tarball}: ${error instanceof Error ? error.message : String(error)}`,
|
|
620
|
+
logs: ["npm-publish: refusing to publish a tarball that cannot be verified"]
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const actualIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
624
|
+
if (actualIntegrity !== source.artifact.integrity) {
|
|
625
|
+
return {
|
|
626
|
+
outcome: "failed",
|
|
627
|
+
error: `npm-publish: tarball integrity ${actualIntegrity} does not match frozen artifact ${source.artifact.integrity}`,
|
|
628
|
+
logs: ["npm-publish: refusing to publish a tarball that is not the frozen artifact"]
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
// Reject option-looking tarball paths before any subprocess sees
|
|
632
|
+
// them: both `tar -xOf` (manifest inspection) and `npm publish`
|
|
633
|
+
// would interpret a file named "--dry-run" as a flag, not an
|
|
634
|
+
// operand, and report a normal result without any registry effect.
|
|
635
|
+
if (tarball.startsWith("-")) {
|
|
636
|
+
return {
|
|
637
|
+
outcome: "failed",
|
|
638
|
+
error: `npm-publish: tarball path must not start with '-': ${tarball}`,
|
|
639
|
+
logs: ["npm-publish: refusing to publish an option-looking tarball path"]
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
// P1-3 (rr22): Snapshot the verified bytes to a workflow-private,
|
|
643
|
+
// read-only temp file. Both the manifest inspection and `npm
|
|
644
|
+
// publish` read THIS snapshot, so a TOCTOU replacement of the
|
|
645
|
+
// original path after the integrity check cannot change what is
|
|
646
|
+
// published. The snapshot is removed once publish completes.
|
|
647
|
+
const snapshot = await writeVerifiedTarballSnapshot(bytes);
|
|
648
|
+
try {
|
|
649
|
+
// Inspect the tarball manifest before publishing: the actual
|
|
650
|
+
// package name/version must match the frozen declaration, not
|
|
651
|
+
// just the file name or bytes. A same-named tarball with a
|
|
652
|
+
// different manifest would publish the wrong package, and a
|
|
653
|
+
// source without a frozen artifact still gets this check.
|
|
654
|
+
const target = params.package ?? derivePackageName(source);
|
|
655
|
+
const manifest = await readTarballManifest(run, snapshot);
|
|
656
|
+
if (manifest === undefined) {
|
|
657
|
+
return {
|
|
658
|
+
outcome: "failed",
|
|
659
|
+
error: `npm-publish: cannot read manifest of ${tarball}`,
|
|
660
|
+
logs: ["npm-publish: refusing to publish a tarball whose manifest cannot be verified"]
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (manifest.name !== target) {
|
|
664
|
+
return {
|
|
665
|
+
outcome: "failed",
|
|
666
|
+
error: `npm-publish: tarball manifest ${manifest.name} does not match publish target ${target}`,
|
|
667
|
+
logs: ["npm-publish: refusing to publish a tarball that is not the frozen package"]
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (params.version !== undefined && manifest.version !== params.version) {
|
|
671
|
+
return {
|
|
672
|
+
outcome: "failed",
|
|
673
|
+
error: `npm-publish: tarball version ${manifest.version} does not match frozen version ${params.version}`,
|
|
674
|
+
logs: ["npm-publish: refusing to publish a tarball that is not the frozen version"]
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// P1 (rr25): resolve and persist the exact npm executable and
|
|
678
|
+
// registry BEFORE the irreversible effect. A resume in a
|
|
679
|
+
// different environment reads this durable receipt instead of
|
|
680
|
+
// invoking its own npm/registry: a different mirror serving the
|
|
681
|
+
// same version+integrity would otherwise falsely confirm this
|
|
682
|
+
// publish. The registry is also passed to publish explicitly so
|
|
683
|
+
// the receipt names the target the effect actually used.
|
|
684
|
+
if (pinnedNpmPath === undefined) {
|
|
685
|
+
return {
|
|
686
|
+
outcome: "failed",
|
|
687
|
+
error: "npm-publish: npm is not resolvable at adapter construction; refusing an unbound effect target",
|
|
688
|
+
logs: ["npm-publish: refusing to publish without a pinned npm executable"]
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
const registry = await resolveNpmPublishRegistry(run, manifest.name, manifest.publishConfig?.registry);
|
|
692
|
+
if (registry === undefined) {
|
|
693
|
+
return {
|
|
694
|
+
outcome: "failed",
|
|
695
|
+
error: "npm-publish: registry target could not be resolved; refusing an unbound effect target",
|
|
696
|
+
logs: ["npm-publish: refusing to publish without a pinned registry"]
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
await persistNpmPublishTarget(deps.home, idempotencyKey, {
|
|
701
|
+
npmPath: pinnedNpmPath,
|
|
702
|
+
registry
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
return {
|
|
707
|
+
outcome: "failed",
|
|
708
|
+
error: `npm-publish: cannot persist effect target before publish: ${error instanceof Error ? error.message : String(error)}`,
|
|
709
|
+
logs: ["npm-publish: refusing to publish when the durable target cannot be recorded"]
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const published = await run("npm", [
|
|
713
|
+
"publish",
|
|
714
|
+
"--registry",
|
|
715
|
+
registry,
|
|
716
|
+
"--",
|
|
717
|
+
snapshot
|
|
718
|
+
]);
|
|
719
|
+
if (published.code !== 0) {
|
|
720
|
+
// A transport failure after the upload may have actually published
|
|
721
|
+
// the package. Record the identity so the engine can query npm
|
|
722
|
+
// authoritatively on resume instead of re-publishing blindly.
|
|
723
|
+
if (isTransportFailure(published.stderr)) {
|
|
724
|
+
return {
|
|
725
|
+
outcome: "failed",
|
|
726
|
+
externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
|
|
727
|
+
error: published.stderr.trim() || "npm publish failed",
|
|
728
|
+
logs: ["npm-publish: transport failure after upload; querying registry on resume"]
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
return { outcome: "failed", error: published.stderr.trim() || "npm publish failed" };
|
|
732
|
+
}
|
|
733
|
+
// Verify the publish result matches the frozen declaration. npm
|
|
734
|
+
// reports the published id as "+ name@version"; a result naming a
|
|
735
|
+
// different package or version must not satisfy the step, and a
|
|
736
|
+
// result without a parseable confirmation is uncertain: the engine
|
|
737
|
+
// re-queries the registry by the exact package identity on resume.
|
|
738
|
+
const confirmation = parsePublishConfirmation(published.stdout);
|
|
739
|
+
if (confirmation === undefined) {
|
|
740
|
+
return {
|
|
741
|
+
outcome: "timeout",
|
|
742
|
+
externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
|
|
743
|
+
error: "npm-publish: publish result did not confirm the frozen package@version",
|
|
744
|
+
logs: ["npm-publish: publish result unconfirmed; querying registry on resume"]
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (confirmation.name !== target || confirmation.version !== manifest.version) {
|
|
748
|
+
return {
|
|
749
|
+
outcome: "failed",
|
|
750
|
+
externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
|
|
751
|
+
error: `npm-publish: published ${confirmation.name}@${confirmation.version} does not match frozen ${target}@${manifest.version}`,
|
|
752
|
+
logs: ["npm-publish: refusing to satisfy the step with a different publish result"]
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
outcome: "succeeded",
|
|
757
|
+
externalId: manifest.version,
|
|
758
|
+
logs: [`npm-publish: ${tarball}`]
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
// Remove the private snapshot on every exit path (success,
|
|
763
|
+
// failure, or throw) so the verified bytes never linger.
|
|
764
|
+
await rm(snapshot, { force: true }).catch(() => { });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
case "fresh-install-smoke": {
|
|
768
|
+
const version = params.version;
|
|
769
|
+
// P2-3: Require a concrete, pinned version. Without one, npx
|
|
770
|
+
// resolves to "latest" (or a moving range), which could install a
|
|
771
|
+
// different release than the one this workflow just published.
|
|
772
|
+
if (version === undefined || !isConcreteVersion(version)) {
|
|
773
|
+
return {
|
|
774
|
+
outcome: "failed",
|
|
775
|
+
error: `fresh-install-smoke: params.version ${version ?? "(missing)"} is not a concrete pinned version (e.g. 1.2.3)`,
|
|
776
|
+
logs: ["fresh-install-smoke: refusing to install a moving or unpinned version"]
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
// Install the released scoped package derived from the exact source
|
|
780
|
+
// repository, not an unscoped or stale name.
|
|
781
|
+
const pkg = params.package ?? derivePackageName(source);
|
|
782
|
+
// P2-3: Validate the package spec is not an option.
|
|
783
|
+
if (pkg.startsWith("-")) {
|
|
784
|
+
return {
|
|
785
|
+
outcome: "failed",
|
|
786
|
+
error: `fresh-install-smoke: package ${pkg} must not start with '-' (would be interpreted as an npx option)`,
|
|
787
|
+
logs: ["fresh-install-smoke: refusing to install a package that looks like an option"]
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
// P2-2 (rr19): Install in a fresh temp directory with an isolated
|
|
791
|
+
// npm cache, so npx cannot reuse a local node_modules binary. Then
|
|
792
|
+
// run the installed binary directly and verify the output matches
|
|
793
|
+
// the pinned version.
|
|
794
|
+
const smokeDir = await mkdtemp(join(tmpdir(), "yui-smoke-"));
|
|
795
|
+
const smokeCache = join(smokeDir, "npm-cache");
|
|
796
|
+
try {
|
|
797
|
+
const install = await run("npm", [
|
|
798
|
+
"install",
|
|
799
|
+
"--prefix", smokeDir,
|
|
800
|
+
"--cache", smokeCache,
|
|
801
|
+
"--no-audit", "--no-fund",
|
|
802
|
+
`${pkg}@${version}`
|
|
803
|
+
]);
|
|
804
|
+
if (install.code !== 0) {
|
|
805
|
+
return { outcome: "failed", error: install.stderr.trim() || `npm install ${pkg}@${version} failed` };
|
|
806
|
+
}
|
|
807
|
+
// Derive the binary name from the package name (scoped or unscoped).
|
|
808
|
+
const binName = pkg.includes("/") ? pkg.split("/").pop() : pkg;
|
|
809
|
+
const binPath = join(smokeDir, "node_modules", ".bin", binName);
|
|
810
|
+
const smoke = await run(binPath, ["--version"]);
|
|
811
|
+
if (smoke.code !== 0) {
|
|
812
|
+
return { outcome: "failed", error: smoke.stderr.trim() || "fresh-install smoke failed" };
|
|
813
|
+
}
|
|
814
|
+
const output = smoke.stdout.trim();
|
|
815
|
+
if (output !== version) {
|
|
816
|
+
return {
|
|
817
|
+
outcome: "failed",
|
|
818
|
+
error: `fresh-install-smoke: installed version ${output} does not match pinned ${version}`,
|
|
819
|
+
logs: [`fresh-install-smoke: refusing to accept a version mismatch (got ${output}, want ${version})`]
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return { outcome: "succeeded", externalId: version, logs: [`fresh-install-smoke: ${output}`] };
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
await rm(smokeDir, { recursive: true, force: true }).catch(() => { });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
case "post-verify": {
|
|
829
|
+
const command = params.command;
|
|
830
|
+
if (command === undefined) {
|
|
831
|
+
return { outcome: "failed", error: "post-verify requires params.command." };
|
|
832
|
+
}
|
|
833
|
+
const verified = await run("sh", ["-c", command], params.cwd);
|
|
834
|
+
if (verified.code !== 0) {
|
|
835
|
+
return { outcome: "failed", error: verified.stderr.trim() || `post-verify exited ${verified.code}` };
|
|
836
|
+
}
|
|
837
|
+
return { outcome: "succeeded", logs: [`post-verify: ${command}`] };
|
|
838
|
+
}
|
|
839
|
+
default:
|
|
840
|
+
return { outcome: "failed", error: `Unsupported step kind: ${String(step.kind)}` };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
home: deps.home,
|
|
845
|
+
async executeStep(input) {
|
|
846
|
+
// Adapter-level idempotency: a crash after the effect landed but
|
|
847
|
+
// before the engine persisted it re-invokes executeStep under the
|
|
848
|
+
// same key. Replay the recorded success instead of producing a
|
|
849
|
+
// second shell execution. A store that cannot be read fails the
|
|
850
|
+
// step, since at-most-once cannot be proven.
|
|
851
|
+
let recorded;
|
|
852
|
+
try {
|
|
853
|
+
recorded = await idempotency.load(input.idempotencyKey);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
return {
|
|
857
|
+
outcome: "failed",
|
|
858
|
+
error: `idempotency store unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
859
|
+
logs: ["executeStep: refusing to run without the idempotency record"]
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
if (recorded !== undefined) {
|
|
863
|
+
return {
|
|
864
|
+
...recorded,
|
|
865
|
+
logs: [
|
|
866
|
+
...(recorded.logs ?? []),
|
|
867
|
+
`idempotent: replayed recorded effect for ${input.idempotencyKey}`
|
|
868
|
+
]
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const effect = await executeStepOnce(input);
|
|
872
|
+
if (effect.outcome === "succeeded") {
|
|
873
|
+
try {
|
|
874
|
+
await idempotency.recordSuccess(input.idempotencyKey, effect);
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
// The effect landed but its dedup record is not durable. Never
|
|
878
|
+
// report success: a crash before the engine persisted the step
|
|
879
|
+
// would re-run the effect. Fail closed as ambiguous, carrying a
|
|
880
|
+
// queryable identity when the step kind has one so the engine
|
|
881
|
+
// re-queries authoritatively on resume instead of re-submitting.
|
|
882
|
+
const identity = await queryIdentityFor(input.step, input.params, effect, input.source, deps.home, run);
|
|
883
|
+
return {
|
|
884
|
+
outcome: "timeout",
|
|
885
|
+
...(identity === undefined ? {} : { externalIdentity: identity }),
|
|
886
|
+
error: `idempotency record not persisted: ${error instanceof Error ? error.message : String(error)}`,
|
|
887
|
+
logs: [
|
|
888
|
+
...(effect.logs ?? []),
|
|
889
|
+
"executeStep: effect landed but its idempotency record is not durable; failing closed"
|
|
890
|
+
]
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return effect;
|
|
895
|
+
},
|
|
896
|
+
async queryStepEffect({ step, source, externalIdentity }) {
|
|
897
|
+
// P2-1: Check the durable idempotency store first. A recorded success
|
|
898
|
+
// proves the effect landed even when the engine has no
|
|
899
|
+
// externalIdentity (e.g. a crash between executeStep and the engine's
|
|
900
|
+
// completeStep save). Without this, a successfully executed step is
|
|
901
|
+
// permanently stranded as unconfirmed.
|
|
902
|
+
const durable = await idempotency.load(step.idempotencyKey);
|
|
903
|
+
if (durable !== undefined) {
|
|
904
|
+
return { state: "exists", externalId: durable.externalId };
|
|
905
|
+
}
|
|
906
|
+
if (externalIdentity === undefined) {
|
|
907
|
+
// P2-1 (rr19/rr20): When no identity was recorded (a hard exit
|
|
908
|
+
// between the external effect and the idempotency/identity
|
|
909
|
+
// persistence), derive the identity from the frozen plan for kinds
|
|
910
|
+
// that support it. This prevents a permanently stranded unconfirmed
|
|
911
|
+
// step when the effect actually landed.
|
|
912
|
+
if (step.kind === "cli-update") {
|
|
913
|
+
// P1-2 (rr23): A hard-exit cli-update may only be queried against
|
|
914
|
+
// the exact activation target persisted to a durable file BEFORE
|
|
915
|
+
// the irreversible effect. Never derive the target from the resume
|
|
916
|
+
// caller's npm/PATH environment: a different global prefix on
|
|
917
|
+
// resume could confirm a different installation. With no durable
|
|
918
|
+
// record (the process exited before the pre-effect persistence)
|
|
919
|
+
// the effect is unprovable, so fail closed.
|
|
920
|
+
externalIdentity = await readPersistedCliUpdateIdentity(deps.home, step.idempotencyKey);
|
|
921
|
+
if (externalIdentity === undefined)
|
|
922
|
+
return { state: "unknown" };
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
const derived = deriveIdentityFromPlan(step, source);
|
|
926
|
+
if (derived !== undefined) {
|
|
927
|
+
externalIdentity = derived;
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
// A step that crashed before recording an effect identity cannot be
|
|
931
|
+
// re-queried by a stable handle. The step plan alone is not an
|
|
932
|
+
// authoritative handle for every kind, so fail closed: the engine
|
|
933
|
+
// treats the disposition as unconfirmed rather than re-submitting.
|
|
934
|
+
return { state: "unknown" };
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
switch (externalIdentity.kind) {
|
|
939
|
+
case "pull-request": {
|
|
940
|
+
// P2-2: Bind the query to the source repository and require the
|
|
941
|
+
// PR's head to be the exact frozen commit. A same-numbered PR in a
|
|
942
|
+
// different repository, or one whose head moved, must not confirm
|
|
943
|
+
// the effect.
|
|
944
|
+
if (source === undefined)
|
|
945
|
+
return { state: "unknown" };
|
|
946
|
+
const repo = `${source.repository.owner}/${source.repository.name}`;
|
|
947
|
+
const found = await run("gh", [
|
|
948
|
+
"pr", "view", externalIdentity.value,
|
|
949
|
+
"--repo", repo,
|
|
950
|
+
"--json", "number,headRefOid"
|
|
951
|
+
]);
|
|
952
|
+
if (found.code !== 0) {
|
|
953
|
+
// A non-zero exit is ambiguous (the PR may be gone, or the
|
|
954
|
+
// transport failed); fail closed rather than authorizing a
|
|
955
|
+
// re-submission.
|
|
956
|
+
return { state: "unknown" };
|
|
957
|
+
}
|
|
958
|
+
const headInfo = parsePrHeadEntry(found.stdout);
|
|
959
|
+
if (headInfo === undefined || headInfo.headSha !== source.commit) {
|
|
960
|
+
return { state: "unknown" };
|
|
961
|
+
}
|
|
962
|
+
return { state: "exists", externalId: `pr:${headInfo.prNumber}` };
|
|
963
|
+
}
|
|
964
|
+
case "git-tag": {
|
|
965
|
+
// A local tag is not authoritative: query the remote so a tag that
|
|
966
|
+
// was never pushed reads as absent. The query runs inside the
|
|
967
|
+
// step's source repository checkout, never the Controller's cwd.
|
|
968
|
+
// An empty result (exit 0, no output) is an authoritative absent;
|
|
969
|
+
// a non-zero exit is a transport failure and reads as unknown.
|
|
970
|
+
//
|
|
971
|
+
// Re-attest the checkout before querying: an answer from a foreign
|
|
972
|
+
// or unverifiable origin is worthless (and a foreign origin could
|
|
973
|
+
// confirm a tag we never pushed). Without the bound source or the
|
|
974
|
+
// checkout path the binding cannot be checked, so fail closed.
|
|
975
|
+
// P1-5: Verify both fetch and push URLs.
|
|
976
|
+
const checkoutPath = step.params?.repositoryPath;
|
|
977
|
+
if (source === undefined || checkoutPath === undefined) {
|
|
978
|
+
return { state: "unknown" };
|
|
979
|
+
}
|
|
980
|
+
const fetchUrl = await run("git", ["remote", "get-url", "origin"], checkoutPath);
|
|
981
|
+
if (fetchUrl.code !== 0 || !remoteMatchesRepository(fetchUrl.stdout, source.repository)) {
|
|
982
|
+
return { state: "unknown" };
|
|
983
|
+
}
|
|
984
|
+
// P1-5: Verify ALL configured push URLs in the recovery path too.
|
|
985
|
+
const pushUrls = await run("git", ["remote", "get-url", "--push", "--all", "origin"], checkoutPath);
|
|
986
|
+
if (pushUrls.code !== 0) {
|
|
987
|
+
return { state: "unknown" };
|
|
988
|
+
}
|
|
989
|
+
const recoveryPushUrls = pushUrls.stdout
|
|
990
|
+
.split(/\r?\n/)
|
|
991
|
+
.map((url) => url.trim())
|
|
992
|
+
.filter((url) => url.length > 0);
|
|
993
|
+
if (recoveryPushUrls.length === 0
|
|
994
|
+
|| recoveryPushUrls.some((url) => !remoteMatchesRepository(url, source.repository))) {
|
|
995
|
+
return { state: "unknown" };
|
|
996
|
+
}
|
|
997
|
+
const found = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${externalIdentity.value}`], checkoutPath);
|
|
998
|
+
if (found.code !== 0) {
|
|
999
|
+
return { state: "unknown" };
|
|
1000
|
+
}
|
|
1001
|
+
if (found.stdout.trim().length > 0) {
|
|
1002
|
+
// Verify the tag points at the expected source commit. A tag that
|
|
1003
|
+
// points at a different commit is not the effect we recorded.
|
|
1004
|
+
// Prefer the peeled ref (refs/tags/name^{}): for an annotated tag
|
|
1005
|
+
// the direct line names the tag object, not the commit it
|
|
1006
|
+
// records, so its SHA differs from the source commit even when
|
|
1007
|
+
// the tag is ours. The peeled line names the commit the tag
|
|
1008
|
+
// ultimately points at; a lightweight tag has no peeled line and
|
|
1009
|
+
// the direct line's SHA is the commit itself.
|
|
1010
|
+
const lines = found.stdout
|
|
1011
|
+
.split(/\r?\n/)
|
|
1012
|
+
.map((line) => line.trim())
|
|
1013
|
+
.filter((line) => line.length > 0);
|
|
1014
|
+
const peeled = lines.find((line) => line.endsWith(`refs/tags/${externalIdentity.value}^{}`));
|
|
1015
|
+
const direct = lines.find((line) => line.endsWith(`refs/tags/${externalIdentity.value}`));
|
|
1016
|
+
const actualCommit = (peeled ?? direct)?.split(/\s+/)[0];
|
|
1017
|
+
if (source !== undefined && actualCommit !== undefined && actualCommit !== source.commit) {
|
|
1018
|
+
return { state: "unknown" };
|
|
1019
|
+
}
|
|
1020
|
+
return { state: "exists", externalId: `tag:${externalIdentity.value}` };
|
|
1021
|
+
}
|
|
1022
|
+
return { state: "absent" };
|
|
1023
|
+
}
|
|
1024
|
+
case "npm-package": {
|
|
1025
|
+
// The externalIdentity value may be "package" or "package@version".
|
|
1026
|
+
// Query the exact version, not the latest, so a newer concurrent
|
|
1027
|
+
// publish cannot confirm our step.
|
|
1028
|
+
const atIndex = externalIdentity.value.lastIndexOf("@");
|
|
1029
|
+
let pkg = externalIdentity.value;
|
|
1030
|
+
let expectedVersion;
|
|
1031
|
+
if (atIndex > 0) {
|
|
1032
|
+
pkg = externalIdentity.value.slice(0, atIndex);
|
|
1033
|
+
expectedVersion = externalIdentity.value.slice(atIndex + 1);
|
|
1034
|
+
}
|
|
1035
|
+
// Fall back to the step params for the expected version.
|
|
1036
|
+
if (expectedVersion === undefined) {
|
|
1037
|
+
expectedVersion = step.params?.version;
|
|
1038
|
+
}
|
|
1039
|
+
// Without an exact version, `npm view <pkg>` answers for whatever
|
|
1040
|
+
// the latest publish is — a concurrent release could confirm our
|
|
1041
|
+
// step with someone else's version. Fail closed instead of
|
|
1042
|
+
// querying the moving "latest" target.
|
|
1043
|
+
if (expectedVersion === undefined) {
|
|
1044
|
+
return { state: "unknown" };
|
|
1045
|
+
}
|
|
1046
|
+
// P1 (rr24): an npm-publish recovery query may only run against
|
|
1047
|
+
// the exact npm executable and registry persisted BEFORE the
|
|
1048
|
+
// original effect. Never fall back to the resume caller's
|
|
1049
|
+
// npm/registry: a different mirror serving the same
|
|
1050
|
+
// version+integrity would falsely confirm environment A's
|
|
1051
|
+
// publish. With no durable record (the process exited before the
|
|
1052
|
+
// pre-effect persistence, or the record is malformed) the effect
|
|
1053
|
+
// is unprovable, so fail closed.
|
|
1054
|
+
const persistedTarget = await readPersistedNpmPublishTarget(deps.home, step.idempotencyKey);
|
|
1055
|
+
if (persistedTarget === undefined) {
|
|
1056
|
+
return { state: "unknown" };
|
|
1057
|
+
}
|
|
1058
|
+
const view = (field) => run(persistedTarget.npmPath, ["view", `${pkg}@${expectedVersion}`, field, "--registry", persistedTarget.registry]);
|
|
1059
|
+
const found = await view("version");
|
|
1060
|
+
if (found.code === 0 && found.stdout.trim().length > 0) {
|
|
1061
|
+
const actualVersion = found.stdout.trim();
|
|
1062
|
+
// The published version must match the exact expected version.
|
|
1063
|
+
if (actualVersion !== expectedVersion) {
|
|
1064
|
+
return { state: "unknown" };
|
|
1065
|
+
}
|
|
1066
|
+
// P1-4 (rr22): a matching version string alone does not prove
|
|
1067
|
+
// the frozen artifact landed. Compare the published
|
|
1068
|
+
// `dist.integrity` with the frozen source artifact's integrity:
|
|
1069
|
+
// a version republished with different bytes is a conflict and
|
|
1070
|
+
// is never confirmed. Without a frozen integrity the published
|
|
1071
|
+
// bytes cannot be proven to be the granted artifact, so fail
|
|
1072
|
+
// closed there too.
|
|
1073
|
+
const frozenIntegrity = source?.artifact?.integrity;
|
|
1074
|
+
if (frozenIntegrity === undefined) {
|
|
1075
|
+
return { state: "unknown" };
|
|
1076
|
+
}
|
|
1077
|
+
const integrity = await view("dist.integrity");
|
|
1078
|
+
if (integrity.code !== 0 || integrity.stdout.trim().length === 0) {
|
|
1079
|
+
return { state: "unknown" };
|
|
1080
|
+
}
|
|
1081
|
+
if (integrity.stdout.trim() !== frozenIntegrity) {
|
|
1082
|
+
return { state: "unknown" };
|
|
1083
|
+
}
|
|
1084
|
+
return { state: "exists", externalId: actualVersion };
|
|
1085
|
+
}
|
|
1086
|
+
// A 404 means the package/version does not exist (absent).
|
|
1087
|
+
if (/E404|404 Not Found/i.test(found.stderr)) {
|
|
1088
|
+
return { state: "absent" };
|
|
1089
|
+
}
|
|
1090
|
+
// A non-404 failure is a transport error; fail closed.
|
|
1091
|
+
return { state: "unknown" };
|
|
1092
|
+
}
|
|
1093
|
+
case "controller-home": {
|
|
1094
|
+
// P1-2 (rr22): the identity value is a JSON envelope
|
|
1095
|
+
// {home, globalPrefix?} that pins the exact activation target;
|
|
1096
|
+
// legacy identities are bare Home path strings and re-derive the
|
|
1097
|
+
// prefix from the caller's environment.
|
|
1098
|
+
let queriedHome = deps.home;
|
|
1099
|
+
let pinnedPrefix;
|
|
1100
|
+
const parsedIdentity = parseControllerHomeIdentity(externalIdentity.value);
|
|
1101
|
+
if (parsedIdentity !== undefined) {
|
|
1102
|
+
if (parsedIdentity.globalPrefix === undefined)
|
|
1103
|
+
return { state: "unknown" };
|
|
1104
|
+
queriedHome = parsedIdentity.home;
|
|
1105
|
+
pinnedPrefix = parsedIdentity.globalPrefix;
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
queriedHome = externalIdentity.value;
|
|
1109
|
+
}
|
|
1110
|
+
// P1-3 (rr20): Query the GLOBAL install target — the same binary
|
|
1111
|
+
// `cli-update` activates via `npm install --global` — not the
|
|
1112
|
+
// current checkout module. A checkout that happens to be the target
|
|
1113
|
+
// version must not confirm a global install that is stale or never
|
|
1114
|
+
// activated. A pinned prefix is used as-is; only legacy/unpinned
|
|
1115
|
+
// identities re-derive via `npm prefix --global`, never PATH.
|
|
1116
|
+
let globalPrefix = pinnedPrefix;
|
|
1117
|
+
if (globalPrefix === undefined) {
|
|
1118
|
+
const prefixResult = await run("npm", ["prefix", "--global"]);
|
|
1119
|
+
if (prefixResult.code !== 0)
|
|
1120
|
+
return { state: "unknown" };
|
|
1121
|
+
globalPrefix = prefixResult.stdout.trim();
|
|
1122
|
+
if (globalPrefix.length === 0)
|
|
1123
|
+
return { state: "unknown" };
|
|
1124
|
+
}
|
|
1125
|
+
const globalYui = join(globalPrefix, "bin", "yui");
|
|
1126
|
+
const homeEnv = { YUI_HOME: queriedHome };
|
|
1127
|
+
const checked = await run(process.execPath, [globalYui, "--json", "doctor"], undefined, homeEnv);
|
|
1128
|
+
if (checked.code !== 0)
|
|
1129
|
+
return { state: "unknown" };
|
|
1130
|
+
const expectedVersion = step.params?.version;
|
|
1131
|
+
if (expectedVersion !== undefined) {
|
|
1132
|
+
const versioned = await run(process.execPath, [globalYui, "--version"], undefined, homeEnv);
|
|
1133
|
+
if (versioned.code !== 0 || versioned.stdout.trim() !== expectedVersion) {
|
|
1134
|
+
return { state: "unknown" };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// P1-1 (rr22): binary health alone does not prove the Controller
|
|
1138
|
+
// lifecycle handoff completed. Require a current Controller for
|
|
1139
|
+
// this exact Home and, when a version is frozen, that the running
|
|
1140
|
+
// Controller's identity reports the expected version. A hard exit
|
|
1141
|
+
// during the stop/activate/verify/start window leaves no current
|
|
1142
|
+
// Controller (or a wrong-version one), so the query fails closed.
|
|
1143
|
+
const lifecycle = await queryControllerLifecycle(run, globalYui, queriedHome, expectedVersion);
|
|
1144
|
+
if (lifecycle !== "confirmed")
|
|
1145
|
+
return { state: "unknown" };
|
|
1146
|
+
return { state: "exists" };
|
|
1147
|
+
}
|
|
1148
|
+
default:
|
|
1149
|
+
return { state: "unknown" };
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Finds the open PR for a head ref. Prefers `gh pr view --head`; older gh
|
|
1156
|
+
* versions that lack the flag fall back to `gh pr list --head`. A non-zero
|
|
1157
|
+
* exit from both commands is a failed lookup (transport error), not an
|
|
1158
|
+
* authoritative "no PR found". Both paths request the head object id so the
|
|
1159
|
+
* caller can prove the PR names the frozen source commit before reuse.
|
|
1160
|
+
*/
|
|
1161
|
+
/**
|
|
1162
|
+
* Resolves the SHA a remote head branch currently points at, or undefined
|
|
1163
|
+
* when the branch cannot be resolved or the answer is not a 40-hex commit.
|
|
1164
|
+
* Used to prove the proposed PR head is the exact frozen source commit
|
|
1165
|
+
* before creating an external PR.
|
|
1166
|
+
*/
|
|
1167
|
+
async function resolveRemoteHead(run, repo, head) {
|
|
1168
|
+
const slash = repo.indexOf("/");
|
|
1169
|
+
if (slash <= 0)
|
|
1170
|
+
return undefined;
|
|
1171
|
+
const owner = repo.slice(0, slash);
|
|
1172
|
+
const name = repo.slice(slash + 1);
|
|
1173
|
+
const viewed = await run("gh", [
|
|
1174
|
+
"api",
|
|
1175
|
+
`repos/${owner}/${name}/git/ref/heads/${head}`,
|
|
1176
|
+
"--jq", ".object.sha"
|
|
1177
|
+
]);
|
|
1178
|
+
if (viewed.code !== 0)
|
|
1179
|
+
return undefined;
|
|
1180
|
+
const sha = viewed.stdout.trim();
|
|
1181
|
+
return /^[0-9a-f]{40}$/i.test(sha) ? sha : undefined;
|
|
1182
|
+
}
|
|
1183
|
+
async function findHeadPullRequest(run, head, repo) {
|
|
1184
|
+
const viewed = await run("gh", [
|
|
1185
|
+
"pr", "view", "--head", head,
|
|
1186
|
+
"--repo", repo,
|
|
1187
|
+
"--json", "number,headRefOid"
|
|
1188
|
+
]);
|
|
1189
|
+
if (viewed.code === 0) {
|
|
1190
|
+
const found = parsePrHeadEntry(viewed.stdout);
|
|
1191
|
+
if (found !== undefined) {
|
|
1192
|
+
return { status: "found", prNumber: found.prNumber, headSha: found.headSha };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// A non-zero exit from `gh pr view` may mean "no PR for head" (exit 1 with
|
|
1196
|
+
// empty stdout) or a transport failure. The list fallback disambiguates.
|
|
1197
|
+
const listed = await run("gh", [
|
|
1198
|
+
"pr", "list", "--head", head,
|
|
1199
|
+
"--repo", repo,
|
|
1200
|
+
"--state", "open",
|
|
1201
|
+
"--json", "number,headRefOid"
|
|
1202
|
+
]);
|
|
1203
|
+
if (listed.code === 0) {
|
|
1204
|
+
const found = parsePrHeadList(listed.stdout);
|
|
1205
|
+
if (found !== undefined) {
|
|
1206
|
+
return { status: "found", prNumber: found.prNumber, headSha: found.headSha };
|
|
1207
|
+
}
|
|
1208
|
+
// Authoritative empty result: no open PR for this head.
|
|
1209
|
+
return { status: "not-found" };
|
|
1210
|
+
}
|
|
1211
|
+
// Both commands failed: transport error, not "no PR found".
|
|
1212
|
+
return {
|
|
1213
|
+
status: "failed",
|
|
1214
|
+
error: listed.stderr.trim() || viewed.stderr.trim() || "gh PR lookup failed"
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/** Parses `gh pr view --json number,headRefOid` output. */
|
|
1218
|
+
function parsePrHeadEntry(stdout) {
|
|
1219
|
+
try {
|
|
1220
|
+
return prHeadFromJson(JSON.parse(stdout.trim()));
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/** Parses the first entry of `gh pr list --json number,headRefOid` output. */
|
|
1227
|
+
function parsePrHeadList(stdout) {
|
|
1228
|
+
try {
|
|
1229
|
+
const parsed = JSON.parse(stdout.trim());
|
|
1230
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
1231
|
+
return undefined;
|
|
1232
|
+
return prHeadFromJson(parsed[0]);
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function prHeadFromJson(value) {
|
|
1239
|
+
if (typeof value !== "object" || value === null)
|
|
1240
|
+
return undefined;
|
|
1241
|
+
const record = value;
|
|
1242
|
+
if (typeof record.number !== "number" || !Number.isFinite(record.number))
|
|
1243
|
+
return undefined;
|
|
1244
|
+
if (typeof record.headRefOid !== "string" || record.headRefOid.length === 0)
|
|
1245
|
+
return undefined;
|
|
1246
|
+
return { prNumber: String(record.number), headSha: record.headRefOid };
|
|
1247
|
+
}
|
|
1248
|
+
async function mergePullRequest(run, source, number, params) {
|
|
1249
|
+
if (number === undefined) {
|
|
1250
|
+
return { outcome: "failed", error: "merge requires params.pr or a prior PR externalId." };
|
|
1251
|
+
}
|
|
1252
|
+
// P1-4: Restrict the merge method to explicit values. "auto" and "queue"
|
|
1253
|
+
// would let GitHub choose the method, which could produce a merge commit
|
|
1254
|
+
// when the caller expected a rebase or squash.
|
|
1255
|
+
const method = params.method ?? "squash";
|
|
1256
|
+
if (method !== "merge" && method !== "rebase" && method !== "squash") {
|
|
1257
|
+
return {
|
|
1258
|
+
outcome: "failed",
|
|
1259
|
+
error: `merge: method ${method} is not allowed; use merge, rebase, or squash`,
|
|
1260
|
+
logs: ["merge: refusing to merge with an unrestricted method"]
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
// Strip the "pr:" display prefix; the CLI expects a bare number.
|
|
1264
|
+
const prNumber = number.startsWith("pr:") ? number.slice(3) : number;
|
|
1265
|
+
// Only a canonical positive decimal PR number is accepted. Anything else
|
|
1266
|
+
// (flags, URLs, branch names, empty) would be passed to `gh pr merge` and
|
|
1267
|
+
// could inject admin flags or select the wrong PR.
|
|
1268
|
+
if (!/^[1-9][0-9]*$/.test(prNumber)) {
|
|
1269
|
+
return {
|
|
1270
|
+
outcome: "failed",
|
|
1271
|
+
error: `merge: PR number must be a positive integer: ${number}`,
|
|
1272
|
+
logs: ["merge: refusing to merge with an invalid PR selector"]
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const merged = await run("gh", [
|
|
1276
|
+
"pr", "merge", prNumber,
|
|
1277
|
+
"--repo", `${source.repository.owner}/${source.repository.name}`,
|
|
1278
|
+
"--match-head-commit", source.commit,
|
|
1279
|
+
`--${method}`,
|
|
1280
|
+
...(params.admin === "true" ? ["--admin"] : [])
|
|
1281
|
+
]);
|
|
1282
|
+
if (merged.code !== 0) {
|
|
1283
|
+
return { outcome: "failed", error: merged.stderr.trim() || "gh pr merge failed" };
|
|
1284
|
+
}
|
|
1285
|
+
return { outcome: "succeeded", externalId: `merge:${prNumber}`, logs: [`merge: PR #${prNumber}`] };
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* A publish that failed after the upload (a transport error) is ambiguous:
|
|
1289
|
+
* the registry may have accepted the package. Such a failure carries an
|
|
1290
|
+
* externalIdentity so the engine queries the registry on resume instead of
|
|
1291
|
+
* re-publishing. A pre-upload failure (auth, bad tarball) is definite.
|
|
1292
|
+
*/
|
|
1293
|
+
function isTransportFailure(stderr) {
|
|
1294
|
+
return /timeout|timed out|network|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|socket/i.test(stderr);
|
|
1295
|
+
}
|
|
1296
|
+
/** The scoped package name for a source repository: @owner/name. */
|
|
1297
|
+
function derivePackageName(source) {
|
|
1298
|
+
return `@${source.repository.owner}/${source.repository.name}`;
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* The queryable identity for a succeeded effect whose idempotency record could
|
|
1302
|
+
* not be persisted. The engine re-queries by this identity on resume instead
|
|
1303
|
+
* of re-submitting the effect. Only kinds with a `queryStepEffect` case can
|
|
1304
|
+
* be re-queried; the rest return undefined and the caller fails closed
|
|
1305
|
+
* without an identity (the engine then treats the step as failed rather than
|
|
1306
|
+
* re-running an unrecorded success).
|
|
1307
|
+
*/
|
|
1308
|
+
async function queryIdentityFor(step, params, effect, source, home, run) {
|
|
1309
|
+
switch (step.kind) {
|
|
1310
|
+
case "cli-update":
|
|
1311
|
+
// queryStepEffect verifies the installed version against step.params.
|
|
1312
|
+
// P1-2 (rr22): pin the resolved global prefix so a resume query checks
|
|
1313
|
+
// the same activation target instead of re-deriving it from the
|
|
1314
|
+
// caller's npm/PATH environment.
|
|
1315
|
+
return await controllerHomeIdentity(run, home);
|
|
1316
|
+
case "version-tag": {
|
|
1317
|
+
const tag = effect.externalId?.replace(/^tag:/, "") ?? params.tag;
|
|
1318
|
+
return tag === undefined || tag.length === 0
|
|
1319
|
+
? undefined
|
|
1320
|
+
: { kind: "git-tag", value: tag };
|
|
1321
|
+
}
|
|
1322
|
+
case "npm-publish": {
|
|
1323
|
+
const version = effect.externalId;
|
|
1324
|
+
if (version === undefined || version.length === 0)
|
|
1325
|
+
return undefined;
|
|
1326
|
+
const pkg = params.package ?? derivePackageName(source);
|
|
1327
|
+
return { kind: "npm-package", value: `${pkg}@${version}` };
|
|
1328
|
+
}
|
|
1329
|
+
case "pr-create-or-reuse": {
|
|
1330
|
+
const pr = effect.externalId?.replace(/^pr:/, "");
|
|
1331
|
+
return pr === undefined || pr.length === 0
|
|
1332
|
+
? undefined
|
|
1333
|
+
: { kind: "pull-request", value: pr };
|
|
1334
|
+
}
|
|
1335
|
+
default:
|
|
1336
|
+
return undefined;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Derive an authoritative query identity from the frozen step plan when no
|
|
1341
|
+
* identity was recorded (a hard exit between the external effect and the
|
|
1342
|
+
* idempotency/identity persistence). Only kinds whose identity is fully
|
|
1343
|
+
* determined by the immutable plan + source can be recovered this way.
|
|
1344
|
+
*/
|
|
1345
|
+
function deriveIdentityFromPlan(step, source) {
|
|
1346
|
+
switch (step.kind) {
|
|
1347
|
+
case "version-tag": {
|
|
1348
|
+
const tag = step.params?.tag;
|
|
1349
|
+
if (tag === undefined || tag.length === 0)
|
|
1350
|
+
return undefined;
|
|
1351
|
+
return { kind: "git-tag", value: tag };
|
|
1352
|
+
}
|
|
1353
|
+
default:
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const CANONICAL_REMOTE_HOST = "github.com";
|
|
1358
|
+
/**
|
|
1359
|
+
* Whether a git remote URL names the granted repository on the canonical host.
|
|
1360
|
+
* Accepts the common spellings (https://github.com/owner/name(.git),
|
|
1361
|
+
* ssh://git@github.com/owner/name(.git), git@github.com:owner/name(.git)) and
|
|
1362
|
+
* compares the host, owner, and name case-insensitively, matching GitHub's
|
|
1363
|
+
* repository identity. A URL on any other host is rejected even when its
|
|
1364
|
+
* owner/name path matches.
|
|
1365
|
+
*/
|
|
1366
|
+
function remoteMatchesRepository(remoteUrl, repository) {
|
|
1367
|
+
const trimmed = remoteUrl.trim();
|
|
1368
|
+
if (trimmed.length === 0)
|
|
1369
|
+
return false;
|
|
1370
|
+
let rest = trimmed;
|
|
1371
|
+
let host;
|
|
1372
|
+
const schemeIndex = rest.indexOf("://");
|
|
1373
|
+
if (schemeIndex !== -1) {
|
|
1374
|
+
// scheme://[user@]host[:port]/path
|
|
1375
|
+
rest = rest.slice(schemeIndex + 3);
|
|
1376
|
+
const slashIndex = rest.indexOf("/");
|
|
1377
|
+
if (slashIndex === -1)
|
|
1378
|
+
return false;
|
|
1379
|
+
host = rest.slice(0, slashIndex);
|
|
1380
|
+
rest = rest.slice(slashIndex + 1);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
// scp-like: [user@]host:path
|
|
1384
|
+
const colonIndex = rest.indexOf(":");
|
|
1385
|
+
if (colonIndex === -1)
|
|
1386
|
+
return false;
|
|
1387
|
+
host = rest.slice(0, colonIndex);
|
|
1388
|
+
rest = rest.slice(colonIndex + 1);
|
|
1389
|
+
}
|
|
1390
|
+
// Strip userinfo and port from the authority before comparing.
|
|
1391
|
+
const canonicalHost = host.split("@").pop().split(":")[0].trim().toLowerCase();
|
|
1392
|
+
if (canonicalHost !== CANONICAL_REMOTE_HOST)
|
|
1393
|
+
return false;
|
|
1394
|
+
rest = rest.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1395
|
+
const segments = rest.split("/").filter((segment) => segment.length > 0);
|
|
1396
|
+
if (segments.length < 2)
|
|
1397
|
+
return false;
|
|
1398
|
+
const owner = segments[segments.length - 2];
|
|
1399
|
+
const name = segments[segments.length - 1];
|
|
1400
|
+
return owner.toLowerCase() === repository.owner.toLowerCase()
|
|
1401
|
+
&& name.toLowerCase() === repository.name.toLowerCase();
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Reads package/package.json out of an npm tarball via the injected command
|
|
1405
|
+
* runner. Returns undefined when the tarball cannot be read or the manifest
|
|
1406
|
+
* is not a package.json with a non-empty name and version, so the caller can
|
|
1407
|
+
* fail closed without publishing an unverified artifact.
|
|
1408
|
+
*/
|
|
1409
|
+
async function readTarballManifest(run, tarball) {
|
|
1410
|
+
const extracted = await run("tar", ["-xOf", tarball, "package/package.json"]);
|
|
1411
|
+
if (extracted.code !== 0)
|
|
1412
|
+
return undefined;
|
|
1413
|
+
try {
|
|
1414
|
+
const manifest = JSON.parse(extracted.stdout);
|
|
1415
|
+
if (typeof manifest.name !== "string" || manifest.name.length === 0)
|
|
1416
|
+
return undefined;
|
|
1417
|
+
if (typeof manifest.version !== "string" || manifest.version.length === 0)
|
|
1418
|
+
return undefined;
|
|
1419
|
+
const publishConfig = isRecord(manifest.publishConfig)
|
|
1420
|
+
&& typeof manifest.publishConfig.registry === "string"
|
|
1421
|
+
&& manifest.publishConfig.registry.trim().length > 0
|
|
1422
|
+
? { registry: manifest.publishConfig.registry.trim() }
|
|
1423
|
+
: undefined;
|
|
1424
|
+
return {
|
|
1425
|
+
name: manifest.name,
|
|
1426
|
+
version: manifest.version,
|
|
1427
|
+
...(publishConfig === undefined ? {} : { publishConfig })
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
return undefined;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Parses the `npm publish` result line ("+ name@version") and returns the
|
|
1436
|
+
* published id. Takes the last such line (the publish summary); undefined
|
|
1437
|
+
* when the output carries no parseable confirmation.
|
|
1438
|
+
*/
|
|
1439
|
+
function parsePublishConfirmation(stdout) {
|
|
1440
|
+
const lines = stdout.split(/\r?\n/);
|
|
1441
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1442
|
+
const line = lines[index].trim();
|
|
1443
|
+
if (!line.startsWith("+"))
|
|
1444
|
+
continue;
|
|
1445
|
+
const body = line.slice(1).trim();
|
|
1446
|
+
const at = body.lastIndexOf("@");
|
|
1447
|
+
if (at <= 0)
|
|
1448
|
+
continue;
|
|
1449
|
+
const name = body.slice(0, at);
|
|
1450
|
+
const version = body.slice(at + 1);
|
|
1451
|
+
if (name.length === 0 || version.length === 0 || /\s/.test(version))
|
|
1452
|
+
continue;
|
|
1453
|
+
return { name, version };
|
|
1454
|
+
}
|
|
1455
|
+
return undefined;
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Resolve the global npm prefix the same way the update port activates it
|
|
1459
|
+
* (`npm prefix --global` + `bin/yui`). Returns undefined when the prefix
|
|
1460
|
+
* cannot be resolved, callers must fail closed rather than persist a Home-only
|
|
1461
|
+
* identity that would be re-derived from a different environment.
|
|
1462
|
+
*/
|
|
1463
|
+
async function resolveGlobalPrefix(run) {
|
|
1464
|
+
const result = await run("npm", ["prefix", "--global"]);
|
|
1465
|
+
if (result.code !== 0)
|
|
1466
|
+
return undefined;
|
|
1467
|
+
const prefix = result.stdout.trim();
|
|
1468
|
+
return prefix.length > 0 ? prefix : undefined;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* The controller-home query identity for a cli-update effect. The value is a
|
|
1472
|
+
* JSON envelope carrying the Home and — when resolvable — the exact global
|
|
1473
|
+
* prefix that was activated, so a resume query checks the same installation
|
|
1474
|
+
* instead of re-deriving the target from the caller's npm/PATH environment
|
|
1475
|
+
* (P1-2, rr22). Legacy identities are bare Home path strings; the query
|
|
1476
|
+
* accepts both shapes via {@link parseControllerHomeIdentity}.
|
|
1477
|
+
*/
|
|
1478
|
+
async function controllerHomeIdentity(run, home) {
|
|
1479
|
+
const globalPrefix = await resolveGlobalPrefix(run);
|
|
1480
|
+
if (globalPrefix === undefined)
|
|
1481
|
+
return undefined;
|
|
1482
|
+
return {
|
|
1483
|
+
kind: "controller-home",
|
|
1484
|
+
value: JSON.stringify({
|
|
1485
|
+
home,
|
|
1486
|
+
globalPrefix
|
|
1487
|
+
})
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* The durable pre-effect record path for a cli-update attempt: the exact
|
|
1492
|
+
* activation target (Home + global npm prefix) keyed by the step's
|
|
1493
|
+
* idempotency key. Written before the irreversible effect and never deleted,
|
|
1494
|
+
* so a hard-exit recovery query can re-query the same installation (P1-2,
|
|
1495
|
+
* rr23).
|
|
1496
|
+
*/
|
|
1497
|
+
function cliUpdateIdentityPath(home, idempotencyKey) {
|
|
1498
|
+
return join(home, "release", "cli-update-identity", `${idempotencyKey}.json`);
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Persist a cli-update's exact activation target BEFORE the irreversible
|
|
1502
|
+
* effect. A hard exit between this write and the engine's identity persistence
|
|
1503
|
+
* still leaves a durable receipt recovery can read. Best-effort by contract:
|
|
1504
|
+
* callers wrap this in try/catch and proceed with the update regardless.
|
|
1505
|
+
*/
|
|
1506
|
+
async function persistCliUpdateIdentity(run, home, idempotencyKey) {
|
|
1507
|
+
const identity = await controllerHomeIdentity(run, home);
|
|
1508
|
+
if (identity === undefined)
|
|
1509
|
+
return;
|
|
1510
|
+
const target = cliUpdateIdentityPath(home, idempotencyKey);
|
|
1511
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1512
|
+
// Write to a temp file in the same directory and rename: the rename is
|
|
1513
|
+
// atomic on the same filesystem, so a crash never leaves a torn record.
|
|
1514
|
+
const temp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
|
|
1515
|
+
await writeFile(temp, identity.value, { flag: "wx" });
|
|
1516
|
+
await rename(temp, target);
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Read the durable pre-effect activation target for a hard-exit cli-update
|
|
1520
|
+
* recovery query. Returns undefined when the record is absent or malformed;
|
|
1521
|
+
* the caller then fails closed rather than deriving the target from the resume
|
|
1522
|
+
* caller's environment (P1-2, rr23).
|
|
1523
|
+
*/
|
|
1524
|
+
async function readPersistedCliUpdateIdentity(home, idempotencyKey) {
|
|
1525
|
+
let raw;
|
|
1526
|
+
try {
|
|
1527
|
+
raw = await readFile(cliUpdateIdentityPath(home, idempotencyKey), "utf8");
|
|
1528
|
+
}
|
|
1529
|
+
catch {
|
|
1530
|
+
return undefined;
|
|
1531
|
+
}
|
|
1532
|
+
const parsed = parseControllerHomeIdentity(raw);
|
|
1533
|
+
if (parsed === undefined || parsed.globalPrefix === undefined)
|
|
1534
|
+
return undefined;
|
|
1535
|
+
return { kind: "controller-home", value: JSON.stringify(parsed) };
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* The durable pre-effect record path for an npm-publish attempt: the exact
|
|
1539
|
+
* npm executable and registry the publish ran against, keyed by the step's
|
|
1540
|
+
* idempotency key. Written before the irreversible effect and never deleted,
|
|
1541
|
+
* so a resume recovery query re-queries the same registry through the same
|
|
1542
|
+
* npm binary instead of trusting the resume caller's environment (P1, rr24).
|
|
1543
|
+
*/
|
|
1544
|
+
function npmPublishTargetPath(home, idempotencyKey) {
|
|
1545
|
+
return join(home, "release", "npm-publish-target", `${idempotencyKey}.json`);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Persist an npm-publish's exact executable and registry BEFORE the
|
|
1549
|
+
* irreversible effect. A hard exit between this write and the engine's
|
|
1550
|
+
* identity persistence still leaves a durable receipt recovery can read.
|
|
1551
|
+
*/
|
|
1552
|
+
async function persistNpmPublishTarget(home, idempotencyKey, targetValue) {
|
|
1553
|
+
const target = npmPublishTargetPath(home, idempotencyKey);
|
|
1554
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1555
|
+
// Write to a temp file in the same directory and rename: the rename is
|
|
1556
|
+
// atomic on the same filesystem, so a crash never leaves a torn record.
|
|
1557
|
+
const temp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
|
|
1558
|
+
await writeFile(temp, JSON.stringify(targetValue), { flag: "wx" });
|
|
1559
|
+
await rename(temp, target);
|
|
1560
|
+
}
|
|
1561
|
+
/** Resolve the registry npm publish will use for the verified package. */
|
|
1562
|
+
async function resolveNpmPublishRegistry(run, packageName, publishConfigRegistry) {
|
|
1563
|
+
if (publishConfigRegistry !== undefined && publishConfigRegistry.trim().length > 0) {
|
|
1564
|
+
return publishConfigRegistry.trim();
|
|
1565
|
+
}
|
|
1566
|
+
const scope = packageName.startsWith("@")
|
|
1567
|
+
? packageName.slice(0, packageName.indexOf("/"))
|
|
1568
|
+
: undefined;
|
|
1569
|
+
if (scope !== undefined) {
|
|
1570
|
+
const scoped = await run("npm", ["config", "get", `${scope}:registry`]);
|
|
1571
|
+
const scopedRegistry = normalizeNpmRegistry(scoped.stdout, scoped.code);
|
|
1572
|
+
if (scopedRegistry !== undefined)
|
|
1573
|
+
return scopedRegistry;
|
|
1574
|
+
}
|
|
1575
|
+
const defaultRegistry = await run("npm", ["config", "get", "registry"]);
|
|
1576
|
+
return normalizeNpmRegistry(defaultRegistry.stdout, defaultRegistry.code);
|
|
1577
|
+
}
|
|
1578
|
+
function normalizeNpmRegistry(stdout, code) {
|
|
1579
|
+
if (code !== 0)
|
|
1580
|
+
return undefined;
|
|
1581
|
+
const value = stdout.trim();
|
|
1582
|
+
return value.length === 0 || value === "undefined" ? undefined : value;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Read the durable pre-effect npm executable and registry for an
|
|
1586
|
+
* npm-publish recovery query. Returns undefined when the record is absent
|
|
1587
|
+
* or malformed; the caller then fails closed rather than querying the
|
|
1588
|
+
* resume caller's npm/registry (P1, rr24).
|
|
1589
|
+
*/
|
|
1590
|
+
async function readPersistedNpmPublishTarget(home, idempotencyKey) {
|
|
1591
|
+
let raw;
|
|
1592
|
+
try {
|
|
1593
|
+
raw = await readFile(npmPublishTargetPath(home, idempotencyKey), "utf8");
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
return undefined;
|
|
1597
|
+
}
|
|
1598
|
+
try {
|
|
1599
|
+
const parsed = JSON.parse(raw);
|
|
1600
|
+
if (!isRecord(parsed)
|
|
1601
|
+
|| typeof parsed.npmPath !== "string" || parsed.npmPath.length === 0
|
|
1602
|
+
|| typeof parsed.registry !== "string" || parsed.registry.length === 0) {
|
|
1603
|
+
return undefined;
|
|
1604
|
+
}
|
|
1605
|
+
return { npmPath: parsed.npmPath, registry: parsed.registry };
|
|
1606
|
+
}
|
|
1607
|
+
catch {
|
|
1608
|
+
return undefined;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Parse a controller-home identity value. Accepts the JSON envelope
|
|
1613
|
+
* {home, globalPrefix?} written by {@link controllerHomeIdentity}; a legacy
|
|
1614
|
+
* bare Home path string (or any unparseable value) yields undefined so the
|
|
1615
|
+
* caller treats the raw value as the Home.
|
|
1616
|
+
*/
|
|
1617
|
+
function parseControllerHomeIdentity(value) {
|
|
1618
|
+
try {
|
|
1619
|
+
const parsed = JSON.parse(value);
|
|
1620
|
+
if (!isRecord(parsed) || typeof parsed.home !== "string" || parsed.home.length === 0) {
|
|
1621
|
+
return undefined;
|
|
1622
|
+
}
|
|
1623
|
+
return {
|
|
1624
|
+
home: parsed.home,
|
|
1625
|
+
...(typeof parsed.globalPrefix === "string" && parsed.globalPrefix.length > 0
|
|
1626
|
+
? { globalPrefix: parsed.globalPrefix }
|
|
1627
|
+
: {})
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
return undefined;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Verify the replacement Controller's lifecycle identity for a controller-
|
|
1636
|
+
* home recovery query (P1-1, rr22/rr23). Binary health (`doctor`/`version`)
|
|
1637
|
+
* alone does not prove the Controller handoff completed: a hard exit during
|
|
1638
|
+
* the stop/activate/verify/start window can leave the global binary healthy
|
|
1639
|
+
* with no current Controller (or a wrong-version one). This requires a
|
|
1640
|
+
* `current` Controller resource for the exact Home and, when a version is
|
|
1641
|
+
* frozen, that the running Controller's authenticated identity matches the
|
|
1642
|
+
* activated artifact on ALL THREE production startup checks
|
|
1643
|
+
* ({@link assertActivatedControllerIdentity}): the Node.js executable path,
|
|
1644
|
+
* the exact Controller entrypoint derived from the pinned global binary, and
|
|
1645
|
+
* the package version. A same-version Controller launched from a foreign
|
|
1646
|
+
* installation — or a malformed identity — fails closed. Anything unprovable
|
|
1647
|
+
* returns "unknown" so the step is never confirmed.
|
|
1648
|
+
*/
|
|
1649
|
+
async function queryControllerLifecycle(run, globalYui, home, expectedVersion) {
|
|
1650
|
+
const homeEnv = { YUI_HOME: home };
|
|
1651
|
+
const status = await run(process.execPath, [globalYui, "--json", "controller", "status"], undefined, homeEnv);
|
|
1652
|
+
if (status.code !== 0)
|
|
1653
|
+
return "unknown";
|
|
1654
|
+
let envelope;
|
|
1655
|
+
try {
|
|
1656
|
+
envelope = JSON.parse(status.stdout);
|
|
1657
|
+
}
|
|
1658
|
+
catch {
|
|
1659
|
+
return "unknown";
|
|
1660
|
+
}
|
|
1661
|
+
if (!isRecord(envelope) || envelope.ok !== true || !isRecord(envelope.data))
|
|
1662
|
+
return "unknown";
|
|
1663
|
+
const resources = envelope.data.resources;
|
|
1664
|
+
if (!Array.isArray(resources))
|
|
1665
|
+
return "unknown";
|
|
1666
|
+
const resolvedHome = resolve(home);
|
|
1667
|
+
const current = resources.find((resource) => (isRecord(resource)
|
|
1668
|
+
&& resource.kind === "controller"
|
|
1669
|
+
&& resource.state === "current"
|
|
1670
|
+
&& typeof resource.yuiHome === "string"
|
|
1671
|
+
&& resolve(resource.yuiHome) === resolvedHome));
|
|
1672
|
+
if (current === undefined)
|
|
1673
|
+
return "unknown";
|
|
1674
|
+
if (expectedVersion !== undefined) {
|
|
1675
|
+
const identity = await run(process.execPath, [globalYui, "--json", "controller", "identity"], undefined, homeEnv);
|
|
1676
|
+
if (identity.code !== 0)
|
|
1677
|
+
return "unknown";
|
|
1678
|
+
let identityEnvelope;
|
|
1679
|
+
try {
|
|
1680
|
+
identityEnvelope = JSON.parse(identity.stdout);
|
|
1681
|
+
}
|
|
1682
|
+
catch {
|
|
1683
|
+
return "unknown";
|
|
1684
|
+
}
|
|
1685
|
+
if (!isRecord(identityEnvelope) || identityEnvelope.ok !== true || !isRecord(identityEnvelope.data)) {
|
|
1686
|
+
return "unknown";
|
|
1687
|
+
}
|
|
1688
|
+
const data = identityEnvelope.data;
|
|
1689
|
+
if (data.version !== expectedVersion)
|
|
1690
|
+
return "unknown";
|
|
1691
|
+
if (typeof data.executablePath !== "string" || data.executablePath !== process.execPath) {
|
|
1692
|
+
return "unknown";
|
|
1693
|
+
}
|
|
1694
|
+
if (!Array.isArray(data.args)
|
|
1695
|
+
|| data.args.length !== 1
|
|
1696
|
+
|| typeof data.args[0] !== "string"
|
|
1697
|
+
|| data.args[0] !== activatedControllerEntrypoint(globalYui)) {
|
|
1698
|
+
return "unknown";
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return "confirmed";
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Write the verified tarball bytes to a workflow-private temp file with a
|
|
1705
|
+
* random name and read-only permissions (P1-3, rr22). The manifest
|
|
1706
|
+
* inspection and `npm publish` both read this snapshot, so replacing the
|
|
1707
|
+
* original path after the integrity check cannot change what is published.
|
|
1708
|
+
* The caller removes the snapshot once publish completes.
|
|
1709
|
+
*/
|
|
1710
|
+
async function writeVerifiedTarballSnapshot(bytes) {
|
|
1711
|
+
const snapshot = join(tmpdir(), `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
|
|
1712
|
+
// "wx" fails if the random name already exists, so a pre-existing file
|
|
1713
|
+
// (or symlink) can never be overwritten or followed.
|
|
1714
|
+
await writeFile(snapshot, bytes, { flag: "wx" });
|
|
1715
|
+
await chmod(snapshot, 0o400);
|
|
1716
|
+
return snapshot;
|
|
1717
|
+
}
|
|
1718
|
+
function isRecord(value) {
|
|
1719
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1720
|
+
}
|