@starci/hfs 4.12.2 → 4.13.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/stack/deploy.mjs CHANGED
@@ -10,10 +10,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
10
10
  // deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, post-deploy tasks (deploy.postDeploy, when declared), verify, accept, prune. A failure at or
11
11
  // after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
12
12
  // Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
13
+ // An app that declares the `database:` block gets the database phases of db-phases.mjs in every deploy (owner approval 2026-10-31): migrate preview, a
14
+ // verified backup only when something is pending, the expand migrations inside the database container before the stack deploy, the contract migrations
15
+ // after accept, and an automatic first start when no release was ever accepted. An app without the block deploys exactly as before.
13
16
  // An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
14
17
  // backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails; its
15
18
  // first start (`firstStart`) deploys the stack with only the database running (the `deploy-hold` step), proves the database empty and migrates it inside its own
16
19
  // container (the `first-start` step), then deploys the real stack and converges.
20
+ // Every execute run is one recorded ATTEMPT (journal.mjs): the deploy lock of the manager (shared with the owner's urgent deploy), the deploy journal written at
21
+ // every phase boundary, the skip when the accepted release already runs this commit, and the live services snapshotted after converge, after a rollback and on accept
22
+ // (status.mjs; `stack status` and `stack verify` judge them). Preflight also pulls the images of the rollback target, so a registry clean-up cannot strand a rollback.
17
23
  //
18
24
  // `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
19
25
  // module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
@@ -28,11 +34,16 @@ import { convergeStack } from './converge.mjs';
28
34
  import { describeClash, edgeCollisions, reconcileEdgeAliases } from './edge-alias.mjs';
29
35
  import { firstLine, tool } from './context.mjs';
30
36
  import { hasPlaceholderDigest, isReleaseId } from './digest.mjs';
37
+ import { contractPhase, declaredDatabase, expandPhase, firstStartHooks, FLOOR_DEPS, unknownDatabaseService } from './db-phases.mjs';
31
38
  import { failure, refusal } from './errors.mjs';
39
+ import { recordedAttempt } from './journal.mjs';
32
40
  import { postDeployStack } from './post-deploy.mjs';
33
41
  import { pruneStack } from './prune.mjs';
42
+ import { compareWithRelease, observedServices, stackImages } from './status.mjs';
34
43
  import { parseYaml } from '../runtime/engine/yaml.mjs';
35
44
  import { createReleaseStore } from './release-store.mjs';
45
+ import { contractFinding, enforceContractGuard } from './rollback-guard.mjs';
46
+ import { gateIsOn } from './ssh-gate.mjs';
36
47
  import { urgentDeploy } from './urgent-floor.mjs';
37
48
  import { verifyStack } from './verify.mjs';
38
49
  /** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
@@ -49,6 +60,7 @@ export const DEFAULT_DEPS = Object.freeze({
49
60
  verifyStack,
50
61
  pruneStack,
51
62
  releaseStore: createReleaseStore,
63
+ observedServices,
52
64
  ssh: lazy('./remote.mjs', 'ownerSshRunner'),
53
65
  floor: {},
54
66
  });
@@ -136,10 +148,10 @@ function writeHoldOverride(config, out, database) {
136
148
  `);
137
149
  return file;
138
150
  }
139
- /** Runs [name, action] steps in order until one fails; records { step, ok } and collects the lines. Returns the failed step name or ''. */
140
- async function runSteps(plan, lines, steps) {
151
+ /** Runs [name, action] steps in order until one fails; records { step, ok } and collects the lines; each step is also a phase of the journal. Returns the failed step name or ''. */
152
+ async function runSteps(plan, lines, steps, rec) {
141
153
  const failed = await findInOrder(plan, async ([step, action]) => {
142
- const result = await attempt(action);
154
+ const result = await rec.phase(step, () => attempt(action), (done) => done.ok);
143
155
  lines.push(...result.lines);
144
156
  steps.push({ step, ok: result.ok });
145
157
  return !result.ok;
@@ -153,17 +165,17 @@ async function openRunner(ctx, config, options, d, cleanups) {
153
165
  cleanups.push(() => run.cleanup?.());
154
166
  return run;
155
167
  }
156
- const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups));
168
+ const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups), { gated: gateIsOn(ctx.env) });
157
169
  /** Redeploys a stored release after a failed deploy: fetch, stack deploy, converge. Returns the lines to print. */
