@wtfalch/foundry 0.73.0 → 0.74.0

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/dist/index.d.ts CHANGED
@@ -22,9 +22,9 @@ export { StaleLeaseError } from './store.js';
22
22
  export type { PlanApply, DestroyProgress } from './apply.js';
23
23
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
24
24
  export { createPostgresAuditLog } from './store/audit.js';
25
- export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
25
+ export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseRequirement, ReleaseSignal, ReleaseState, ReleasedVersion, WindowState, } from './release/types.js';
26
26
  export type { ReleaseStore } from './release/state.js';
27
- export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
27
+ export { readReleaseHistory, readReleasedVersions, readReleaseState, requestReleaseAction, rescheduleRelease, } from './release/state.js';
28
28
  export type { BuiltinFanOut, FanOutApp, FanOutOrgState } from './release/fanout.js';
29
29
  export { readBuiltinFanOut } from './release/fanout.js';
30
30
  export type { ReleaseSeriesHost, ReleaseSeriesQuery } from './release/series.js';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_RE
14
14
  export { StaleLeaseError } from './store.js';
15
15
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
16
16
  export { createPostgresAuditLog } from './store/audit.js';
17
- export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
17
+ export { readReleaseHistory, readReleasedVersions, readReleaseState, requestReleaseAction, rescheduleRelease, } from './release/state.js';
18
18
  export { readBuiltinFanOut } from './release/fanout.js';
19
19
  export { readReleaseSeries } from './release/series.js';
20
20
  export { readEntryReleaseSeries } from './plan/release-series-reader.js';
@@ -0,0 +1,12 @@
1
+ -- @wtfalch/foundry: a release request can wait for a time. `not_before` is null for every request made
2
+ -- until now, so each of them stays due at once. The release runner reads only the open rows whose time has come.
3
+ -- `claimed_at` is set when the runner starts a due go-live: from then on it can no longer be cancelled or moved.
4
+ -- Additive: two nullable columns and one partial index; no existing row is touched.
5
+ -- Mirrors src/store/schema.ts's `foundryReleaseRequests`.
6
+
7
+ alter table foundry_release_requests add column if not exists not_before timestamptz;
8
+ alter table foundry_release_requests add column if not exists claimed_at timestamptz;
9
+
10
+ create index if not exists foundry_release_requests_scheduled_idx
11
+ on foundry_release_requests (org_id, entry_key, not_before)
12
+ where consumed_at is null and not_before is not null;
@@ -1207,6 +1207,8 @@ export class FoundryPlanApply {
1207
1207
  host,
1208
1208
  context,
1209
1209
  now: this.now(),
1210
+ nowFn: () => this.now(),
1211
+ ...(ownerOrgId !== null ? { ownerOrgId } : {}),
1210
1212
  primaryUuid,
1211
1213
  ...(copies === 'defer' ? { deferCopies: true } : {}),
1212
1214
  });
@@ -1248,6 +1250,7 @@ export class FoundryPlanApply {
1248
1250
  host,
1249
1251
  context,
1250
1252
  now: this.now(),
1253
+ nowFn: () => this.now(),
1251
1254
  primaryUuid,
1252
1255
  ...(copies === 'defer' ? { deferCopies: true } : {}),
1253
1256
  });
@@ -84,7 +84,11 @@ export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
84
84
  * (like the next apply, through its `bluegreen-lb` step) writes it again.
85
85
  */
