@wtfalch/foundry 0.72.3 → 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
  });
@@ -70,13 +70,14 @@ export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
70
70
  export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
71
  /**
72
72
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
73
- * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
73
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`, `ZITADEL_DOMAIN`, `LOGIN_COMPANY_NAME`). Raise it when a key is added or its meaning changes, so an applied
74
74
  * sign-in plans its step once more and the apps get the env. Never holds a value.
75
75
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
76
76
  * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
77
77
  * 3: the login apps get `ZITADEL_DOMAIN` (foundry 0.72.2; written from 0.72.3).
78
+ * 4: the login apps get `LOGIN_COMPANY_NAME`, the sign-in page's wordmark (foundry 0.73.0).
78
79
  */
79
- export declare const AUTH_LOGIN_ENV_VERSION = "3";
80
+ export declare const AUTH_LOGIN_ENV_VERSION = "4";
80
81
  /**
81
82
  * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
82
83
  * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
@@ -77,13 +77,14 @@ export const AUTH_TUNNEL_ROUTES_VERSION = '2';
77
77
  export const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = 'auth_tunnel_routes_applied';
78
78
  /**
79
79
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
80
- * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
80
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`, `ZITADEL_DOMAIN`, `LOGIN_COMPANY_NAME`). Raise it when a key is added or its meaning changes, so an applied
81
81
  * sign-in plans its step once more and the apps get the env. Never holds a value.
82
82
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
83
83
  * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
84
84
  * 3: the login apps get `ZITADEL_DOMAIN` (foundry 0.72.2; written from 0.72.3).
85
+ * 4: the login apps get `LOGIN_COMPANY_NAME`, the sign-in page's wordmark (foundry 0.73.0).
85
86
  */
86
- export const AUTH_LOGIN_ENV_VERSION = '3';
87
+ export const AUTH_LOGIN_ENV_VERSION = '4';
87
88
  /**
88
89
  * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
89
90
  * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
@@ -1575,11 +1575,12 @@ const PLATFORM_AUTH_RESERVED_USERNAME = 'admin';
1575
1575
  /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
1576
1576
  const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
1577
1577
  export const PLATFORM_AUTH_ORG_NAME = 'foundry';
1578
+ const LOGIN_COMPANY_NAME_ENV = 'LOGIN_COMPANY_NAME';
1578
1579
  /**
1579
1580
  * The instance's first organisation (the default one, whose name the login shows as its wordmark).
1580
1581
  * A company's own instance passes the recorded id (the platform passes none and keeps the name `foundry`);
1581
- * found by that id when there is one, because a company's own instance has it renamed to the
1582
- * company's name (`applySigninMail`), and a lookup by name would then make a second `foundry` org. The
1582
+ * found by that id when there is one, because a company's own instance may have it renamed (foundry
1583
+ * before 0.73.0 did), and a lookup by name would then make a second `foundry` org. The
1583
1584
  * name is the way in only before the id is recorded. A recorded id ZITADEL no longer has is a failure,
1584
1585
  * never a reason to make another org.
1585
1586
  */
@@ -2228,6 +2229,11 @@ export async function provisionAuthStack(scope, deps, target, input) {
2228
2229
  },
2229
2230
  // #1086: the login builds redirects for an unbound host from this, never from a header.
2230
2231
  { key: 'ZITADEL_DOMAIN', value: hostname },
2232
+ // The wordmark on the sign-in page: a company's own name, `Archon` for the platform's login.
2233
+ {
2234
+ key: LOGIN_COMPANY_NAME_ENV,
2235
+ value: input.mailBranding ? signinMailName(input.mailBranding.companyName) : 'Archon',
2236
+ },
2231
2237
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
2232
2238
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
2233
2239
  // #405: the login app's own requestId-free fallback (`resolveRedirectUri`