158
- async function redeployStored(ctx, config, d, store, previous, out, steps) {
170
+ async function redeployStored(ctx, config, d, store, previous, out, steps, rec) {
159
171
  const dir = path.join(out, 'previous');
160
172
  const lines = [`ROLLING BACK to the previous accepted release ${previous}`];
161
173
  const failed = await runSteps([
162
174
  ['rollback-fetch', async () => { await store.fetch(previous, dir); return { ok: true, lines: [] }; }],
163
175
  ['rollback-deploy', () => stackDeploy(ctx, config, path.join(dir, 'stack.yaml'))],
164
176
  ['rollback-edge', async () => d.reconcileEdge(ctx, config, path.join(dir, 'stack.yaml'))],
165
- ['rollback-converge', () => d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 })],
166
- ], lines, steps);
177
+ ['rollback-converge', async () => { const converged = await d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 }); snapshot(ctx, config, d, rec, 'rollback'); return converged; }],
178
+ ], lines, steps, rec);
167
179
  lines.push(failed ? 'ROLLBACK DID NOT CONVERGE: follow the stack runbook, Rollback' : `rolled back to ${previous}`);
168
180
  return { lines, ok: !failed };
169
181
  }
@@ -192,28 +204,103 @@ function prePull(ctx, images) {
192
204
  }
193
205
  return lines;
194
206
  }
207
+ /**
208
+ * Pulls the images of the rollback target (the release accepted at the start of the deploy) that the new release does not already name: a registry clean-up
209
+ * must not strand a rollback, so a target whose images no longer pull refuses the deploy before the services are touched.
210
+ */
211
+ async function prePullRollbackTarget(ctx, store, previous, pulled) {
212
+ const target = await store.read(previous);
213
+ const images = [...new Set(Object.values(stackImages(target.stackYaml)))].filter((image) => IMAGE_REF.test(image) && !pulled.includes(image));
214
+ const lines = [];
215
+ for (const image of images) {
216
+ const run = tool(ctx, 'docker', ['pull', '--quiet', image]);
217
+ if (run.status !== 0)
218
+ throw failure('STACK_DEPLOY_ROLLBACK_PULL', `the rollback target ${previous} names ${image}, which no longer pulls (${firstLine(run.stderr)}): a failed deploy could not go back; nothing was changed`);
219
+ lines.push(`pulled ${image} (rollback target ${previous})`);
220
+ }
221
+ return lines;
222
+ }
223
+ /** The live services into the recorder under `moment`; null (and no snapshot) when docker cannot be asked: a snapshot never fails a deploy. */
224
+ function snapshot(ctx, config, d, rec, moment) {
225
+ try {
226
+ const services = d.observedServices(ctx, config);
227
+ rec.snapshot(moment, services);
228
+ return services;
229
+ }
230
+ catch {
231
+ return null;
232
+ }
233
+ }
234
+ /**
235
+ * Accepts `releaseId`: stamps the services observed now into its release record (`observed.accept`, what `stack status` compares against), stores it
236
+ * again, and moves the pointers. Without a snapshot the release is accepted as before.
237
+ */
238
+ async function acceptObserved(ctx, config, d, store, rec, { out, releaseId }) {
239
+ const services = snapshot(ctx, config, d, rec, 'accept');
240
+ if (services) {
241
+ try {
242
+ const file = path.join(out, 'release.json');
243
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
244
+ fs.writeFileSync(file, `${JSON.stringify({ ...record, observed: { at: new Date(ctx.now()).toISOString(), accept: services } }, null, 2)}
245
+ `);
246
+ await store.store(out, releaseId);
247
+ }
248
+ catch (error) {
249
+ rec.warnings.push(`journal: the observed services were not stored in the release record (${firstLine(error.message)})`);
250
+ }
251
+ }
252
+ await store.accept(releaseId);
253
+ }
254
+ /**
255
+ * The accepted release when it already runs this commit and its services still match it (nothing to deploy), else null. A same-commit release whose
256
+ * services drifted (Swarm rolled one back, a manual update) is deployed again; an unreadable swarm is deployed too.
257
+ */
258
+ async function alreadyLive(ctx, config, d, store, sourceSha, lines) {
259
+ const current = await store.current();
260
+ if (!current)
261
+ return null;
262
+ try {
263
+ const stored = await store.read(current);
264
+ if (stored.release.sourceSha !== sourceSha)
265
+ return null;
266
+ const drift = compareWithRelease(stored, d.observedServices(ctx, config));
267
+ if (drift.length === 0)
268
+ return current;
269
+ lines.push(`release ${current} runs this commit, but ${drift.map((entry) => `${entry.service} (${entry.kind})`).join(', ')} differ: deploying again`);
270
+ }
271
+ catch {
272
+ lines.push(`release ${current} could not be compared with the live services: deploying`);
273
+ }
274
+ return null;
275
+ }
195
276
  /** True when the deploy names the commit it deploys. */
