@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 +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0071_release_request_schedule.sql +12 -0
- package/dist/plan/plan-apply.js +3 -0
- package/dist/plan/release-host.d.ts +5 -1
- package/dist/plan/release-host.js +99 -50
- package/dist/release/decide.d.ts +9 -0
- package/dist/release/decide.js +1 -1
- package/dist/release/golive.d.ts +77 -0
- package/dist/release/golive.js +151 -0
- package/dist/release/run.d.ts +7 -1
- package/dist/release/run.js +205 -14
- package/dist/release/state.d.ts +82 -4
- package/dist/release/state.js +114 -2
- package/dist/release/types.d.ts +48 -2
- package/dist/store/postgres.js +97 -6
- package/dist/store/schema.d.ts +34 -0
- package/dist/store/schema.js +11 -2
- package/package.json +1 -1
package/dist/release/run.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
|
39
|
-
const
|
|
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
|
|
53
|
+
const go = planReleaseRequests({
|
|
54
|
+
app: entryKey,
|
|
55
|
+
requests: releaseRequests,
|
|
47
56
|
recorded,
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
147
|
+
blocked,
|
|
98
148
|
refused,
|
|
99
149
|
decision,
|
|
100
|
-
requestIds: [
|
|
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
|
+
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/release/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/release/types.d.ts
CHANGED
|
@@ -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;
|