@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.
@@ -1,7 +1,9 @@
1
+ import { StaleLeaseError } from '../store.js';
1
2
  import { decide, healthRange, } from './decide.js';
2
3
  import { eventsOf } from './events.js';
3
- import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
4
- import { isStageCopyAction } from './types.js';
4
+ import { formatGoLive, parseGoLive, planGoLive, recordWrites, } from './golive.js';
5
+ import { formatRefusals, formatReleased, formatWeights, formatWindow, recordedFrom, releaseKeys, releasedFrom, releasedKey, } from './state.js';
6
+ import { isStageCopyAction, } from './types.js';
5
7
  const REFUSAL_TEXT = {
6
8
  'no-copy-port': 'copy refused: this host cannot restore',
7
9
  };
@@ -23,29 +25,44 @@ export async function runRelease(input) {
23
25
  decision: null,
24
26
  requestIds: [],
25
27
  deferredCopy: false,
28
+ goLiveOpen: false,
29
+ claimIds: [],
26
30
  copy: null,
27
31
  events: [],
28
32
  };
29
33
  }
30
34
  const k = releaseKeys(entryKey);
31
35
  // `fanout_retry` belongs to the built-in fan-out (`fanout.ts`), which consumes it; this run leaves it open.
32
- const all = (await store.openRequests({ orgId, entryKey })).filter((r) => r.action.kind !== 'fanout_retry');
36
+ // A request with a time that has not come is left alone whatever the store returned.
37
+ const all = (await store.openRequests({ orgId, entryKey, now })).filter((r) => r.action.kind !== 'fanout_retry' && !(r.notBefore && Date.parse(r.notBefore) > now.getTime()));
33
38
  // A stage request whose result was persisted before a crash is consumed, never run a second time.
34
39
  const handledId = ids[k.stageCopyHandled];
35
40
  const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
36
41
  const fresh = all.filter((r) => !replayed.includes(r));
37
42
  const deferred = input.deferCopies ? fresh.filter((r) => isStageCopyAction(r.action)) : [];
38
- const open = fresh.filter((r) => !deferred.includes(r));
39
- const actions = open.map((r) => r.action);
43
+ const due = fresh.filter((r) => !deferred.includes(r));
44
+ const isRelease = (a) => a.kind === 'go_live' || a.kind === 'set_required';
45
+ const open = due.filter((r) => !isRelease(r.action));
46
+ const releaseRequests = due.filter((r) => isRelease(r.action));
40
47
  const range = healthRange(recorded, now);
41
48
  const target = recorded.release?.to ?? null;
42
49
  const health = range && !input.hardOnly ? await deps.readHealth(range) : null;
43
50
  const seen = target
44
51
  ? await deps.observeColor(target)
45
52
  : { status: 'other', address: 'unchecked' };
