@casecore/release-agent 0.1.4 → 0.1.7

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 CHANGED
@@ -13,7 +13,7 @@
13
13
  ## Подключение
14
14
 
15
15
  ```sh
16
- pnpm add --save-exact @casecore/release-agent@0.1.0
16
+ pnpm add --save-exact @casecore/release-agent@0.1.7
17
17
  ```
18
18
 
19
19
  В проекте фиксируются точная версия и `pnpm-lock.yaml`. Поддерживается Node.js
@@ -281,3 +281,54 @@ RELEASE_AGENT_TARBALL=/absolute/path/casecore-release-agent-0.1.0.tgz \
281
281
 
282
282
  Запуск проверок и сборок должен учитывать действующее разрешение владельца задачи;
283
283
  наличие команд и тестов не является подтверждением их успешного выполнения.
284
+
285
+ ## Завершение исходной попытки публикации
286
+
287
+ Начиная с 0.1.6 адаптер может реализовать `publicationTerminal(command, context)`.
288
+ Движок вызывает его после сохранения подтверждённого результата `deployed` либо
289
+ штатного `failed` до установки. Адаптер закрывает свой исходный журнал под своей
290
+ блокировкой; общий движок и исходные часы выполнения остаются единственными.
291
+
292
+ `readPublicationTerminalAuthority(context.authority, context.bindings)` возвращает
293
+ подтверждённый результат с точной исходной командой, исполнителем, кандидатом и
294
+ исходными часами. Поле `artifactIdentity` отдельно хранит подписанную идентичность
295
+ артефакта: её SHA может отличаться от SHA исходных изменений. Authority действует
296
+ только внутри ограниченного 30 секундами callback; скопированный объект, snapshot
297
+ или digest полномочия не дают. Перед записью адаптер проверяет `context.signal`.
298
+
299
+ Ошибка callback сохраняет фактический результат установки и записывается в
300
+ диагностику; она не подтверждает закрытие исходного журнала. Повторный
301
+ `reconcile(commandId)` и восстановление после перезапуска получают новое
302
+ полномочие только после свежей независимой проверки production либо штатного
303
+ восстановления FAILED с native ACK. Запись адаптера должна быть идемпотентной для
304
+ той же исходной попытки. Публикация 0.1.6 и обновление установок проверяются отдельно
305
+ от локальной готовности этого исходного контракта.
306
+
307
+ Для первой установки уже собранного артефакта тот же callback вызывается с
308
+ `bindings.action: 'deploy'`. Его `repository` и `ref` берутся из сохранённого
309
+ артефактного кандидата; исходные шесть полей `delivery` остаются неизменными.
310
+ Адаптер обязан реализовать `verifyArtifactDelivery(command, identity, delivery,
311
+ source, context)` и подтвердить подпись, исходную операцию и происхождение
312
+ артефакта. `context.completedAt` — сохранённое время завершения установки.
313
+ Повторное подтверждение после истечения срока проверяет этот результат и свежий
314
+ production readback; новую установку или продление срока оно не разрешает.
315
+
316
+ `await sealPublicationTerminalReceipt(authority, bindings, managementToken)`
317
+ сохраняет заверенный результат в существующем журнале движка перед возвратом.
318
+ `verifyPublicationTerminalReceipt(receipt, managementToken)` проверяет HMAC и
319
+ возвращает данные, которые не являются authority. Токен управления уже должен
320
+ быть настроен у обоих участников; передавать его с результатом нельзя.
321
+ Авторизованный `GET /v1/release-control/publications/:commandId/terminal` читает
322
+ сохранённый результат без нового workflow claim или запуска. Для результата,
323
+ принятого через существующий report, адаптер может предоставить
324
+ `readTerminalReceipt(commandId)`. Неизвестный результат возвращает 404;
325
+ неподтверждённые ошибки установки не получают такой результат.
326
+
327
+ Отказ до push использует тот же guarded recovery с `kind: failed_before_source_push`,
328
+ `stage: source_ready` и `sourceExecution` вместо GitHub `delivery`. Исходная sealed
329
+ попытка имеет `schema: 1`, `kind: source_not_pushed`, `id`, `candidateId`,
330
+ `sourceCommit`, `requestedAt`, `deadlineAt`, `policy: immediate`. Обязательный
331
+ `verifyPublicationSourceFailure` подтверждает эту границу под удерживаемым guard.
332
+ Любой bound delivery, артефакт или начатая следующая стадия отказывают. ACK сохраняет
333
+ предыдущую рабочую установку и снимает блокировку без создания GitHub-статуса или
334
+ publication-terminal receipt. Истёкший срок не продлевается.
@@ -18,6 +18,9 @@ function metadata(producer) {
18
18
  assert.ok(state, 'DELIVERY_OPERATION_BARRIER_REQUIRED');
19
19
  return state;
20
20
  }