196
277
  const hasSourceSha = (options) => Boolean(options.sourceSha);
197
278
  /** Everything before the stack deploy: any failure here leaves the services untouched. Returns { ok, lines, releaseId, images, previous }. */
198
- async function prepareRelease(ctx, config, options, d, out, cleanups) {
199
- const run = await openRunner(ctx, config, options, d, cleanups);
200
- const store = d.releaseStore(config, run);
279
+ async function prepareRelease(ctx, config, options, d, out, seams) {
280
+ const { run, store, rec } = seams;
201
281
  const previous = await store.current();
202
- const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, previous || null));
203
- const { releaseId, images, bytes } = inspectRendered(out, config);
204
- await refuseEdgeCollisions(run, config, bytes);
205
- const lines = [...(rendered.lines ?? [])];
206
- const provisioned = await d.provisionRelease(ctx, config, { execute: true, out, releaseFile: path.join(out, 'release.json') });
282
+ rec.note({ previous: previous || null });
283
+ const prepared = await rec.phase('render', async () => {
284
+ const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, previous || null));
285
+ const inspected = inspectRendered(out, config);
286
+ await refuseEdgeCollisions(run, config, inspected.bytes);
287
+ return { rendered, ...inspected };
288
+ });
289
+ const { releaseId, images } = prepared;
290
+ rec.note({ releaseId });
291
+ const lines = [...(prepared.rendered.lines ?? [])];
292
+ const provisioned = await rec.phase('provision', () => d.provisionRelease(ctx, config, { execute: true, out, releaseFile: path.join(out, 'release.json') }), (done) => done.ok !== false);
207
293
  lines.push(...(provisioned.lines ?? []));
208
294
  if (provisioned.ok === false)
209
- return { ok: false, lines, releaseId, previous, store, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: false }] };
210
- await store.store(out, releaseId);
211
- lines.push(...prePull(ctx, images));
212
- return { ok: true, lines, releaseId, previous, store, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: true }, { step: 'store', ok: true }, { step: 'pre-pull', ok: true }] };
295
+ return { ok: false, lines, releaseId, previous, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: false }] };
296
+ await rec.phase('store', () => store.store(out, releaseId));
297
+ lines.push(...await rec.phase('pre-pull', async () => [...prePull(ctx, images), ...(previous ? await prePullRollbackTarget(ctx, store, previous, images) : [])]));
298
+ return { ok: true, lines, releaseId, previous, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: true }, { step: 'store', ok: true }, { step: 'pre-pull', ok: true }] };
213
299
  }