86
86
  export declare function runSubjectRelease(input: {
87
- store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests'> & Partial<Pick<ReleaseStore, 'appendEvents'>>;
87
+ store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests' | 'claimRequests'> & Partial<Pick<ReleaseStore, 'appendEvents'>>;
88
+ /** `DeclaredPlatform.ownerOrgId`: without it no go_live or set_required request runs. */
89
+ ownerOrgId?: string;
90
+ /** The clock for the repeated passes of a go-live; absent: `now` for every pass. */
91
+ nowFn?: () => Date;
88
92
  scopeOwnerId: string;
89
93
  subject: BlueGreenSubject;
90
94
  host: ReleaseHost;
@@ -9,6 +9,9 @@ import { waitForDeploymentFinished, waitForNewDeployment, } from './deploy-healt
9
9
  import { isNotFoundError } from './executor.js';
10
10
  import { runStageCopyRequest } from './stage-copy-host.js';
11
11
  const COMMIT = /^[0-9a-f]{40}$/;
12
+ const APP_RELEASE_ENV = ['APP_TAG', 'APP_RELEASE', 'APP_MINIMUM_RELEASE'];
13
+ /** One tick runs a pending go-live at most this many times (stage, idle, staff, open). */
14
+ const GO_LIVE_RUNS = 4;
12
15
  /** A stage deploy waits at most this many polls of this long (10 minutes) for Coolify's deployment to finish. */
13
16
  export const STAGE_DEPLOY_ATTEMPTS = 60;
14
17
  export const STAGE_DEPLOY_INTERVAL_MS = 10_000;
@@ -109,7 +112,7 @@ export function createReleaseHost(input) {
109
112
  const pinInput = () => pinInputOf(input, recorded);
110
113
  const addressOf = (color) => colorAddress(input, recorded, color);
111
114
  const healthColor = () => healthColorOf(recorded(), id);
112
- const deployTo = async (color, digest) => {
115
+ const deployTo = async (color, digest, appEnv) => {
113
116
  if (!COMMIT.test(digest))
114
117
  throw new Error(`${input.label}: the release is not a built commit`);
115
118
  const token = subject.reporting ? await input.healthToken() : null;
@@ -135,22 +138,40 @@ export function createReleaseHost(input) {
135
138
  throw new Error(`${input.label}: reading the env of the blue app failed`);
136
139
  }
137
140
  }
141
+ // A go-live's own values replace blue's: the color boots with the tag's tag, release and minimum.
138
142
  const refreshed = copiedEnv({ label: input.label, secret: input.secret }, subject, source, {
139
143
  uuid: server,
140
144
  primaryUuid: input.primaryUuid,
141
- });
145
+ }).filter((e) => !appEnv || !APP_RELEASE_ENV.includes(e.key));
146
+ const release = appEnv
147
+ ? [
148
+ { key: 'APP_TAG', value: appEnv.tag },
149
+ { key: 'APP_RELEASE', value: String(appEnv.release) },
150
+ ...(appEnv.minimum === null
151
+ ? []
152
+ : [{ key: 'APP_MINIMUM_RELEASE', value: String(appEnv.minimum) }]),
153
+ ]
154
+ : [];
142
155
  const reporting = token ? reportingEnv({ color, release: digest, healthToken: token }) : [];
143
156
  try {
144
- const envs = [...refreshed, ...reporting];
157
+ const envs = [...refreshed, ...reporting, ...release];
145
158
  if (envs.length > 0) {
146
159
  await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
147
160
  }
148
161
  // A key removed from blue (a rotated-out credential) must not stay on the slot.
149
- if (appId !== input.primaryBlueAppId) {
150
- const keep = new Set([...source.map((e) => e.key), ...envs.map((e) => e.key)]);
162
+ if (appId !== input.primaryBlueAppId || (appEnv && appEnv.minimum === null)) {
163
+ const keep = new Set([
164
+ ...(appId === input.primaryBlueAppId
165
+ ? []
166
+ : source
167
+ .map((e) => e.key)
168
+ .filter((key) => !appEnv || !APP_RELEASE_ENV.includes(key))),
169
+ ...envs.map((e) => e.key),
170
+ ]);
151
171
  for (const env of await coolify.listEnvs(appId)) {
152
172
  if (env.uuid &&
153
173
  !keep.has(env.key) &&
174
+ (appId !== input.primaryBlueAppId || env.key === 'APP_MINIMUM_RELEASE') &&
154
175
  !env.key.startsWith('REPORTING_') &&
155
176
  env.key !== ACTIONS_KEY_ENV) {
156
177
  await coolify.deleteEnv(appId, env.uuid);
@@ -272,7 +293,7 @@ export function createReleaseHost(input) {
272
293
  stageCopy: (request) => runStageCopyRequest({ subject, context, ports: input.stageCopy, renew: input.renew }, request),
273
294
  async effect(effect) {
274
295
  if (effect.kind === 'deploy')
275
- await deployTo(effect.color, effect.digest);
296
+ await deployTo(effect.color, effect.digest, effect.appEnv);
276
297
  else if (effect.kind === 'stop')
277
298
  await stop(effect.color);
278
299
  else if (effect.kind === 'deploy_stage')
@@ -355,53 +376,81 @@ export async function runSubjectRelease(input) {
355
376
  context.recordScopeId(bgKey.weights(id), DEFAULT_WEIGHTS);
356
377
  await context.persist();
357
378
  }
358
- await input.host.refreshStage();
359
- const before = ids()[bgKey.weights(id)];
360
- const result = await runRelease({
361
- store: {
362
- getScopeIds: async () => ({ ...ids() }),
363
- openRequests: (query) => input.store.openRequests(query),
364
- },
365
- orgId: input.scopeOwnerId,
366
- entryKey: id,
367
- entry: subject.config,
368
- now: input.now,
369
- deps: input.host.deps,
370
- ...(subject.reporting ? {} : { hardOnly: true }),
371
- ...(input.deferCopies ? { deferCopies: true } : {}),
372
- });
373
- for (const [key, value] of Object.entries(result.writes)) {
374
- if (value === null)
375
- context.forgetId(key);
376
- else
377
- context.recordScopeId(key, value);
378
- }
379
- await context.persist();
380
- if (input.store.appendEvents && result.events.length > 0) {
381
- try {
382
- await input.store.appendEvents({
383
- orgId: input.scopeOwnerId,
384
- entryKey: id,
385
- events: result.events,
386
- });
379
+ /** Where a go-live stands: it moves when stage, a color's digest or the window changes. */
380
+ const stand = () => [
381
+ ids()[bgKey.stage(id)] ?? '',
382
+ ids()[bgKey.digest(id, 'blue')] ?? '',
383
+ ids()[bgKey.digest(id, 'green')] ?? '',
384
+ // A window never written reads as idle, so the first run's own write is not a change.
385
+ ids()[bgKey.window(id)] ?? '{"kind":"idle"}',
386
+ ].join('|');
387
+ const pass = async (now) => {
388
+ await input.host.refreshStage();
389
+ const before = ids()[bgKey.weights(id)];
390
+ const result = await runRelease({
391
+ store: {
392
+ getScopeIds: async () => ({ ...ids() }),
393
+ openRequests: (query) => input.store.openRequests(query),
394
+ claimRequests: (claim) => input.store.claimRequests(claim),
395
+ },
396
+ orgId: input.scopeOwnerId,
397
+ ...(input.ownerOrgId !== undefined ? { ownerOrgId: input.ownerOrgId } : {}),
398
+ entryKey: id,
399
+ entry: subject.config,
400
+ now,
401
+ deps: input.host.deps,
402
+ ...(subject.reporting ? {} : { hardOnly: true }),
403
+ ...(input.deferCopies ? { deferCopies: true } : {}),
404
+ });
405
+ for (const [key, value] of Object.entries(result.writes)) {
406
+ if (value === null)
407
+ context.forgetId(key);
408
+ else
409
+ context.recordScopeId(key, value);
387
410
  }
388
- catch {
389
- // The history is a record, not a gate: losing a line must not leave the run's requests open to replay.
390
- console.error(`release ${id}: recording ${result.events.length} history lines failed`);
411
+ await context.persist();
412
+ if (input.store.appendEvents && result.events.length > 0) {
413
+ try {
414
+ await input.store.appendEvents({
415
+ orgId: input.scopeOwnerId,
416
+ entryKey: id,
417
+ events: result.events,
418
+ });
419
+ }
420
+ catch {
421
+ // The history is a record, not a gate: losing a line must not leave the run's requests open to replay.
422
+ console.error(`release ${id}: recording ${result.events.length} history lines failed`);
423
+ }
391
424
  }
392
- }
393
- await input.store.consumeRequests(result.requestIds);
394
- const after = parseWeights(ids()[bgKey.weights(id)]);
395
- // Also when a balancer lags the weights (an earlier write failed), so the next tick retries it.
396
- if (after && (formatWeights(after) !== before || balancerLags(ids(), id, formatWeights(after)))) {
397
- await input.host.writeWeights();
398
- }
399
- return {
400
- blocked: result.blocked,
401
- refused: result.refused,
402
- deferredCopy: result.deferredCopy,
403
- copy: result.copy,
425
+ await input.store.consumeRequests(result.requestIds);
426
+ const after = parseWeights(ids()[bgKey.weights(id)]);
427
+ // Also when a balancer lags the weights (an earlier write failed), so the next tick retries it.
428
+ if (after &&
429
+ (formatWeights(after) !== before || balancerLags(ids(), id, formatWeights(after)))) {
430
+ await input.host.writeWeights();
431
+ }
432
+ return result;
404
433
  };
434
+ let was = stand();
435
+ let result = await pass(input.now);
436
+ const seen = { blocked: new Set(result.blocked), refused: new Set(result.refused) };
437
+ let copy = result.copy;
438
+ let deferredCopy = result.deferredCopy;
439
+ // A go-live is stage, then idle, then staff, then open: each step is one run, and a step that worked makes the
440
+ // next one possible, so the tag goes live in one tick when nothing fails. A run that changed nothing stops it.
441
+ for (let more = 1; more < GO_LIVE_RUNS && result.goLiveOpen; more += 1) {
442
+ if (stand() === was)
443
+ break;
444
+ was = stand();
445
+ result = await pass(input.nowFn?.() ?? input.now);
446
+ for (const line of result.blocked)
447
+ seen.blocked.add(line);
448
+ for (const line of result.refused)
449
+ seen.refused.add(line);
450
+ copy ??= result.copy;
451
+ deferredCopy ||= result.deferredCopy;
452
+ }
453
+ return { blocked: [...seen.blocked], refused: [...seen.refused], deferredCopy, copy };
405
454
  }
406
455
  /**
407
456
  * Runs the stage copy a release run decided on and records what came of it. Call it after
@@ -79,6 +79,15 @@ export type ReleaseEffect = {
79
79
  kind: 'deploy';
80
80
  color: Color;
81
81
  digest: string;
82
+ /**
83
+ * A go-live's `APP_TAG`, `APP_RELEASE` and `APP_MINIMUM_RELEASE` (removed when `minimum` is null). The host
84
+ * writes them to this color with the deploy, so the color boots with them; the serving color is never touched.
85
+ */
86
+ appEnv?: {
87
+ tag: string;
88
+ release: number;
89
+ minimum: number | null;
90
+ };
82
91
  } | {
83
92
  kind: 'stop';
84
93
  color: Color;
@@ -161,7 +161,7 @@ function applyActions(r, actions, now, out, kind) {
161
161
  out.refused.push('promote refused: stage has no digest');
162
162
  else if (cur.held.includes(digest))
163
163
  out.refused.push('promote refused: digest is held');
164
- else if (digest === cur.digests[cur.live])
164
+ else if (digest === cur.digests[cur.live] && !a.redeploy)
165
165
  out.refused.push('promote refused: digest is already live');
166
166
  else {
167
167
  const to = otherColor(cur.live);
@@ -0,0 +1,77 @@
1
+ import type { Recorded } from './decide.js';
2
+ import type { ReleaseAction, ReleaseEvent, ReleasedVersion } from './types.js';
3
+ export type GoLiveAction = Extract<ReleaseAction, {
4
+ kind: 'go_live';
5
+ }>;
6
+ export type SetRequiredAction = Extract<ReleaseAction, {
7
+ kind: 'set_required';
8
+ }>;
9
+ export type App = 'boule' | 'agora';
10
+ /** A go-live tries each deploy step this many ticks before it is refused. */
11
+ export declare const GO_LIVE_TRIES = 3;
12
+ /** `bg.<entry>.golive`: `<request id>|<stage deploys tried>|<release reserved by the idle deploy>|<idle deploys failed>`. */
13
+ export interface GoLiveState {
14
+ requestId: number;
15
+ tries: number;
16
+ release: number | null;
17
+ idle: number;
18
+ }
19
+ export declare function parseGoLive(value: string | undefined): GoLiveState | null;
20
+ export declare function formatGoLive(s: GoLiveState): string;
21
+ /** What one due `go_live` request comes to in this run. */
22
+ export interface GoLivePlan {
23
+ /** For `decide`: `deploy_stage`, `promote` or `open` (never more than one). */
24
+ actions: ReleaseAction[];
25
+ /** The `promote` among `actions`, when there is one: its deploy carries the tag's env. */
26
+ promote?: ReleaseAction;
27
+ /** The tag completed in this run: record it. */
28
+ record: boolean;
29
+ /** The request is consumed. False: it stays open and the next run judges it again. */
30
+ consume: boolean;
31
+ /** The runner starts this go-live: the request is claimed before any effect, so it can no longer be cancelled or moved. */
32
+ claim: boolean;
33
+ /** The env the idle color is deployed with (only with `promote`). */
34
+ appEnv?: {
35
+ tag: string;
36
+ release: number;
37
+ minimum: number | null;
38
+ };
39
+ /** The tries to carry into `bg.<entry>.golive` when the idle deploy worked or failed (only with `promote`). */
40
+ carry?: {
41
+ tries: number;
42
+ idle: number;
43
+ };
44
+ /** `bg.<entry>.golive` after this run (null removes it); not used with `promote`. */
45
+ state?: GoLiveState | null;
46
+ refused?: string;
47
+ blocked?: string;
48
+ events: Array<Pick<ReleaseEvent, 'kind' | 'reason'>>;
49
+ }
50
+ /**
51
+ * Decides one `go_live`. Stage is deployed at the tag's commit when it differs, then the idle color is deployed
52
+ * with the tag's env (`promote`, also for a commit the live color already runs), then it is opened (the staff hold
53
+ * is passed here: stage was the review) only when the idle color runs and answers, and the request waits until
54
+ * the release completes, one color at 100 % with the window closed. Only then is the version recorded, and only
55
+ * when `bg.<entry>.golive` holds the release number this request's own idle deploy reserved: a tag no color
56
+ * carries is never recorded. Each deploy step is tried `GO_LIVE_TRIES` ticks, a history line for each retry.
57
+ */
58
+ export declare function planGoLive(input: {
59
+ app: App;
60
+ action: GoLiveAction;
61
+ requestId: number;
62
+ recorded: Recorded;
63
+ ids: Readonly<Record<string, string>>;
64
+ released: readonly ReleasedVersion[];
65
+ now: Date;
66
+ /** The new color's observation (only meaningful while a release runs). */
67
+ observation: {
68
+ status: 'running' | 'crash_loop' | 'other';
69
+ address: 'ok' | 'failed' | 'unchecked';
70
+ };
71
+ }): GoLivePlan;
72
+ /** The highest release at or below `release` that is required; null when none. */
73
+ export declare function minimumFor(versions: readonly ReleasedVersion[], release: number): number | null;
74
+ /** A free release number: the unix second of `now`, or the next free one when a version already took it. */
75
+ export declare function releaseNumber(now: Date, versions: readonly ReleasedVersion[]): number;
76
+ /** The scope writes that record `v`. */
77
+ export declare function recordWrites(v: ReleasedVersion): Record<string, string>;
@@ -0,0 +1,151 @@
1
+ import { formatReleased, releaseKeys, releasedKey } from './state.js';
2
+ /** A go-live tries each deploy step this many ticks before it is refused. */
3
+ export const GO_LIVE_TRIES = 3;
4
+ export function parseGoLive(value) {
5
+ const m = /^(\d+)\|(\d+)\|(\d*)(?:\|(\d+))?$/.exec(value ?? '');
6
+ if (!m)
7
+ return null;
8
+ return {
9
+ requestId: Number(m[1]),
10
+ tries: Number(m[2]),
11
+ release: m[3] ? Number(m[3]) : null,
12
+ idle: m[4] ? Number(m[4]) : 0,
13
+ };
14
+ }
15
+ export function formatGoLive(s) {
16
+ return `${s.requestId}|${s.tries}|${s.release ?? ''}|${s.idle}`;
17
+ }
18
+ /**
19
+ * Decides one `go_live`. Stage is deployed at the tag's commit when it differs, then the idle color is deployed
20
+ * with the tag's env (`promote`, also for a commit the live color already runs), then it is opened (the staff hold
21
+ * is passed here: stage was the review) only when the idle color runs and answers, and the request waits until
22
+ * the release completes, one color at 100 % with the window closed. Only then is the version recorded, and only
23
+ * when `bg.<entry>.golive` holds the release number this request's own idle deploy reserved: a tag no color
24
+ * carries is never recorded. Each deploy step is tried `GO_LIVE_TRIES` ticks, a history line for each retry.
25
+ */
26
+ export function planGoLive(input) {
27
+ const { action, recorded } = input;
28
+ const k = releaseKeys(input.app);
29
+ const state = parseGoLive(input.ids[k.goLive]);
30
+ const mine = state?.requestId === input.requestId ? state : null;
31
+ const reserved = mine?.release ?? null;
32
+ const refuse = (reason) => ({
33
+ actions: [],
34
+ record: false,
35
+ consume: true,
36
+ claim: false,
37
+ refused: reason,
38
+ state: null,
39
+ events: [{ kind: 'go_live_refused', reason }],
40
+ });
41
+ const same = input.released.find((v) => v.tag === action.tag);
42
+ if (same) {
43
+ if (same.sha === action.ref) {
44
+ return { actions: [], record: false, consume: true, claim: false, state: null, events: [] };
45
+ }
46
+ return refuse('go_live refused: that tag already went live on another commit');
47
+ }
48
+ const rel = recorded.release;
49
+ if (rel) {
50
+ // Only the release this request's own idle deploy started carries the tag's env.
51
+ if (rel.digest !== action.ref || reserved === null) {
52
+ return {
53
+ actions: [],
54
+ record: false,
55
+ consume: false,
56
+ claim: false,
57
+ blocked: 'go live waiting: another release is running',
58
+ events: [],
59
+ };
60
+ }
61
+ const ready = input.observation.status === 'running' && input.observation.address === 'ok';
62
+ if (recorded.window.kind === 'staff' && ready) {
63
+ return {
64
+ actions: [{ kind: 'open' }],
65
+ record: false,
66
+ consume: false,
67
+ claim: true,
68
+ events: [],
69
+ };
70
+ }
71
+ return { actions: [], record: false, consume: false, claim: true, events: [] };
72
+ }
73
+ // Held first: a rolled-back redeploy of a commit already live still finds that commit live.
74
+ if (recorded.held.includes(action.ref)) {
75
+ return refuse('go_live refused: that commit was rolled back and is held');
76
+ }
77
+ if (reserved !== null && recorded.digests[recorded.live] === action.ref) {
78
+ return { actions: [], record: true, consume: true, claim: true, state: null, events: [] };
79
+ }
80
+ const tries = mine?.tries ?? 0;
81
+ const idle = mine?.idle ?? 0;
82
+ if (recorded.stageDigest === action.ref) {
83
+ if (idle >= GO_LIVE_TRIES)
84
+ return refuse('go_live refused: the idle color could not be deployed');
85
+ const promote = {
86
+ kind: 'promote',
87
+ ...(recorded.digests[recorded.live] === action.ref ? { redeploy: true } : {}),
88
+ };
89
+ const release = releaseNumber(input.now, input.released);
90
+ const probe = {
91
+ app: input.app,
92
+ tag: action.tag,
93
+ sha: action.ref,
94
+ release,
95
+ required: action.required,
96
+ requires: action.requires,
97
+ at: input.now.toISOString(),
98
+ };
99
+ return {
100
+ actions: [promote],
101
+ promote,
102
+ record: false,
103
+ consume: false,
104
+ claim: true,
105
+ appEnv: {
106
+ tag: action.tag,
107
+ release,
108
+ minimum: minimumFor([probe, ...input.released], release),
109
+ },
110
+ carry: { tries, idle },
111
+ events: [],
112
+ };
113
+ }
114
+ if (tries >= GO_LIVE_TRIES)
115
+ return refuse('go_live refused: stage did not take that commit');
116
+ return {
117
+ actions: [{ kind: 'deploy_stage', ref: action.ref }],
118
+ record: false,
119
+ consume: false,
120
+ claim: true,
121
+ state: { requestId: input.requestId, tries: tries + 1, release: null, idle },
122
+ events: tries > 0
123
+ ? [
124
+ {
125
+ kind: 'go_live_failed',
126
+ reason: `stage did not take that commit (try ${tries} of ${GO_LIVE_TRIES})`,
127
+ },
128
+ ]
129
+ : [],
130
+ };
131
+ }
132
+ /** The highest release at or below `release` that is required; null when none. */
133
+ export function minimumFor(versions, release) {
134
+ let min = null;
135
+ for (const v of versions) {
136
+ if (v.required && v.release <= release && (min === null || v.release > min))
137
+ min = v.release;
138
+ }
139
+ return min;
140
+ }
141
+ /** A free release number: the unix second of `now`, or the next free one when a version already took it. */
142
+ export function releaseNumber(now, versions) {
143
+ let n = Math.floor(now.getTime() / 1000);
144
+ while (versions.some((v) => v.release === n))
145
+ n += 1;
146
+ return n;
147
+ }
148
+ /** The scope writes that record `v`. */
149
+ export function recordWrites(v) {
150
+ return { [releasedKey(v.app, v.release)]: formatReleased(v) };
151
+ }
@@ -49,6 +49,10 @@ export interface ReleaseRunResult {
49
49
  requestIds: number[];
50
50
  /** A stage copy request was left open because this run does not run copies. */
51
51
  deferredCopy: boolean;
52
+ /** The go-live requests this run started: the caller marks them claimed (`store.claimRequests`) after it persists. */
53
+ claimIds: number[];
54
+ /** A go-live request is still open and not waiting for another release: the next run may take its next step. */
55
+ goLiveOpen: boolean;
52
56
  /**
53
57
  * The stage copy this run decided on, not yet run. The caller runs it (`runStageCopy`) after it has persisted
54
58
  * `writes` and written the weights, so a rollback decided in the same run never waits for the copy. Its
@@ -76,8 +80,10 @@ export interface StageCopyJob {
76
80
  * the recorded keys as they were: the next run decides again from them.
77
81
  */
78
82
  export declare function runRelease(input: {
79
- store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'>;
83
+ store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'> & Partial<Pick<ReleaseStore, 'claimRequests'>>;
80
84
  orgId: string;
85
+ /** `DeclaredPlatform.ownerOrgId`: only the owner org's go_live and set_required requests run. */
86
+ ownerOrgId?: string;
81
87
  entryKey: string;
82
88
  entry: ReleaseEntryConfig;
83
89
  now: Date;