21
+ export function assertProducerOperation(producer) {
22
+ assert.ok(metadata(producer).activity > 0, 'DELIVERY_OPERATION_AUTHORITY_REQUIRED');
23
+ }
21
24
  function pendingFailures(producer, state) {
22
25
  return [
23
26
  ...Object.values(producer.state.commands),
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './pipeline.mjs';
2
2
  export * from './source-store.mjs';
3
3
  export * from './producer.mjs';
4
- export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
4
+ export { publicationFailureProofDigest, validateSourceRecoveryExecution } from './producer-publication-failure.mjs';
5
5
  export { createProtectedJsonStore } from './journal.mjs';
@@ -151,6 +151,9 @@ export class ProducerAdmission extends ProducerState {
151
151
  command: structuredClone(input),
152
152
  baseline: structuredClone(current.installed),
153
153
  ...(publication && candidate ? { sourceCandidate: structuredClone(candidate) } : {}),
154
+ ...(!publication && input.action === 'deploy' && candidate?.deliveryExecution
155
+ ? { artifactCandidate: structuredClone(candidate) }
156
+ : {}),
154
157
  ...(candidate?.deliveryExecution ? { deliveryExecution: candidate.deliveryExecution } : {}),
155
158
  ack,
156
159
  };
@@ -6,7 +6,7 @@ export async function verifyPublicationDelivery(
6
6
  producer,
7
7
  command,
8
8
  delivery,
9
- { fresh = true } = {}
9
+ { fresh = true, verification } = {}
10
10
  ) {
11
11
  assert.equal(command.action, 'publish');
12
12
  validateOriginalDelivery(delivery, command.expectedSource.sourceCommit);
@@ -19,7 +19,7 @@ export async function verifyPublicationDelivery(
19
19
  assert.deepEqual(item.deliveryExecution, delivery, 'Original published delivery changed');
20
20
  if (typeof producer.provider.verifyPublicationDelivery === 'function')
21
21
  assert.equal(
22
- await producer.provider.verifyPublicationDelivery(command, delivery),
22
+ await producer.provider.verifyPublicationDelivery(command, delivery, verification),
23
23
  true,
24
24
  'Original delivery correlation was not verified'
25
25
  );
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { notifyPublicationTerminal } from './publication-terminal.mjs';
2
3
  import { canonical } from './protocol-internals.mjs';
3
4
  import { sameIdentity, acceptedProduction } from './protocol.mjs';
4
5
  import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
@@ -6,10 +7,7 @@ import { ProducerPublication } from './producer-publication.mjs';
6
7
 
7
8
  export class ProducerExecution extends ProducerPublication {
8
9
  async qualifyForwardRecovery(run, command) {
9
- if (
10
- command.action === 'publish' &&
11
- !this.state.commands[command.commandId]?.artifactIdentity
12
- )
10
+ if (command.action === 'publish' && !this.state.commands[command.commandId]?.artifactIdentity)
13
11
  return;
14
12
  command = this.resolvedCommand(command);
15
13
  if (
@@ -49,33 +47,27 @@ export class ProducerExecution extends ProducerPublication {
49
47
  run.committedReadback = fact;
50
48
  run.completedAt ??= this.now().toISOString();
51
49
  for (const row of run.stages.filter((s) =>
52
- ['running', 'unverified'].includes(s.status),
50
+ ['running', 'unverified'].includes(s.status)
53
51
  )) {
54
52
  row.status = 'failed';
55
53
  row.completedAt ??= run.completedAt;
56
54
  row.elapsedMs = row.startedAt
57
- ? Math.max(
58
- 0,
59
- Date.parse(row.completedAt) - Date.parse(row.startedAt),
60
- )
55
+ ? Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt))
61
56
  : null;
62
- row.failure = blocker(
63
- 'INTERRUPTED_PROOF',
64
- 'Подтверждение результата было прервано',
65
- );
57
+ row.failure = blocker('INTERRUPTED_PROOF', 'Подтверждение результата было прервано');
66
58
  }
67
59
  run.blockers.push(
68
60
  blocker(
69
61
  'FORWARD_CORRECTION_REQUIRED',
70
- 'Установка завершена, приёмка не пройдена; требуется исправляющий релиз',
71
- ),
62
+ 'Установка завершена, приёмка не пройдена; требуется исправляющий релиз'
63
+ )
72
64
  );
73
65
  this.event(
74
66
  run.id,
75
67
  null,
76
68
  'failure',
77
69
  'Разрешён исправляющий релиз; исходная выкатка завершилась ошибкой',
78
- 'error',
70
+ 'error'
79
71
  );
80
72
  }
81
73
  } catch (qualificationError) {
@@ -87,13 +79,12 @@ export class ProducerExecution extends ProducerPublication {
87
79
  }
88
80
  async execute(command, { activated = false } = {}) {
89
81
  const run = this.state.runs.find((r) => r.commandId === command.commandId);
90
- const remaining = () =>
91
- Date.parse(command.deadlineAt) - this.now().getTime();
82
+ const remaining = () => Date.parse(command.deadlineAt) - this.now().getTime();
92
83
  let suspended = false;
93
84
  const stage = async (
94
85
  id,
95
86
  operation,
96
- { inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {},
87
+ { inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {}
97
88
  ) => {
98
89
  assert.ok(remaining() > 0, 'Original deadline exceeded');
99
90
  if (id === 'deploy') run.recoveryState = 'unverified';
@@ -103,8 +94,7 @@ export class ProducerExecution extends ProducerPublication {
103
94
  command.action === 'publish' &&
104
95
  this.provider.remotePublication === true &&
105
96
  row.startedAt !== null;
106
- if (!retained || !['passed', 'reused'].includes(row.status))
107
- row.status = 'running';
97
+ if (!retained || !['passed', 'reused'].includes(row.status)) row.status = 'running';
108
98
  if (!retained) row.startedAt = this.now().toISOString();
109
99
  if (!retained) row.inputsDigest = inputsDigest;
110
100
  if (!retained)
@@ -149,10 +139,7 @@ export class ProducerExecution extends ProducerPublication {
149
139
  } finally {
150
140
  if (!suspended) {
151
141
  row.completedAt = this.now().toISOString();
152
- row.elapsedMs = Math.max(
153
- 0,
154
- Date.parse(row.completedAt) - Date.parse(row.startedAt),
155
- );
142
+ row.elapsedMs = Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt));
156
143
  this.event(run.id, id, 'stage', `${row.title}: ${row.status}`);
157
144
  }
158
145
  await this.save();
@@ -160,21 +147,15 @@ export class ProducerExecution extends ProducerPublication {
160
147
  };
161
148
  try {
162
149
  if (command.lane === 'scheduled' && !activated) {
163
- assert.ok(
164
- this.provider.announce,
165
- 'Scheduled notice transport is not configured',
166
- );
150
+ assert.ok(this.provider.announce, 'Scheduled notice transport is not configured');
167
151
  await this.provider.announce(command);
168
152
  run.noticeAt = this.now().toISOString();
169
- if (
170
- Date.parse(command.activationAt) <
171
- Date.parse(run.noticeAt) + 86400000
172
- ) {
153
+ if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
173
154
  run.status = 'blocked';
174
155
  run.blockers = [
175
156
  blocker(
176
157
  'NOTICE_WINDOW_TOO_SHORT',
177
- 'От фактического уведомления до установки должно пройти минимум 24 часа',
158
+ 'От фактического уведомления до установки должно пройти минимум 24 часа'
178
159
  ),
179
160
  ];
180
161
  await this.save();
@@ -184,11 +165,7 @@ export class ProducerExecution extends ProducerPublication {
184
165
  // Scheduling is durable; reconciliation resumes after the activation time.
185
166
  run.status = 'queued';
186
167
  run.blockers = [
187
- blocker(
188
- 'SCHEDULED_ACTIVATION',
189
- 'Ожидается согласованное окно установки',
190
- true,
191
- ),
168
+ blocker('SCHEDULED_ACTIVATION', 'Ожидается согласованное окно установки', true),
192
169
  ];
193
170
  await this.save();
194
171
  return;
@@ -202,27 +179,23 @@ export class ProducerExecution extends ProducerPublication {
202
179
  admitted?.ack.state === 'accepted' &&
203
180
  canonical(admitted.command) === canonical(command) &&
204
181
  Number.isFinite(Date.parse(run.noticeAt)) &&
205
- Date.parse(command.activationAt) >=
206
- Date.parse(run.noticeAt) + 86400000 &&
182
+ Date.parse(command.activationAt) >= Date.parse(run.noticeAt) + 86400000 &&
207
183
  Date.parse(command.activationAt) <= this.now().getTime();
208
184
  await this.provider.execute(command, {
209
185
  stage,
210
186
  remainingMs: remaining,
211
187
  admittedScheduled,
212
- bindDelivery: (delivery) =>
213
- this.bindPublicationDelivery(command, delivery),
188
+ bindDelivery: (delivery) => this.bindPublicationDelivery(command, delivery),
214
189
  bindArtifact: (identity, options) =>
215
190
  this.bindPublicationArtifact(command, identity, options),
216
191
  event: async (fact) => {
217
- assert.ok(
218
- ['source_ready', 'quality', 'source_merge'].includes(fact.stage),
219
- );
192
+ assert.ok(['source_ready', 'quality', 'source_merge'].includes(fact.stage));
220
193
  assert.ok(['running', 'completed', 'failed'].includes(fact.status));
221
194
  this.event(
222
195
  run.id,
223
196
  fact.stage === 'source_ready' ? 'source_ready' : 'ci',
224
197
  'log',
225
- `${fact.stage}: ${fact.status}`,
198
+ `${fact.stage}: ${fact.status}`
226
199
  );
227
200
  await this.save();
228
201
  },
@@ -232,7 +205,7 @@ export class ProducerExecution extends ProducerPublication {
232
205
  run.stages
233
206
  .filter((s) => s.id !== 'production_acceptance')
234
207
  .every((s) => ['passed', 'reused'].includes(s.status)),
235
- 'Every prerequisite stage needs actual proof',
208
+ 'Every prerequisite stage needs actual proof'
236
209
  );
237
210
  const readback = await stage('production_acceptance', async (signal) => {
238
211
  const fact = await this.provider.readProduction(resolved, { signal });
@@ -243,15 +216,13 @@ export class ProducerExecution extends ProducerPublication {
243
216
  }
244
217
  assert.ok(
245
218
  acceptedProduction(fact, resolved, this.now()),
246
- 'Independent production identity and functional proof required',
219
+ 'Independent production identity and functional proof required'
247
220
  );
248
221
  return fact;
249
222
  });
250
223
  run.recoveryState = 'verified';
251
224
  if (command.action === 'rollback') {
252
- const previous = this.state.runs.find(
253
- (r) => r.id === command.previousRunId,
254
- );
225
+ const previous = this.state.runs.find((r) => r.id === command.previousRunId);
255
226
  if (previous) {
256
227
  previous.status = 'failed';
257
228
  previous.recoveryState = 'verified';
@@ -259,7 +230,7 @@ export class ProducerExecution extends ProducerPublication {
259
230
  previous.blockers = [
260
231
  blocker(
261
232
  'RECOVERY_VERIFIED',
262
- 'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой',
233
+ 'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой'
263
234
  ),
264
235
  ];
265
236
  }
@@ -270,7 +241,7 @@ export class ProducerExecution extends ProducerPublication {
270
241
  run.id,
271
242
  null,
272
243
  'readback',
273
- 'Производственная версия и функциональная проверка подтверждены',
244
+ 'Производственная версия и функциональная проверка подтверждены'
274
245
  );
275
246
  } catch (error) {
276
247
  if (
@@ -288,19 +259,17 @@ export class ProducerExecution extends ProducerPublication {
288
259
  remaining() <= 0 ? 'DEADLINE_EXCEEDED' : 'RECONCILIATION_REQUIRED',
289
260
  remaining() <= 0
290
261
  ? 'Исходный срок выполнения истёк'
291
- : 'Выполнение требует проверки фактического результата',
262
+ : 'Выполнение требует проверки фактического результата'
292
263
  ),
293
264
  ];
294
265
  this.event(run.id, null, 'failure', run.blockers[0].message, 'error');
295
266
  await this.qualifyForwardRecovery(run, command);
296
267
  } finally {
297
- if (
298
- !suspended &&
299
- !run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION')
300
- )
268
+ if (!suspended && !run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION'))
301
269
  run.completedAt = this.now().toISOString();
302
270
  await this.save();
303
271
  }
272
+ await notifyPublicationTerminal(this, this.state.commands[command.commandId], run);
304
273
  }
305
274
  async resumeScheduled() {
306
275
  if (this.scheduling) return;
@@ -310,11 +279,11 @@ export class ProducerExecution extends ProducerPublication {
310
279
  (r) =>
311
280
  r.status === 'queued' &&
312
281
  r.lane === 'scheduled' &&
313
- Date.parse(r.activationAt) <= this.now().getTime(),
282
+ Date.parse(r.activationAt) <= this.now().getTime()
314
283
  )) {
315
284
  const command = this.state.commands[run.commandId].command;
316
285
  const candidate = (await this.snapshot()).candidates.find(
317
- (c) => c.id === command.candidateId,
286
+ (c) => c.id === command.candidateId
318
287
  );
319
288
  if (
320
289
  Date.parse(command.deadlineAt) <= this.now().getTime() ||
@@ -325,22 +294,19 @@ export class ProducerExecution extends ProducerPublication {
325
294
  run.blockers = [
326
295
  blocker(
327
296
  'SCHEDULED_CANDIDATE_INVALID',
328
- 'Срок истёк или подготовленный кандидат изменился',
297
+ 'Срок истёк или подготовленный кандидат изменился'
329
298
  ),
330
299
  ];
331
300
  await this.save();
332
301
  continue;
333
302
  }
334
303
  run.blockers = [];
335
- if (
336
- Date.parse(command.activationAt) <
337
- Date.parse(run.noticeAt) + 86400000
338
- ) {
304
+ if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
339
305
  run.status = 'blocked';
340
306
  run.blockers = [
341
307
  blocker(
342
308
  'NOTICE_WINDOW_TOO_SHORT',
343
- 'Недостаточно времени после фактического уведомления',
309
+ 'Недостаточно времени после фактического уведомления'
344
310
  ),
345
311
  ];
346
312
  await this.save();
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { hash, canonical, keys, uuid } from './protocol-internals.mjs';
3
3
  import { validateRunnerCommand, validateProducerSnapshot } from './protocol.mjs';
4
+ import { validatePublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
4
5
  import {
5
6
  validatePublicationFailureRecord,
6
7
  validatePublicationFailureReadback,
@@ -29,6 +30,46 @@ export function validProducerState(state, installationKey, sourcePolicy) {
29
30
  assert.equal(item.ack.protocolVersion, 1);
30
31
  assert.ok(['accepted', 'rejected'].includes(item.ack.state));
31
32
  assert.match(item.ack.runId, uuid);
33
+ if (item.artifactCandidate) {
34
+ assert.equal(item.command.action, 'deploy');
35
+ assert.equal(
36
+ canonical(item.artifactCandidate.identity),
37
+ canonical(item.command.expectedIdentity)
38
+ );
39
+ assert.equal(
40
+ canonical(item.artifactCandidate.deliveryExecution),
41
+ canonical(item.deliveryExecution)
42
+ );
43
+ }
44
+ if (item.deliveryTerminalReceipt) {
45
+ const proof = validatePublicationTerminalReceipt(item.deliveryTerminalReceipt).proof;
46
+ assert.equal(proof.bindings.commandId, id);
47
+ assert.equal(proof.bindings.runId, item.ack.runId);
48
+ assert.equal(proof.bindings.installationKey, installationKey);
49
+ assert.equal(proof.bindings.action, item.command.action);
50
+ assert.equal(proof.bindings.candidateId, item.command.candidateId);
51
+ assert.equal(proof.bindings.provider, item.command.executionProvider);
52
+ assert.equal(canonical(proof.bindings.delivery), canonical(item.deliveryExecution));
53
+ const source =
54
+ item.command.action === 'publish'
55
+ ? item.command.expectedSource
56
+ : item.artifactCandidate?.source;
57
+ assert.equal(proof.bindings.repository, source?.repository);
58
+ assert.equal(proof.bindings.ref, source?.ref);
59
+ assert.equal(
60
+ canonical({ identity: proof.artifactIdentity }),
61
+ canonical({
62
+ identity:
63
+ item.command.action === 'publish'
64
+ ? (item.artifactIdentity ?? null)
65
+ : item.command.expectedIdentity,
66
+ })
67
+ );
68
+ const run = state.runs.find((value) => value.id === item.ack.runId);
69
+ assert.equal(run?.status, proof.status);
70
+ assert.equal(run?.completedAt, proof.completedAt);
71
+ assert.equal(run?.recoveryState, 'verified');
72
+ }
32
73
  if (item.publicationFailureReconciliation !== undefined)
33
74
  validatePublicationFailureRecord(
34
75
  item.publicationFailureReconciliation,
@@ -59,6 +100,9 @@ export function validProducerState(state, installationKey, sourcePolicy) {
59
100
  if (reconciliation) {
60
101
  assert.equal(state.commands[run.commandId].command.action, 'publish');
61
102
  assert.ok(!state.commands[run.commandId].artifactIdentity);
103
+ if (reconciliation.fact?.kind === 'failed_before_source_push')
104
+ for (const stage of run.stages.filter((stage) => stage.id !== 'source_ready'))
105
+ assert.ok(stage.status === 'pending' && stage.startedAt === null);
62
106
  if (reconciliation.state === 'settled') {
63
107
  assert.equal(run.status, 'failed');
64
108
  assert.equal(run.recoveryState, 'verified');
@@ -4,6 +4,8 @@ import {
4
4
  hash,
5
5
  keys,
6
6
  timestamp,
7
+ uuid,
8
+ boundedText,
7
9
  completeIdentity,
8
10
  validateReadback,
9
11
  } from './protocol-internals.mjs';
@@ -11,20 +13,43 @@ import { acceptedProduction, sameIdentity } from './protocol.mjs';
11
13
  import { validateOriginalDelivery, blocker } from './producer-support.mjs';
12
14
  import { verifyPublicationDelivery } from './producer-delivery.mjs';
13
15
  import { reservePublicationFailureReconciliation } from './exclusive-operation.mjs';
16
+ import { notifyPublicationTerminal } from './publication-terminal.mjs';
14
17
 
15
18
  const stages = ['source_ready', 'ci', 'build', 'verify', 'publish'];
16
19
  export function publicationFailureProofDigest(fact, baselineReadback) {
17
20
  return `sha256:${hash(canonical({ fact, baselineReadback }))}`;
18
21
  }
19
22
 
23
+ export function validateSourceRecoveryExecution(value, command) {
24
+ keys(value, ['schema', 'kind', 'id', 'candidateId', 'sourceCommit', 'requestedAt', 'deadlineAt', 'policy']);
25
+ assert.equal(value.schema, 1);
26
+ assert.equal(value.kind, 'source_not_pushed');
27
+ assert.match(value.id, uuid);
28
+ assert.match(value.sourceCommit, /^[a-f0-9]{40}$/);
29
+ boundedText(value.candidateId);
30
+ assert.equal(value.policy, 'immediate');
31
+ timestamp(value.requestedAt);
32
+ timestamp(value.deadlineAt);
33
+ assert.equal(Date.parse(value.deadlineAt) - Date.parse(value.requestedAt), 900000);
34
+ if (command) {
35
+ assert.equal(command.action, 'publish');
36
+ assert.equal(value.candidateId, command.candidateId);
37
+ assert.equal(value.sourceCommit, command.expectedSource.sourceCommit);
38
+ assert.equal(value.requestedAt, command.requestedAt);
39
+ assert.equal(value.deadlineAt, command.deadlineAt);
40
+ }
41
+ return value;
42
+ }
43
+
20
44
  export function validatePublicationFailureFact(fact, command, delivery) {
45
+ const beforePush = fact?.kind === 'failed_before_source_push';
21
46
  keys(fact, [
22
47
  'schema',
23
48
  'kind',
24
49
  'commandId',
25
50
  'installationKey',
26
51
  'expectedSource',
27
- 'delivery',
52
+ beforePush ? 'sourceExecution' : 'delivery',
28
53
  'observedAt',
29
54
  'stage',
30
55
  'authenticated',
@@ -34,15 +59,21 @@ export function validatePublicationFailureFact(fact, command, delivery) {
34
59
  'installationNotStarted',
35
60
  ]);
36
61
  assert.equal(fact.schema, 1);
37
- assert.equal(fact.kind, 'failed_before_installation');
62
+ assert.ok(beforePush || fact.kind === 'failed_before_installation');
38
63
  assert.equal(fact.commandId, command.commandId);
39
64
  assert.equal(fact.installationKey, command.installationKey);
40
65
  keys(fact.expectedSource, ['repository', 'ref', 'sourceCommit']);
41
66
  assert.equal(canonical(fact.expectedSource), canonical(command.expectedSource));
42
- validateOriginalDelivery(fact.delivery, command.expectedSource.sourceCommit);
43
- assert.equal(fact.delivery.requestedAt, command.requestedAt);
44
- assert.equal(fact.delivery.deadlineAt, command.deadlineAt);
45
- if (delivery) assert.deepEqual(fact.delivery, delivery);
67
+ if (beforePush) {
68
+ assert.ok(delivery == null, 'Source failure cannot replace an admitted GitHub delivery');
69
+ validateSourceRecoveryExecution(fact.sourceExecution, command);
70
+ assert.equal(fact.stage, 'source_ready');
71
+ } else {
72
+ validateOriginalDelivery(fact.delivery, command.expectedSource.sourceCommit);
73
+ assert.equal(fact.delivery.requestedAt, command.requestedAt);
74
+ assert.equal(fact.delivery.deadlineAt, command.deadlineAt);
75
+ if (delivery) assert.deepEqual(fact.delivery, delivery);
76
+ }
46
77
  timestamp(fact.observedAt);
47
78
  assert.ok(stages.includes(fact.stage));
48
79
  for (const field of [
@@ -97,10 +128,20 @@ export function validatePublicationFailureRecord(record, command, delivery, base
97
128
  assert.ok(record.baselineReadback, 'Settled recovery requires its production baseline proof');
98
129
  }
99
130
 
100
- export async function reconcilePublicationFailure(producer, item, run) {
131
+ export async function reconcilePublicationFailure(
132
+ producer,
133
+ item,
134
+ run,
135
+ { terminalRetry = false } = {}
136
+ ) {
101
137
  assert.equal(item.ack.state, 'accepted');
102
138
  assert.equal(item.command.action, 'publish');
103
- assert.equal(run.status, 'blocked');
139
+ assert.ok(
140
+ run.status === 'blocked' ||
141
+ (terminalRetry &&
142
+ run.status === 'failed' &&
143
+ item.publicationFailureReconciliation?.state === 'settled')
144
+ );
104
145
  assert.ok(!item.artifactIdentity);
105
146
  for (const id of ['deploy', 'production_acceptance']) {
106
147
  const row = run.stages.find((stage) => stage.id === id);
@@ -137,7 +178,9 @@ export async function reconcilePublicationFailure(producer, item, run) {
137
178
  fact: null,
138
179
  baselineReadback: null,
139
180
  };
181
+ if (terminalRetry) item.publicationFailureReconciliation.state = 'pending';
140
182
  assert.equal(item.publicationFailureReconciliation.state, 'pending');
183
+ run.status = 'blocked';
141
184
  run.recoveryState = 'unverified';
142
185
  run.completedAt = null;
143
186
  await bounded(producer.save());
@@ -196,7 +239,19 @@ export async function reconcilePublicationFailure(producer, item, run) {
196
239
  const { observedAt: _newTime, ...newIdentity } = fact;
197
240
  assert.deepEqual(newIdentity, oldIdentity, 'Publication failure reconciliation changed');
198
241
  }
199
- await bounded(
242
+ const beforePush = fact.kind === 'failed_before_source_push';
243
+ if (beforePush) {
244
+ assert.ok(!item.deliveryExecution && !item.artifactIdentity);
245
+ for (const stage of run.stages.filter((stage) => stage.id !== 'source_ready'))
246
+ assert.ok(stage.status === 'pending' && stage.startedAt === null,
247
+ 'Source failure cannot erase a started downstream stage');
248
+ assert.equal(typeof producer.provider.verifyPublicationSourceFailure, 'function',
249
+ 'Source failure boundary verifier required');
250
+ assert.equal(await bounded(producer.provider.verifyPublicationSourceFailure(
251
+ structuredClone(item.command), structuredClone(fact.sourceExecution),
252
+ { signal: controller.signal, recoveryDeadlineAt }
253
+ )), true, 'Source failure boundary was not verified');
254
+ } else await bounded(
200
255
  verifyPublicationDelivery(producer, item.command, fact.delivery, { fresh: false })
201
256
  );
202
257
  await bounded(guard.assertOwned());
@@ -205,7 +260,7 @@ export async function reconcilePublicationFailure(producer, item, run) {
205
260
  assertFresh();
206
261
  item.publicationFailureReconciliation.fact = fact;
207
262
  item.publicationFailureReconciliation.baselineReadback = baselineReadback;
208
- item.deliveryExecution = structuredClone(fact.delivery);
263
+ if (!beforePush) item.deliveryExecution = structuredClone(fact.delivery);
209
264
  run.readback = structuredClone(baselineReadback);
210
265
  await bounded(producer.save());
211
266
  const proofDigest = publicationFailureProofDigest(fact, baselineReadback);
@@ -267,4 +322,8 @@ export async function reconcilePublicationFailure(producer, item, run) {
267
322
  if (guard && !settled && !releasing && typeof guard.release === 'function') void cleanup(guard);
268
323
  lease.release({ resolved: settled });
269
324
  }
325
+ // Pre-push source recovery has no GitHub delivery or publication-terminal MAC.
326
+ // Its existing guarded acknowledgement is the sole settlement boundary.
327
+ if (settled && item.publicationFailureReconciliation.fact.kind !== 'failed_before_source_push')
328
+ await notifyPublicationTerminal(producer, item, run);
270
329
  }