214
- async function deployExecute(ctx, config, options, d, out, cleanups) {
215
- const prepared = await prepareRelease(ctx, config, options, d, out, cleanups);
216
- const { releaseId, previous, store } = prepared;
300
+ async function deployExecute(ctx, config, options, d, out, seams) {
301
+ const { store, rec, run } = seams;
302
+ const prepared = await prepareRelease(ctx, config, options, d, out, seams);
303
+ const { releaseId, previous } = prepared;
217
304
  const lines = [...prepared.lines];
218
305
  const steps = [...prepared.stepsDone];
219
306
  const data = { releaseId, previous: previous || null, steps, rolledBack: false };
@@ -225,37 +312,76 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
225
312
  // The first start of a self-hosted database (urgent-floor.mjs): the services that need its schema must not start against an empty one (their
226
313
  // healthchecks fail and update_config rolls them back), so the stack is first deployed with only the database running, the schema is
227
314
  // bootstrapped and migrated, and then the real stack is deployed.
228
- const hold = options.firstStartStep && options.firstStartDb ? writeHoldOverride(config, out, options.firstStartDb) : null;
315
+ // An app that declares the `database:` block migrates in every deploy (db-phases.mjs); with no accepted release the first start is chosen by itself.
316
+ const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
317
+ const phase = database ? { ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run, dbUrlEnv: options.dbUrlEnv, backupOut: options.backupOut } : null;
318
+ const unknown = database ? unknownDatabaseService(config, database) : null;
319
+ if (unknown)
320
+ return { ok: false, refused: unknown, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: unknown.code } };
321
+ let first = options.firstStartStep && options.firstStartDb ? { step: options.firstStartStep, service: options.firstStartDb } : null;
322
+ let firstCode = null;
323
+ if (phase && !first && phase.database.target === 'stack' && !previous) {
324
+ const automatic = firstStartHooks(phase);
325
+ first = { service: automatic.service, step: async () => { const outcome = await automatic.step(); firstCode = outcome.code ?? null; return outcome; } };
326
+ }
327
+ if (phase && !first) {
328
+ const expanded = await rec.phase('migrate', () => expandPhase(phase), (done) => done.ok);
329
+ rec.note({ backup: expanded.record.backup, migrations: expanded.record.applied });
330
+ lines.push(...expanded.lines);
331
+ steps.push(...expanded.steps);
332
+ data.migrations = expanded.record;
333
+ if (!expanded.ok)
334
+ return { ok: false, refused: expanded.refused, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: expanded.refused?.code } };
335
+ }
336
+ const hold = first ? writeHoldOverride(config, out, first.service) : null;
229
337
  const failed = await runSteps([
230
338
  ...(hold ? [['deploy-hold', () => stackDeploy(ctx, config, stackFile, [hold])]] : []),
231
- ...(options.firstStartStep ? [['first-start', options.firstStartStep]] : []),
339
+ ...(first ? [['first-start', first.step]] : []),
232
340
  ['deploy', () => stackDeploy(ctx, config, stackFile)],
233
341
  ['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
234
- ['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
342
+ ['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
235
343
  ...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
236
344
  ['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
237
- ...(skipPublic ? [] : [['accept', async () => { await store.accept(releaseId); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
238
- ], lines, steps);
345
+ ...(skipPublic ? [] : [['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out, releaseId }); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
346
+ ], lines, steps, rec);
239
347
  if (!failed && skipPublic) {
240
348
  data.pendingAccept = true;
241
- return { ok: true, lines: [...lines, `deployed release ${releaseId}: converged and verified inside the stack; the public hosts were NOT checked and the release is NOT accepted yet`, `next: starci app stack ingress publish --env ${config.env} --execute, then starci app stack accept --env ${config.env} --release ${releaseId} --execute`], data };
349
+ return { ok: true, lines: [...lines, ...(phase && !first ? ['db: the contract migrations (if any) run when the release is accepted'] : []), `deployed release ${releaseId}: converged and verified inside the stack; the public hosts were NOT checked and the release is NOT accepted yet`, `next: starci app stack ingress publish --env ${config.env} --execute, then starci app stack accept --env ${config.env} --release ${releaseId} --execute`], data };
242
350
  }
243
351
  if (!failed) {
244
352
  await housekeeping(ctx, config, d, store, { previous, out }, lines);
353
+ // The contract migrations run only now, with the release accepted and serving: the previous release is no longer the one the stack runs.
354
+ const contract = phase && !first ? await rec.phase('contract', () => contractPhase(phase), (done) => done.ok) : null;
355
+ if (contract) {
356
+ lines.push(...contract.lines);
357
+ rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied], contract: contract.applied });
358
+ steps.push({ step: 'contract', ok: contract.ok });
359
+ data.migrations = { ...data.migrations, contractApplied: contract.applied };
360
+ }
361
+ if (contract && !contract.ok)
362
+ return { ok: false, lines: [...lines, `release ${releaseId} is accepted and serving; its contract migration is red: the owner decides`], data: { ...data, accepted: true, contractFailed: true } };
245
363
  return { ok: true, lines: [...lines, `deployed release ${releaseId}`], data };
246
364
  }
247
365
  lines.push(`deploy failed at ${failed}`);
366
+ const migrations = data.migrations;
367
+ if (migrations?.applied?.length)
368
+ lines.push(`db: the ${migrations.applied.length} expand migration(s) this deploy applied stay (the previous release keeps working on the expanded schema; a rollback restores services, never data); the backup ${migrations.backup?.receiptPath ?? ''} is the way back for data`);
369
+ const stopped = firstCode ? { refused: { code: firstCode, message: 'the first start failed' } } : {};
248
370
  if (!previous)
249
- return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data };
250
- const back = await redeployStored(ctx, config, d, store, previous, out, steps);
371
+ return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data, ...stopped };
372
+ const back = await redeployStored(ctx, config, d, store, previous, out, steps, rec);
251
373
  data.rolledBack = back.ok;
252
- return { ok: false, lines: [...lines, ...back.lines], data };
374
+ return { ok: false, lines: [...lines, ...back.lines], data, ...stopped };
253
375
  }
376
+ /** What starts an attempt: the owner's urgent deploy, a CI run (`--ci-run`) or a manual dispatch. */
377
+ const triggerOf = (options) => (options.urgent === true ? 'urgent' : options.ciRun ? 'ci' : 'dispatch');
254
378
  /**
255
- * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
379
+ * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut, ciRun, redeploy }.
256
380
  * skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
257
381
  * inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
258
- * Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack } }.
382
+ * With `execute` the run is one recorded attempt under the deploy lock (journal.mjs); the accepted release that already runs this commit ends it green
383
+ * as `already-current` unless `redeploy` is set.
384
+ * Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack, attempt } }.
259
385
  */
260
386
  export async function deployRelease(ctx, config, options = {}, deps = {}) {
261
387
  const d = { ...DEFAULT_DEPS, ...deps };
@@ -273,20 +399,44 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
273
399
  const dryRun = async () => {
274
400
  const { rendered, releaseId } = await render();
275
401
  const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
402
+ const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
403
+ const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted` : '';
276
404
  const plan = options.skipPublic
277
- ? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
278
- : `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted`;
405
+ ? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept${databasePlan}`
406
+ : `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted${databasePlan}`;
279
407
  return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
280
408
  };
281
- if (options.urgent === true) {
282
- return await urgentDeploy(ctx, config, options, {
283
- render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, cleanups), sha256: fileSha256,
284
- }, d.floor);
409
+ if (options.execute !== true) {
410
+ if (options.urgent !== true)
411
+ return await dryRun();
412
+ const never = (what) => () => Promise.reject(new Error(`a dry run ${what}`));
413
+ return await urgentDeploy(ctx, config, options, { render, dryRun, openStore: never('opens no store'), deployExecute: never('deploys nothing'), sha256: fileSha256 }, d.floor);
285
414
  }
286
- if (options.execute !== true)
287
- return await dryRun();
288
- await render();
289
- return await deployExecute(ctx, config, options, d, out, cleanups);
415
+ if (options.urgent !== true)
416
+ await render();
417
+ const run = await openRunner(ctx, config, options, d, cleanups);
418
+ const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
419
+ const meta = {
420
+ kind: 'deploy', trigger: triggerOf(options), sourceSha: options.sourceSha, ciRun: options.ciRun,
421
+ urgent: options.urgent === true ? { role: ctx.env.STARCI_ROLE ?? 'unknown', reason: options.reason ?? '' } : undefined,
422
+ };
423
+ return await recordedAttempt(ctx, config, store, meta, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
424
+ const notes = [];
425
+ const current = options.redeploy === true ? null : await rec.phase('idempotence', () => alreadyLive(ctx, config, d, store, options.sourceSha, notes));
426
+ if (current) {
427
+ rec.note({ releaseId: current });
428
+ return { ok: true, lines: [`already current: release ${current} runs commit ${options.sourceSha.slice(0, 10)} and its services match it; nothing to deploy`], data: { releaseId: current, previous: null, steps: [], rolledBack: false, alreadyCurrent: true } };
429
+ }
430
+ const seams = { run, store, rec };
431
+ const done = options.urgent === true
432
+ ? await urgentDeploy(ctx, config, options, {
433
+ render, dryRun, openStore: async () => store, sha256: fileSha256,
434
+ deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, seams),
435
+ note: (record) => rec.note({ urgent: record }),
436
+ }, d.floor)
437
+ : await deployExecute(ctx, config, options, d, out, seams);
438
+ return { ...done, lines: [...notes, ...done.lines] };
439
+ });
290
440
  }
291
441
  finally {
292
442
  cleanups.forEach((close) => close());
@@ -296,7 +446,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
296
446
  }
297
447
  /**
298
448
  * Redeploys a stored release (default: the `previous` pointer): `options` = { release, execute, timeoutSeconds, sshCmd }.
299
- * `release` is read from `options` only and must be null or 16 lowercase hex.
449
+ * `release` is read from `options` only and must be null or 16 lowercase hex. With `execute` it is one recorded attempt under the deploy lock.
300
450
  */
301
451
  export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
302
452
  const d = { ...DEFAULT_DEPS, ...deps };
@@ -305,38 +455,47 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
305
455
  throw refusal('STACK_RELEASE_ID', 'the release must be 16 lowercase hex characters');
306
456
  const cleanups = [];
307
457
  const store = await openStore(ctx, config, options, d, cleanups);
308
- let dir = null;
309
458
  try {
310
459
  const target = release ?? await store.previous();
311
460
  if (!target)
312
461
  throw refusal('STACK_ROLLBACK_NO_TARGET', 'no previous accepted release is recorded; name one with --release');
462
+ // A manual rollback to a release older than an applied contract migration is refused unless the owner acknowledges the data effect.
463
+ const notice = enforceContractGuard(contractFinding(await store.journals(), target), options.dataAcknowledged === true);
313
464
  if (options.execute !== true)
314
- return { ok: true, lines: [`dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
315
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
316
- dir = scratch;
317
- const lines = [];
318
- const steps = [];
319
- const since = isoBefore(ctx, 30);
320
- const failed = await runSteps([
321
- ['fetch', async () => { await store.fetch(target, scratch); return { ok: true, lines: [] }; }],
322
- ['deploy', () => stackDeploy(ctx, config, path.join(scratch, 'stack.yaml'))],
323
- ['edge', async () => d.reconcileEdge(ctx, config, path.join(scratch, 'stack.yaml'))],
324
- ['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds ?? 600 })],
325
- ['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile: path.join(scratch, 'stack.yaml') })],
326
- ['accept', async () => { await store.accept(target); return { ok: true, lines: [] }; }],
327
- ], lines, steps);
328
- lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
329
- return { ok: !failed, lines, data: { release: target, executed: true, steps } };
465
+ return { ok: true, lines: [...notice, `dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
466
+ return await recordedAttempt(ctx, config, store, { kind: 'rollback', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
467
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
468
+ try {
469
+ rec.note({ releaseId: target, previous: await store.current() || null });
470
+ const lines = [];
471
+ const steps = [];
472
+ const since = isoBefore(ctx, 30);
473
+ const stackFile = path.join(scratch, 'stack.yaml');
474
+ const failed = await runSteps([
475
+ ['fetch', async () => { await store.fetch(target, scratch); return { ok: true, lines: [] }; }],
476
+ ['deploy', () => stackDeploy(ctx, config, stackFile)],
477
+ ['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
478
+ ['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds ?? 600 }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
479
+ ['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile })],
480
+ ['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out: scratch, releaseId: target }); return { ok: true, lines: [] }; }],
481
+ ], lines, steps, rec);
482
+ lines.unshift(...notice);
483
+ lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
484
+ return { ok: !failed, lines, data: { release: target, executed: true, steps } };
485
+ }
486
+ finally {
487
+ fs.rmSync(scratch, { recursive: true, force: true });
488
+ }
489
+ });
330
490
  }
331
491
  finally {
332
- if (dir)
333
- fs.rmSync(dir, { recursive: true, force: true });
334
492
  cleanups.forEach((close) => close());
335
493
  }
336
494
  }
337
495
  /**
338
496
  * Accepts a release that a `skipPublic` deploy left pending: the FULL verification (public hosts and checks inside the stack) must pass first, then the
339
- * pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }.
497
+ * pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }. With `execute` it is one recorded
498
+ * attempt under the deploy lock.
340
499
  */
341
500
  export async function acceptRelease(ctx, config, options = {}, deps = {}) {
342
501
  const d = { ...DEFAULT_DEPS, ...deps };
@@ -358,14 +517,25 @@ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
358
517
  return { ok: true, lines: [`release ${release} is already the accepted release`], data: { release, accepted: true, executed: options.execute === true } };
359
518
  if (options.execute !== true)
360
519
  return { ok: true, lines: [`dry run: would run the full verification (public hosts and checks inside the stack) and then accept release ${release}`], data: { release, accepted: false, executed: false } };
361
- const verified = await d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, stackFile: path.join(out, 'stack.yaml') });
362
- const lines = [...(verified.lines ?? [])];
363
- if (!verified.ok)
364
- return { ok: false, lines: [...lines, `release ${release} is NOT accepted: the full verification failed`], data: { release, accepted: false, executed: true } };
365
- await store.accept(release);
366
- lines.push(`accepted release ${release}`);
367
- await housekeeping(ctx, config, d, store, { previous: previous || null, out }, lines);
368
- return { ok: true, lines, data: { release, accepted: true, executed: true } };
520
+ return await recordedAttempt(ctx, config, store, { kind: 'accept', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
521
+ rec.note({ releaseId: release, previous: previous || null });
522
+ const verified = await rec.phase('verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, stackFile: path.join(out, 'stack.yaml') }), (done) => done.ok);
523
+ const lines = [...(verified.lines ?? [])];
524
+ if (!verified.ok)
525
+ return { ok: false, lines: [...lines, `release ${release} is NOT accepted: the full verification failed`], data: { release, accepted: false, executed: true } };
526
+ await rec.phase('accept', () => acceptObserved(ctx, config, d, store, rec, { out, releaseId: release }));
527
+ lines.push(`accepted release ${release}`);
528
+ await housekeeping(ctx, config, d, store, { previous: previous || null, out }, lines);
529
+ const database = await declaredDatabase(ctx, config);
530
+ const contract = database ? await rec.phase('contract', () => contractPhase({ ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run: null, dbUrlEnv: options.dbUrlEnv }), (done) => done.ok) : null;
531
+ if (contract) {
532
+ lines.push(...contract.lines);
533
+ rec.note({ migrations: contract.applied, contract: contract.applied });
534
+ }
535
+ if (contract && !contract.ok)
536
+ return { ok: false, lines: [...lines, `release ${release} is accepted and serving; its contract migration is red: the owner decides`], data: { release, accepted: true, executed: true, contractFailed: true } };
537
+ return { ok: true, lines, data: { release, accepted: true, executed: true, ...(contract ? { contractApplied: contract.applied } : {}) } };
538
+ });
369
539
  }
370
540
  finally {
371
541
  fs.rmSync(out, { recursive: true, force: true });
@@ -0,0 +1,146 @@
1
+ import type { DeployConfig } from './config.mjs';
2
+ import type { StackContext } from './context.mjs';
3
+ import type { LockTrigger, ReleaseStore } from './release-store.mjs';
4
+ import type { ActionResult } from './remote.mjs';
5
+ import type { ObservedService } from './status.mjs';
6
+ /** What started the attempt: a CI run, a manual dispatch or the owner's urgent deploy. */
7
+ export type Trigger = LockTrigger;
8
+ /** What the attempt does. */
9
+ export type AttemptKind = 'deploy' | 'rollback' | 'accept';
10
+ /** How an attempt ended; `running` is the document of an attempt that has not finished (or was cancelled). */
11
+ export type Outcome = 'running' | 'accepted' | 'pending' | 'rolled-back' | 'rollback-failed' | 'failed' | 'refused' | 'already-current';
12
+ /** One phase of an attempt. */
13
+ export interface PhaseRecord {
14
+ phase: string;
15
+ startedAt: string;
16
+ endedAt: string | null;
17
+ verdict: 'running' | 'ok' | 'failed';
18
+ }
19
+ /** The document of one attempt, as /srv/<stack>/deploys/<attempt>.json holds it. */
20
+ export interface AttemptDocument {
21
+ schema: 'starci/stack-deploy-attempt@1';
22
+ attempt: string;
23
+ kind: AttemptKind;
24
+ stack: string;
25
+ env: string;
26
+ startedAt: string;
27
+ endedAt: string | null;
28
+ trigger: Trigger;
29
+ sourceSha: string | null;
30
+ /** The release tag when the attempt ran from a tag (GITHUB_REF_NAME of a tag run). */
31
+ tag: string | null;
32
+ /** The CI run: its id and, when the runner environment names the repository, its URL. */
33
+ ci: {
34
+ run: string;
35
+ url: string | null;
36
+ } | null;
37
+ /** The owner's urgent record: who and why (the reason is the owner's own text, bounded). */
38
+ urgent: {
39
+ role: string;
40
+ reason: string;
41
+ tag: string | null;
42
+ } | null;
43
+ releaseId: string | null;
44
+ previous: string | null;
45
+ phases: PhaseRecord[];
46
+ /** Reserved for the backup phase: its receipt (names and digests only). Null when no backup ran. */
47
+ backup: Record<string, unknown> | null;
48
+ /** Reserved for the migrate and contract phases: the names of the migrations that were applied. */
49
+ migrations: string[];
50
+ /** The contract migrations this attempt applied (the marker the manual rollback guard reads); empty when it applied none. */
51
+ contract: string[];
52
+ /** `docker service inspect` snapshots by moment: `converge`, `rollback`, `accept`. */
53
+ observed: Record<string, ObservedService[]>;
54
+ outcome: Outcome;
55
+ error: {
56
+ code: string;
57
+ message: string;
58
+ } | null;
59
+ }
60
+ /** The facts of a finished attempt that a notification carries: names, digests and codes, never a message text or a value. */
61
+ export interface AttemptSummary {
62
+ attempt: string;
63
+ kind: AttemptKind;
64
+ stack: string;
65
+ env: string;
66
+ outcome: Outcome;
67
+ trigger: Trigger;
68
+ sourceSha: string | null;
69
+ tag: string | null;
70
+ releaseId: string | null;
71
+ ciUrl: string | null;
72
+ errorCode: string | null;
73
+ }
74
+ /** What an attempt is told about itself when it opens. */
75
+ export interface AttemptMeta {
76
+ kind: AttemptKind;
77
+ trigger: Trigger;
78
+ sourceSha?: string | undefined;
79
+ /** The id of the CI run (digits); with it the trigger is `ci`. */
80
+ ciRun?: string | undefined;
81
+ urgent?: {
82
+ role: string;
83
+ reason: string;
84
+ tag?: string | undefined;
85
+ } | undefined;
86
+ }
87
+ /** The facts an attempt learns while it runs. */
88
+ export interface Note {
89
+ releaseId?: string | null;
90
+ previous?: string | null;
91
+ backup?: Record<string, unknown> | null;
92
+ migrations?: string[];
93
+ contract?: string[];
94
+ urgent?: {
95
+ role: string;
96
+ reason: string;
97
+ tag?: string | undefined;
98
+ };
99
+ }
100
+ /** The open record of one attempt. */
101
+ export interface Recorder {
102
+ readonly id: string;
103
+ /** Journal failures after the first write; the caller prints them. */
104
+ readonly warnings: string[];
105
+ note(facts: Note): void;
106
+ /** Registers what runs at the start of every later phase (the lock refresh). */
107
+ beforePhase(next: () => Promise<void>): void;
108
+ /** Runs `action` as the named phase: start and end are written; a throw is recorded as failed and rethrown. `passed` judges a returned value (default: ok). */
109
+ phase<T>(name: string, action: () => T | Promise<T>, passed?: (value: T) => boolean): Promise<T>;
110
+ snapshot(moment: string, services: ObservedService[]): void;
111
+ finish(outcome: Outcome, error?: {
112
+ code: string;
113
+ message: string;
114
+ } | null): Promise<void>;
115
+ /** The facts of the attempt as it stands (call after `finish` for a notification). */
116
+ summary(): AttemptSummary;
117
+ }
118
+ /** A new attempt id: the UTC time and 8 random hex characters. */
119
+ export declare const newAttemptId: (ctx: Pick<StackContext, "now">) => string;
120
+ /**
121
+ * Opens the journal of an attempt and returns its recorder. Writes the first document and the index line; a failure of either is the refusal
122
+ * `STACK_DEPLOY_JOURNAL` (the manager cannot keep a record, so nothing is changed).
123
+ */
124
+ export declare function openAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta): Promise<Recorder>;
125
+ /** What the lock wait takes. */
126
+ export interface LockWait {
127
+ waitSeconds?: number | undefined;
128
+ pollSeconds?: number | undefined;
129
+ }
130
+ /** The default wait for a held deploy lock (the design's ten minutes). */
131
+ export declare const LOCK_WAIT_SECONDS = 600;
132
+ /** A taken lock: refresh keeps it alive, release gives it back. */
133
+ export interface LockHold {
134
+ token: string;
135
+ refresh(): Promise<boolean>;
136
+ release(): Promise<boolean>;
137
+ }
138
+ /** Takes the deploy lock, waiting while another deploy holds it; `STACK_DEPLOY_LOCKED` when the wait ends. */
139
+ export declare function holdLock(ctx: StackContext, store: ReleaseStore, trigger: Trigger, wait?: LockWait): Promise<LockHold>;
140
+ /** How a finished result reads as an outcome. */
141
+ export declare function outcomeOf(result: ActionResult, kind: AttemptKind): Outcome;
142
+ /**
143
+ * Runs `body` as one recorded attempt under the deploy lock: journal opened, lock taken, `body(recorder)` run, outcome written, lock released. The result
144
+ * gains `data.attempt` (the attempt id) and the journal's warnings as lines; a throw is recorded as `refused` and rethrown.
145
+ */
146
+ export declare function recordedAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta, wait: LockWait, body: (recorder: Recorder) => Promise<ActionResult>): Promise<ActionResult>;