@@ -2856,17 +2862,12 @@ export async function provisionAuthStack(scope, deps, target, input) {
2856
2862
  (deps.signinMailWritable !== true ||
2857
2863
  typeof mailZitadel.ensureEmailOtpMessageText !== 'function' ||
2858
2864
  typeof mailZitadel.ensureLabelPolicy !== 'function' ||
2859
- typeof mailZitadel.ensureOrgName !== 'function' ||
2860
2865
  typeof mailZitadel.ensureEmailOtpGenerator !== 'function')) {
2861
2866
  console.info(`${label}: the host cannot write message texts, so the sign-in mail keeps ZITADEL's default text`);
2862
2867
  }
2863
2868
  else if (input.mailBranding) {
2864
- const firstOrgId = scope.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2865
- if (!firstOrgId) {
2866
- throw new Error(`${label}: the instance's first organisation is not recorded`);
2867
- }
2868
2869
  try {
2869
- await applySigninMail(mailZitadel, input.mailBranding.companyName, firstOrgId);
2870
+ await applySigninMail(mailZitadel, input.mailBranding.companyName);
2870
2871
  }
2871
2872
  catch (error) {
2872
2873
  const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
@@ -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
@@ -18,16 +18,8 @@ export declare const SIGNIN_MAIL_CODE: {
18
18
  readonly length: 6;
19
19
  readonly expirySeconds: 300;
20
20
  };
21
- /** The four ZITADEL calls the sign-in mail needs. */
21
+ /** The three ZITADEL calls the sign-in mail needs. */
22
22
  export interface PlatformZitadelMailBranding {
23
- /**
24
- * Renames an organisation, only when its name differs (`PUT /management/v1/orgs/me` as that
25
- * organisation). The login shows the instance's default organisation's name as its wordmark when the
26
- * host is bound to no company, so a company's own instance names it after the company.
27
- */
28
- ensureOrgName(orgId: string, name: string): Promise<{
29
- changed: boolean;
30
- }>;
31
23
  ensureEmailOtpMessageText(language: string, text: {
32
24
  title: string;
33
25
  preHeader: string;
@@ -104,7 +96,7 @@ export declare const SIGNIN_MAIL_LABEL_POLICY: {
104
96
  readonly warnColorDark: "#FF9C8C";
105
97
  readonly disableWatermark: true;
106
98
  };
107
- /** Names the default organisation after the company, then writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
108
- export declare function applySigninMail(zitadel: PlatformZitadelMailBranding, companyName: string, defaultOrgId: string): Promise<{
99
+ /** Writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. The wordmark is the login's `LOGIN_COMPANY_NAME`, not an organisation name. */
100
+ export declare function applySigninMail(zitadel: PlatformZitadelMailBranding, companyName: string): Promise<{
109
101
  changed: boolean;
110
102
  }>;
@@ -71,13 +71,12 @@ export const SIGNIN_MAIL_LABEL_POLICY = {
71
71
  warnColorDark: '#FF9C8C',
72
72
  disableWatermark: true,
73
73
  };
74
- /** Names the default organisation after the company, then writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
75
- export async function applySigninMail(zitadel, companyName, defaultOrgId) {
76
- const name = await zitadel.ensureOrgName(defaultOrgId, signinMailName(companyName));
74
+ /** Writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. The wordmark is the login's `LOGIN_COMPANY_NAME`, not an organisation name. */
75
+ export async function applySigninMail(zitadel, companyName) {
77
76
  const text = await zitadel.ensureEmailOtpMessageText(SIGNIN_MAIL_LANGUAGE, signinMailText(companyName));
78
77
  const look = await zitadel.ensureLabelPolicy({ ...SIGNIN_MAIL_LABEL_POLICY });
79
78
  const code = await zitadel.ensureEmailOtpGenerator({ ...SIGNIN_MAIL_CODE });
80
79
  return {
81
- changed: name.changed || text.changed || look.changed || code.changed,
80
+ changed: text.changed || look.changed || code.changed,
82
81
  };
83
82
  }
@@ -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>;