46
- const decision = decide({
53
+ const go = planReleaseRequests({
54
+ app: entryKey,
55
+ requests: releaseRequests,
47
56
  recorded,
48
- actions,
57
+ ids,
58
+ now,
59
+ owner: input.ownerOrgId === orgId,
60
+ observation: seen,
61
+ });
62
+ const actions = [...open.map((r) => r.action), ...go.actions];
63
+ const decideWith = (acts) => decide({
64
+ recorded,
65
+ actions: acts,
49
66
  steps: input.entry.steps,
50
67
  stepHoldMinutes: input.entry.stepHoldMinutes,
51
68
  advance: input.entry.advance,
@@ -56,12 +73,41 @@ export async function runRelease(input) {
56
73
  observation: { health, ...seen },
57
74
  ...(input.hardOnly ? { hardOnly: true } : {}),
58
75
  });
59
- const extra = {};
60
- const refused = [...decision.refused];
76
+ let decision = decideWith(actions);
77
+ const refused = [...decision.refused, ...go.refused];
61
78
  const stamp = now.toISOString();
62
79
  let copy = null;
63
80
  const requestOf = (kind) => open.find((r) => r.action.kind === kind);
81
+ // A go-live's idle deploy goes first and may fail: the go-live then stays open and the run goes on without the
82
+ // promote, so a rollback or any other decision of this run is still persisted.
83
+ // Claimed before any effect, so a cancel can never race a deploy this run starts.
84
+ if (go.claim.length > 0) {
85
+ if (!store.claimRequests)
86
+ throw new Error('release store cannot claim a go-live');
87
+ await store.claimRequests(go.claim);
88
+ }
89
+ let deployFailed = false;
90
+ let ran = null;
91
+ const promoted = go.promote !== undefined && decision.applied.includes(go.promote);
92
+ const goDeploy = promoted ? decision.effects.find((e) => e.kind === 'deploy') : undefined;
93
+ if (goDeploy?.kind === 'deploy' && go.appEnv) {
94
+ const withEnv = { ...goDeploy, appEnv: go.appEnv };
95
+ try {
96
+ await deps.effect(withEnv);
97
+ ran = goDeploy;
98
+ }
99
+ catch (error) {
100
+ if (error instanceof StaleLeaseError)
101
+ throw error;
102
+ console.error(`release ${entryKey}: go-live request ${go.requestId}: the idle deploy failed`);
103
+ deployFailed = true;
104
+ decision = decideWith(actions.filter((a) => a !== go.promote));
105
+ refused.push(...decision.refused.filter((line) => !refused.includes(line)));
106
+ }
107
+ }
64
108
  for (const effect of decision.effects) {
109
+ if (effect === ran)
110
+ continue;
65
111
  if (effect.kind !== 'stage_copy') {
66
112
  await deps.effect(effect);
67
113
  continue;
@@ -70,6 +116,9 @@ export async function runRelease(input) {
70
116
  if (request)
71
117
  copy = { mode: effect.mode, requestId: request.id, actor: request.actor };
72
118
  }
119
+ const finished = go.finish(decision, deployFailed);
120
+ const extra = {};
121
+ Object.assign(extra, finished.writes);
73
122
  if (decision.stageRefusal)
74
123
  extra[k.stageCopy] = `${stamp}|refused|${decision.stageRefusal}`;
75
124
  const next = decision.recorded;
@@ -83,7 +132,8 @@ export async function runRelease(input) {
83
132
  [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
84
133
  };
85
134
  // Part of the run's writes: persisted with them, so a failed persist leaves neither. A run with none keeps it.
86
- const lines = [...refused, ...decision.blocked];
135
+ const blocked = [...decision.blocked, ...go.blocked];
136
+ const lines = [...refused, ...blocked];
87
137
  if (lines.length > 0)
88
138
  writes[k.refused] = formatRefusals(stamp, lines);
89
139
  for (const d of recorded.held)
@@ -94,18 +144,24 @@ export async function runRelease(input) {
94
144
  writes[k.held(d)] = now.toISOString();
95
145
  return {
96
146
  writes,
97
- blocked: decision.blocked,
147
+ blocked,
98
148
  refused,
99
149
  decision,
100
- requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
150
+ requestIds: [
151
+ ...replayed,
152
+ ...open.filter((r) => r.id !== copy?.requestId),
153
+ ...finished.consumed,
154
+ ].map((r) => r.id),
101
155
  deferredCopy: deferred.length > 0,
156
+ goLiveOpen: finished.goLiveOpen,
157
+ claimIds: go.claim,
102
158
  copy,
103
159
  events: eventsOf({
104
160
  before: recorded,
105
161
  decision,
106
- actors: new Map(open.map((r) => [r.action, r.actor])),
162
+ actors: new Map([...open.map((r) => [r.action, r.actor]), ...go.actors]),
107
163
  now,
108
- }),
164
+ }).concat(finished.events),
109
165
  };
110
166
  }
111
167
  /**
@@ -157,3 +213,138 @@ export function settleStageCopy(input) {
157
213
  writes[k.stageCopyRetry] = String(job.requestId);
158
214
  return { writes, refused, consume: false };
159
215
  }
216
+ const NOT_BUILTIN = 'go_live and set_required refused: only the entries boule and agora are released this way';
217
+ /**
218
+ * The due `go_live` and `set_required` requests of one run. `set_required` takes effect and is consumed at once;
219
+ * only the due `go_live` with the earliest time is judged per run, the others wait their turn. Nothing here
220
+ * persists or consumes: the caller does both, in the order of the release runner's rules.
221
+ */
222
+ function planReleaseRequests(input) {
223
+ const none = {
224
+ actions: [],
225
+ actors: [],
226
+ claim: [],
227
+ refused: [],
228
+ blocked: [],
229
+ finish: () => ({ writes: {}, consumed: [], events: [], goLiveOpen: false }),
230
+ };
231
+ if (input.requests.length === 0 || !input.owner)
232
+ return none;
233
+ const { app, requests, recorded, now } = input;
234
+ if (app !== 'boule' && app !== 'agora') {
235
+ return {
236
+ ...none,
237
+ refused: [NOT_BUILTIN],
238
+ finish: () => ({
239
+ writes: {},
240
+ consumed: requests,
241
+ claimed: [],
242
+ events: [],
243
+ goLiveOpen: false,
244
+ }),
245
+ };
246
+ }
247
+ const k = releaseKeys(app);
248
+ const stamp = now.toISOString();
249
+ let versions = releasedFrom(input.ids, app);
250
+ const writes = {};
251
+ const consumed = [];
252
+ const refused = [];
253
+ const events = [];
254
+ for (const r of requests) {
255
+ if (r.action.kind !== 'set_required')
256
+ continue;
257
+ const a = r.action;
258
+ consumed.push(r);
259
+ const hit = versions.find((v) => v.tag === a.tag);
260
+ if (!hit) {
261
+ refused.push('set_required refused: that tag has not gone live');
262
+ continue;
263
+ }
264
+ const next = { ...hit, required: a.required };
265
+ versions = versions.map((v) => (v === hit ? next : v));
266
+ writes[releasedKey(app, hit.release)] = formatReleased(next);
267
+ }
268
+ const goRequest = requests
269
+ .filter((r) => r.action.kind === 'go_live')
270
+ .sort((a, b) => (a.notBefore ?? '').localeCompare(b.notBefore ?? '') || a.id - b.id)[0];
271
+ const goAction = goRequest?.action;
272
+ const plan = goRequest && goAction
273
+ ? planGoLive({
274
+ app,
275
+ action: goAction,
276
+ requestId: goRequest.id,
277
+ recorded,
278
+ ids: input.ids,
279
+ released: versions,
280
+ now,
281
+ observation: input.observation,
282
+ })
283
+ : null;
284
+ const syntheticActions = plan?.actions ?? [];
285
+ return {
286
+ actions: syntheticActions,
287
+ ...(plan?.promote ? { promote: plan.promote } : {}),
288
+ ...(plan?.appEnv ? { appEnv: plan.appEnv } : {}),
289
+ actors: goRequest ? syntheticActions.map((a) => [a, goRequest.actor]) : [],
290
+ claim: goRequest && plan?.claim ? [goRequest.id] : [],
291
+ ...(goRequest ? { requestId: goRequest.id } : {}),
292
+ refused: [...refused, ...(plan?.refused ? [plan.refused] : [])],
293
+ blocked: plan?.blocked ? [plan.blocked] : [],
294
+ finish(decision, deployFailed) {
295
+ if (!goRequest || !goAction || !plan)
296
+ return { writes, consumed, events, goLiveOpen: false };
297
+ const applied = (a) => a !== undefined && decision.applied.includes(a);
298
+ const keep = !plan.consume;
299
+ for (const e of plan.events)
300
+ events.push({ at: stamp, ...e, actor: goRequest.actor });
301
+ if (deployFailed) {
302
+ events.push({
303
+ at: stamp,
304
+ kind: 'go_live_failed',
305
+ reason: 'the idle color could not be deployed',
306
+ actor: goRequest.actor,
307
+ });
308
+ }
309
+ const promoted = plan.promote !== undefined && applied(plan.promote) && !deployFailed;
310
+ if (plan.state !== undefined && !plan.promote) {
311
+ writes[k.goLive] = plan.state ? formatGoLive(plan.state) : null;
312
+ }
313
+ if (plan.promote && plan.appEnv && plan.carry && (promoted || deployFailed)) {
314
+ // The idle deploy worked: its release number is reserved for this request. It failed: count the try.
315
+ writes[k.goLive] = formatGoLive({
316
+ requestId: goRequest.id,
317
+ tries: plan.carry.tries,
318
+ release: promoted ? plan.appEnv.release : null,
319
+ idle: plan.carry.idle + (promoted ? 0 : 1),
320
+ });
321
+ }
322
+ if (!keep)
323
+ consumed.push(goRequest);
324
+ if (plan.record) {
325
+ const held = parseGoLive(input.ids[k.goLive]);
326
+ // `planGoLive` records only a tag whose own idle deploy reserved a number.
327
+ const release = held?.requestId === goRequest.id ? held.release : null;
328
+ if (release === null)
329
+ throw new Error('go-live recorded without a reserved release');
330
+ const v = {
331
+ app,
332
+ tag: goAction.tag,
333
+ sha: goAction.ref,
334
+ release,
335
+ required: goAction.required,
336
+ requires: goAction.requires,
337
+ at: stamp,
338
+ };
339
+ Object.assign(writes, recordWrites(v));
340
+ events.push({ at: stamp, kind: 'released', reason: v.tag, actor: goRequest.actor });
341
+ }
342
+ return {
343
+ writes,
344
+ consumed,
345
+ events,
346
+ goLiveOpen: keep && plan.blocked === undefined,
347
+ };
348
+ },
349
+ };
350
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type Recorded, resolveReleaseConfig } from './decide.js';
3
- import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
3
+ import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, ReleasedVersion, StageCopyState, WindowState } from './types.js';
4
4
  /**
5
5
  * The persistence the release runner and Archon share. `getScopeIds` is the
6
6
  * recorded `bg.*` keys of an org's applied state (or of the platform, for the
@@ -14,16 +14,61 @@ export interface ReleaseStore {
14
14
  entryKey: string;
15
15
  action: ReleaseAction;
16
16
  actor: string;
17
+ /** ISO time. The runner sees the request only from then on. Absent: due at once. */
18
+ notBefore?: string;
17
19
  }): Promise<void>;
18
- /** The open requests, oldest first. Reading consumes nothing: a run that fails leaves them open. */
20
+ /**
21
+ * The open requests that are due, oldest first. Reading consumes nothing: a run that fails leaves them open.
22
+ * `now` defaults to the database's clock.
23
+ */
19
24
  openRequests(input: {
20
25
  orgId: string;
21
26
  entryKey: string;
27
+ now?: Date;
22
28
  }): Promise<{
23
29
  id: number;
24
30
  action: ReleaseAction;
25
31
  actor: string;
32
+ notBefore?: string | null;
26
33
  }[]>;
34
+ /** Marks go-live requests the runner has started. A claimed request cannot be cancelled or moved. */
35
+ claimRequests(ids: readonly number[]): Promise<void>;
36
+ /**
37
+ * Takes back a request that waits for a time; writes the `schedule_cancelled` line in the same transaction.
38
+ * False when there is no such request: it was consumed, cancelled, claimed, already due, or never had a time.
39
+ * `now` defaults to the database's clock.
40
+ */
41
+ cancelRequest(input: {
42
+ id: number;
43
+ orgId: string;
44
+ entryKey: string;
45
+ actor: string;
46
+ now?: Date;
47
+ }): Promise<boolean>;
48
+ /** The requests that wait for a time and are not consumed, soonest first. */
49
+ readScheduled(input: {
50
+ orgId: string;
51
+ entryKey: string;
52
+ }): Promise<{
53
+ id: number;
54
+ action: ReleaseAction;
55
+ notBefore: string;
56
+ actor: string;
57
+ claimed: boolean;
58
+ }[]>;
59
+ /**
60
+ * {@link cancelRequest} of `id` and {@link addRequest} of `action` at `notBefore` in one transaction, with the
61
+ * `schedule_cancelled` and `scheduled` lines. False, and nothing added, when `id` cannot be cancelled.
62
+ */
63
+ rescheduleRequest(input: {
64
+ id: number;
65
+ orgId: string;
66
+ entryKey: string;
67
+ action: ReleaseAction;
68
+ notBefore: string;
69
+ actor: string;
70
+ now?: Date;
71
+ }): Promise<boolean>;
27
72
  /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
73
  consumeRequests(ids: readonly number[]): Promise<void>;
29
74
  /** Appends history lines (`foundry_release_events`), oldest first. Nothing is ever updated or deleted. */
@@ -63,10 +108,12 @@ export declare function releaseKeys(entry: string): {
63
108
  stageCopyRetry: string;
64
109
  /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
65
110
  refused: string;
111
+ /** `<request id>|<stage deploys tried>|<release number or empty>`: where the go-live being run stands. */
112
+ goLive: string;
66
113
  };
67
114
  export declare function formatWeights(w: Record<Color, number>): string;
68
115
  export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
69
- export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail", "fanout_done", "fanout_failed"];
116
+ export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail", "fanout_done", "fanout_failed", "scheduled", "schedule_cancelled", "released", "go_live_failed", "go_live_refused"];
70
117
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
71
118
  export declare const releaseEventPayload: z.ZodObject<{
72
119
  percent: z.ZodOptional<z.ZodNumber>;
@@ -102,6 +149,11 @@ export declare const releaseEventKind: z.ZodEnum<{
102
149
  approve: "approve";
103
150
  fanout_done: "fanout_done";
104
151
  fanout_failed: "fanout_failed";
152
+ scheduled: "scheduled";
153
+ schedule_cancelled: "schedule_cancelled";
154
+ released: "released";
155
+ go_live_failed: "go_live_failed";
156
+ go_live_refused: "go_live_refused";
105
157
  }>;
106
158
  /** The window JSON is the C2 state plus the active release's `release` field. */
107
159
  export declare function parseWindow(value: string | undefined): {
@@ -128,13 +180,39 @@ export declare function readReleaseState(store: ReleaseStore, input: {
128
180
  entry?: Parameters<typeof resolveReleaseConfig>[0];
129
181
  }): Promise<ReleaseState | null>;
130
182
  declare const actionSchema: z.ZodType<ReleaseAction>;
131
- /** C2: ask the runner to apply an action on its next run. Archon audits it. */
183
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. `notBefore` (a `go_live` only) waits for that time. */
132
184
  export declare function requestReleaseAction(store: ReleaseStore, input: {
133
185
  orgId: string;
134
186
  entryKey: string;
135
187
  action: ReleaseAction;
136
188
  actor: string;
189
+ notBefore?: string;
190
+ /** `DeclaredPlatform.ownerOrgId`: required for `go_live` and `set_required`. */
191
+ ownerOrgId?: string;
192
+ /** The clock for the time check; default now. */
193
+ now?: Date;
137
194
  }): Promise<void>;
195
+ /** Moves a queued go-live: cancels `id` and queues `action` at `notBefore` in one transaction. False when `id` is consumed, claimed, cancelled or already due. */
196
+ export declare function rescheduleRelease(store: ReleaseStore, input: {
197
+ id: number;
198
+ orgId: string;
199
+ entryKey: string;
200
+ action: ReleaseAction;
201
+ notBefore: string;
202
+ actor: string;
203
+ ownerOrgId?: string;
204
+ now?: Date;
205
+ }): Promise<boolean>;
206
+ /** The applied-state key of one released version: `released.<app>.<release>`. */
207
+ export declare function releasedKey(app: 'boule' | 'agora', release: number): string;
208
+ /** `tag|sha|required|requires|at`; `requires` is comma separated. */
209
+ export declare function formatReleased(v: ReleasedVersion): string;
210
+ /** Reads one `released.*` value; a damaged value reads as absent. */
211
+ export declare function parseReleased(app: 'boule' | 'agora', release: number, value: string | undefined): ReleasedVersion | null;
212
+ /** The released versions of `app` in the owner's applied state, newest first. */
213
+ export declare function releasedFrom(ids: Readonly<Record<string, string>>, app: 'boule' | 'agora'): ReleasedVersion[];
214
+ /** The tags that went live on the owner's Boule or Agora, newest first. A stage-only build is not in it. */
215
+ export declare function readReleasedVersions(store: Pick<ReleaseStore, 'getScopeIds'>, ownerOrgId: string, app: 'boule' | 'agora'): Promise<ReleasedVersion[]>;
138
216
  export { actionSchema as releaseActionSchema };
139
217
  export declare const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
140
218
  export declare const RELEASE_HISTORY_MAX_LIMIT = 1000;
@@ -27,6 +27,8 @@ export function releaseKeys(entry) {
27
27
  stageCopyRetry: `${p}.stagecopyretry`,
28
28
  /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
29
29
  refused: `${p}.refused`,
30
+ /** `<request id>|<stage deploys tried>|<release number or empty>`: where the go-live being run stands. */
31
+ goLive: `${p}.golive`,
30
32
  };
31
33
  }
32
34
  export function formatWeights(w) {
@@ -80,6 +82,11 @@ export const RELEASE_EVENT_KINDS = [
80
82
  'hard_fail',
81
83
  'fanout_done',
82
84
  'fanout_failed',
85
+ 'scheduled',
86
+ 'schedule_cancelled',
87
+ 'released',
88
+ 'go_live_failed',
89
+ 'go_live_refused',
83
90
  ];
84
91
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
85
92
  export const releaseEventPayload = z.object({
@@ -205,6 +212,10 @@ function stageDeployOf(value) {
205
212
  stageDeploy: { at: m[1], outcome: m[2], detail: m[3] },
206
213
  };
207
214
  }
215
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
216
+ /** A tag as a release names it: no `|`, `/` or space, so it fits a key and a `|` separated value. */
217
+ const RELEASE_TAG = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/);
218
+ const BUILTIN_ENTRIES = ['boule', 'agora'];
208
219
  const actionSchema = z.discriminatedUnion('kind', [
209
220
  z.object({ kind: z.literal('promote') }),
210
221
  z.object({ kind: z.literal('rollback') }),
@@ -219,11 +230,112 @@ const actionSchema = z.discriminatedUnion('kind', [
219
230
  z.object({ kind: z.literal('copy_prod_to_stage') }),
220
231
  z.object({ kind: z.literal('undo_stage_copy') }),
221
232
  z.object({ kind: z.literal('fanout_retry') }),
233
+ z.object({
234
+ kind: z.literal('go_live'),
235
+ tag: RELEASE_TAG,
236
+ ref: z.string().regex(COMMIT_SHA),
237
+ required: z.boolean(),
238
+ requires: z.array(z.enum(['data-service'])),
239
+ }),
240
+ z.object({ kind: z.literal('set_required'), tag: RELEASE_TAG, required: z.boolean() }),
222
241
  ]);
223
- /** C2: ask the runner to apply an action on its next run. Archon audits it. */
242
+ const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
243
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. `notBefore` (a `go_live` only) waits for that time. */
224
244
  export async function requestReleaseAction(store, input) {
225
245
  releaseKeys(input.entryKey);
226
- await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
246
+ const action = actionSchema.parse(input.action);
247
+ assertBuiltinAction(input, action);
248
+ if (action.kind === 'set_required')
249
+ await assertReleased(store, input, action.tag);
250
+ if (action.kind === 'go_live' && input.notBefore === undefined) {
251
+ throw new Error('go_live needs a time');
252
+ }
253
+ if (input.notBefore !== undefined)
254
+ assertSchedule(action, input.notBefore, input.now);
255
+ const { ownerOrgId: _owner, now: _now, ...rest } = input;
256
+ await store.addRequest({ ...rest, action });
257
+ }
258
+ function assertBuiltinAction(input, action) {
259
+ if (action.kind !== 'go_live' && action.kind !== 'set_required')
260
+ return;
261
+ if (!BUILTIN_ENTRIES.includes(input.entryKey)) {
262
+ throw new Error('go_live and set_required belong to the entry boule or agora');
263
+ }
264
+ if (input.ownerOrgId === undefined || input.ownerOrgId !== input.orgId) {
265
+ throw new Error('go_live and set_required belong to the owner org');
266
+ }
267
+ }
268
+ async function assertReleased(store, input, tag) {
269
+ const versions = releasedFrom(await store.getScopeIds(input.orgId), input.entryKey);
270
+ if (!versions.some((v) => v.tag === tag)) {
271
+ throw new Error('set_required needs a tag that went live; change the go-live instead');
272
+ }
273
+ }
274
+ function assertSchedule(action, notBefore, now = new Date()) {
275
+ if (action.kind !== 'go_live')
276
+ throw new Error('only go_live can wait for a time');
277
+ if (!ISO_WITH_ZONE.test(notBefore) || Number.isNaN(Date.parse(notBefore))) {
278
+ throw new Error('invalid release time: use ISO 8601 with Z or an offset');
279
+ }
280
+ if (Date.parse(notBefore) <= now.getTime())
281
+ throw new Error('the release time is in the past');
282
+ }
283
+ /** Moves a queued go-live: cancels `id` and queues `action` at `notBefore` in one transaction. False when `id` is consumed, claimed, cancelled or already due. */
284
+ export async function rescheduleRelease(store, input) {
285
+ releaseKeys(input.entryKey);
286
+ const action = actionSchema.parse(input.action);
287
+ assertBuiltinAction(input, action);
288
+ assertSchedule(action, input.notBefore, input.now);
289
+ const { ownerOrgId: _owner, ...rest } = input;
290
+ return store.rescheduleRequest({ ...rest, action });
291
+ }
292
+ const RELEASED_PREFIX = 'released.';
293
+ /** The applied-state key of one released version: `released.<app>.<release>`. */
294
+ export function releasedKey(app, release) {
295
+ return `${RELEASED_PREFIX}${app}.${release}`;
296
+ }
297
+ /** `tag|sha|required|requires|at`; `requires` is comma separated. */
298
+ export function formatReleased(v) {
299
+ return [v.tag, v.sha, String(v.required), v.requires.join(','), v.at].join('|');
300
+ }
301
+ /** Reads one `released.*` value; a damaged value reads as absent. */
302
+ export function parseReleased(app, release, value) {
303
+ const parts = (value ?? '').split('|');
304
+ if (parts.length !== 5)
305
+ return null;
306
+ const [tag, sha, required, requires, at] = parts;
307
+ if (!RELEASE_TAG.safeParse(tag).success || !COMMIT_SHA.test(sha))
308
+ return null;
309
+ if (required !== 'true' && required !== 'false')
310
+ return null;
311
+ if (Number.isNaN(Date.parse(at)))
312
+ return null;
313
+ const list = requires === '' ? [] : requires.split(',');
314
+ if (!list.every((r) => r === 'data-service'))
315
+ return null;
316
+ return { app, tag, sha, release, required: required === 'true', requires: list, at };
317
+ }
318
+ /** The released versions of `app` in the owner's applied state, newest first. */
319
+ export function releasedFrom(ids, app) {
320
+ const prefix = `${RELEASED_PREFIX}${app}.`;
321
+ const out = [];
322
+ for (const key of Object.keys(ids)) {
323
+ if (!key.startsWith(prefix))
324
+ continue;
325
+ const n = key.slice(prefix.length);
326
+ if (!/^[1-9][0-9]{0,11}$/.test(n))
327
+ continue;
328
+ const v = parseReleased(app, Number(n), ids[key]);
329
+ if (v)
330
+ out.push(v);
331
+ }
332
+ return out.sort((a, b) => b.release - a.release);
333
+ }
334
+ /** The tags that went live on the owner's Boule or Agora, newest first. A stage-only build is not in it. */
335
+ export async function readReleasedVersions(store, ownerOrgId, app) {
336
+ if (!BUILTIN_ENTRIES.includes(app))
337
+ throw new Error('invalid released app');
338
+ return releasedFrom(await store.getScopeIds(ownerOrgId), app);
227
339
  }
228
340
  export { actionSchema as releaseActionSchema };
229
341
  export const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
@@ -80,8 +80,11 @@ export interface StageCopyState {
80
80
  outcome: 'ok' | 'refused' | 'failed';
81
81
  detail: string;
82
82
  }
83
- export type ReleaseAction = {
83
+ export type ReleaseAction =
84
+ /** `redeploy`: set by a go-live only (a request cannot carry it); promotes the idle color to the commit the live color already runs. */
85
+ {
84
86
  kind: 'promote';
87
+ redeploy?: boolean;
85
88
  } | {
86
89
  kind: 'rollback';
87
90
  } | {
@@ -111,12 +114,55 @@ export type ReleaseAction = {
111
114
  /** Owner org, entry key `boule` or `agora`: continue the built-in fan-out from the org that failed. */
112
115
  | {
113
116
  kind: 'fanout_retry';
117
+ }
118
+ /**
119
+ * Owner org, entry key `boule` or `agora`: put the tag's commit live. The runner deploys stage at `ref` if stage
120
+ * differs, deploys the idle color with the tag's `APP_*` env, opens it, and records the {@link ReleasedVersion}
121
+ * once the release completes (one color at 100 %, window closed). Usually queued with a time.
122
+ */
123
+ | {
124
+ kind: 'go_live';
125
+ tag: string;
126
+ ref: string;
127
+ required: boolean;
128
+ requires: ReleaseRequirement[];
129
+ }
130
+ /** Owner org, entry key `boule` or `agora`: mark a tag that went live required or not. */
131
+ | {
132
+ kind: 'set_required';
133
+ tag: string;
134
+ required: boolean;
114
135
  };
136
+ /** What a tag needs before a company can take it. `data-service`: the company's data service is applied and its data moved. */
137
+ export type ReleaseRequirement = 'data-service';
138
+ /** One tag that went live on the owner's Boule or Agora. Newest first in {@link readReleasedVersions}. */
139
+ export interface ReleasedVersion {
140
+ app: 'boule' | 'agora';
141
+ tag: string;
142
+ /** The 40-hex commit. */
143
+ sha: string;
144
+ /** The UTC second the idle color was deployed with it. The same number on every copy of the tag. */
145
+ release: number;
146
+ required: boolean;
147
+ requires: ReleaseRequirement[];
148
+ /** ISO time the release completed. */
149
+ at: string;
150
+ }
115
151
  export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
116
152
  /** The built-in fan-out put an org's copy on the live commit (`reason` is the org slug). */
117
153
  | 'fanout_done'
118
154
  /** The built-in fan-out stopped at an org (`reason` is `<slug>: <fixed text>`). */
119
- | 'fanout_failed';
155
+ | 'fanout_failed'
156
+ /** A go-live was queued for a time (`reason` is `<tag> at <ISO time>`). */
157
+ | 'scheduled'
158
+ /** A queued go-live was cancelled before its time (`reason` is the tag). */
159
+ | 'schedule_cancelled'
160
+ /** A tag finished going live: one color at 100 % and the window closed (`reason` is the tag). */
161
+ | 'released'
162
+ /** A step of a go-live did not work and is tried again by the next tick (`reason` is fixed text). */
163
+ | 'go_live_failed'
164
+ /** A go-live was refused and its request consumed (`reason` is fixed text). */
165
+ | 'go_live_refused';
120
166
  /** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
121
167
  export interface ReleaseEvent {
122
168
  at: string;