@wtfalch/foundry 0.47.0 → 0.49.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.
Files changed (51) hide show
  1. package/dist/bootstrap/declare.js +1 -0
  2. package/dist/index.d.ts +6 -4
  3. package/dist/index.js +3 -2
  4. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  5. package/dist/migrations/0060_hosting_repos.sql +7 -0
  6. package/dist/org.d.ts +25 -1
  7. package/dist/org.js +51 -3
  8. package/dist/plan/archon-blue-green.js +15 -1
  9. package/dist/plan/blue-green-apply.d.ts +35 -0
  10. package/dist/plan/blue-green-apply.js +167 -16
  11. package/dist/plan/blue-green.d.ts +97 -2
  12. package/dist/plan/blue-green.js +214 -13
  13. package/dist/plan/database-dump.d.ts +37 -0
  14. package/dist/plan/database-dump.js +101 -0
  15. package/dist/plan/diff.js +3 -0
  16. package/dist/plan/executor.d.ts +48 -0
  17. package/dist/plan/executor.js +79 -20
  18. package/dist/plan/github-release-hook.d.ts +81 -0
  19. package/dist/plan/github-release-hook.js +159 -0
  20. package/dist/plan/index.d.ts +4 -1
  21. package/dist/plan/index.js +5 -1
  22. package/dist/plan/live.d.ts +1 -1
  23. package/dist/plan/locked-hostnames.d.ts +1 -0
  24. package/dist/plan/locked-hostnames.js +1 -0
  25. package/dist/plan/plan-apply.js +16 -7
  26. package/dist/plan/platform-diff.d.ts +59 -0
  27. package/dist/plan/platform-diff.js +100 -2
  28. package/dist/plan/platform-providers.d.ts +10 -0
  29. package/dist/plan/platform-providers.js +73 -1
  30. package/dist/plan/proxy-labels.d.ts +13 -1
  31. package/dist/plan/proxy-labels.js +26 -3
  32. package/dist/plan/release-host.d.ts +1 -1
  33. package/dist/plan/release-host.js +36 -14
  34. package/dist/plan/stage-env.d.ts +38 -0
  35. package/dist/plan/stage-env.js +52 -0
  36. package/dist/plan/stage-guards.d.ts +104 -0
  37. package/dist/plan/stage-guards.js +149 -0
  38. package/dist/plan/stage-isolation.d.ts +57 -0
  39. package/dist/plan/stage-isolation.js +248 -0
  40. package/dist/refuse-org.d.ts +1 -1
  41. package/dist/refuse-org.js +5 -3
  42. package/dist/release/decide.d.ts +5 -0
  43. package/dist/release/decide.js +10 -6
  44. package/dist/release/health.d.ts +6 -1
  45. package/dist/release/health.js +10 -2
  46. package/dist/release/run.d.ts +2 -0
  47. package/dist/release/run.js +2 -1
  48. package/dist/store/postgres.js +32 -2
  49. package/dist/store/schema.d.ts +58 -1
  50. package/dist/store/schema.js +5 -0
  51. package/package.json +1 -1
@@ -151,6 +151,13 @@ export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon
151
151
  * values until someone redeploys by hand (#467).
152
152
  */
153
153
  export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
154
+ /**
155
+ * The sha256 of the `GITHUB_RELEASE_WEBHOOK_SECRET` Archon last deployed with, recorded by
156
+ * `platform:archon` after its deploy (never the secret). `driftedSteps` plans `platform:archon` again
157
+ * while the live secret is missing or hashes differently: the release hook step writes the secret into
158
+ * Coolify and nothing else redeploys an applied Archon.
159
+ */
160
+ export declare const PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY = "archon_webhook_secret_deployed_sha256";
154
161
  /**
155
162
  * The uuid of the deployment Archon's step triggered without waiting
156
163
  * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
@@ -412,6 +419,29 @@ export declare const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = "platform:mas
412
419
  * already holds the same panel's address.
413
420
  */
414
421
  export declare const PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF = "platform:archon:coolify-read-token";
422
+ /**
423
+ * The held key (tenant `platform`) of a fine-grained GitHub token for the `wtfalch`
424
+ * organisation with Webhooks read and write. Foundry only reads it back; a person holds it in
425
+ * Archon, Shared, Keys. Until it is held, the `platform:github-release-hook` step is reported
426
+ * blocked and nothing else is.
427
+ */
428
+ export declare const PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF = "platform:github:org-hooks-token";
429
+ /** Archon's env name for the secret GitHub signs release webhooks with. Minted once by `platform:archon`, never rotated. */
430
+ export declare const GITHUB_RELEASE_WEBHOOK_SECRET_ENV = "GITHUB_RELEASE_WEBHOOK_SECRET";
431
+ /** The GitHub organisation whose release webhook `platform:github-release-hook` makes. */
432
+ export declare const GITHUB_RELEASE_HOOK_ORG = "wtfalch";
433
+ /** Archon's route that takes release webhooks, under its public host. */
434
+ export declare const GITHUB_RELEASE_HOOK_PATH = "/api/github/releases";
435
+ /** The step key. */
436
+ export declare const PLATFORM_GITHUB_RELEASE_HOOK_KEY = "platform:github-release-hook";
437
+ /** Scope key: the id of the organisation hook the step made or adopted. */
438
+ export declare const PLATFORM_GITHUB_RELEASE_HOOK_ID_SCOPE_KEY = "github_release_hook_id";
439
+ /** Scope key: the hash of the hook's url and secret as last written. The secret itself is never recorded. */
440
+ export declare const PLATFORM_GITHUB_RELEASE_HOOK_HASH_SCOPE_KEY = "github_release_hook_hash";
441
+ /** Why the step is blocked while Archon's env cannot be read from Coolify. */
442
+ export declare const GITHUB_RELEASE_HOOK_COOLIFY_READ_FAILED = "the GitHub release webhook is blocked, not planned: could not read Archon's env from Coolify";
443
+ /** Why the step is blocked while the token is not held. */
444
+ export declare const GITHUB_RELEASE_HOOK_TOKEN_MISSING = "the GitHub release webhook is blocked, not planned: hold the GitHub organisation webhooks token in Archon, Shared, Keys";
415
445
  /**
416
446
  * #169: `platform:<service>` for the platform's own mail server (Stalwart),
417
447
  * mirroring {@link PLATFORM_AUTH_KEY} exactly.
@@ -1248,6 +1278,14 @@ export interface PlanPlatformOptions {
1248
1278
  pending: boolean;
1249
1279
  blocked?: string;
1250
1280
  };
1281
+ /** Why the organisation's release webhook step cannot run (the token is not held), or absent. */
1282
+ githubReleaseHook?: string;
1283
+ /**
1284
+ * The token is held: the step may plan, also in the apply that makes Archon.
1285
+ */
1286
+ githubReleaseHookReady?: boolean;
1287
+ /** The hash of the hook's url and Archon's live webhook secret now (`githubReleaseHookHash`). Absent while Archon holds no secret. */
1288
+ githubReleaseHookHash?: string;
1251
1289
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
1252
1290
  archonDeployRunning?: string;
1253
1291
  /**
@@ -1448,6 +1486,27 @@ export declare const PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY = "in
1448
1486
  * `''`) as soon as the mint is recorded, so one request is one rotation.
1449
1487
  */
1450
1488
  export declare const PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY = "integrations_management_key_rotation_requested";
1489
+ /**
1490
+ * foundry#542: the uptime Worker, one `cloudflare:worker` step. Blocked, not
1491
+ * planned (rule 2), while no artifact is pinned, Archon is not applied, the
1492
+ * executor lacks its dependencies, a Worker or Access token of the name exists
1493
+ * that foundry did not make. A lost Worker token or Access pair is not a
1494
+ * block: the step is planned and repairs it (rule 4). Planned again when the
1495
+ * pinned tag or sha256, the Archon hostname (`ARCHON_BASE_URL`), the plain
1496
+ * text variables or the set of secrets that can be set changed (rule 3), or
1497
+ * when a half-made deploy never recorded its applied values; it mints nothing
1498
+ * that is already held. When the platform no longer enables the Worker, a
1499
+ * destructive step removes whatever of it is recorded (edge removes its KV
1500
+ * namespace with the script).
1501
+ *
1502
+ * The returned `blocked` lines are reported in `unmanaged`. A secret whose
1503
+ * source does not exist yet blocks nothing: the step still delivers
1504
+ * everything that exists, so it comes back in `notes`.
1505
+ */
1506
+ /** The hash of a hook's url and secret. The planner and the step both compute it, and it is the only trace of the secret that is recorded. */
1507
+ export declare function githubReleaseHookHash(url: string, secret: string): string;
1508
+ /** The url GitHub delivers release webhooks to. */
1509
+ export declare function githubReleaseHookUrl(hostname: string): string;
1451
1510
  /**
1452
1511
  * #175: Archon (https://github.com/wtfalch/archon), the one shared console,
1453
1512
  * built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
@@ -190,6 +190,13 @@ export const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = 'archon_integra
190
190
  * values until someone redeploys by hand (#467).
191
191
  */
192
192
  export const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = 'archon_envs_deployed_hash';
193
+ /**
194
+ * The sha256 of the `GITHUB_RELEASE_WEBHOOK_SECRET` Archon last deployed with, recorded by
195
+ * `platform:archon` after its deploy (never the secret). `driftedSteps` plans `platform:archon` again
196
+ * while the live secret is missing or hashes differently: the release hook step writes the secret into
197
+ * Coolify and nothing else redeploys an applied Archon.
198
+ */
199
+ export const PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY = 'archon_webhook_secret_deployed_sha256';
193
200
  /**
194
201
  * The uuid of the deployment Archon's step triggered without waiting
195
202
  * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
@@ -464,6 +471,29 @@ export const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = 'platform:master:clou
464
471
  * already holds the same panel's address.
465
472
  */
466
473
  export const PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF = 'platform:archon:coolify-read-token';
474
+ /**
475
+ * The held key (tenant `platform`) of a fine-grained GitHub token for the `wtfalch`
476
+ * organisation with Webhooks read and write. Foundry only reads it back; a person holds it in
477
+ * Archon, Shared, Keys. Until it is held, the `platform:github-release-hook` step is reported
478
+ * blocked and nothing else is.
479
+ */
480
+ export const PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF = 'platform:github:org-hooks-token';
481
+ /** Archon's env name for the secret GitHub signs release webhooks with. Minted once by `platform:archon`, never rotated. */
482
+ export const GITHUB_RELEASE_WEBHOOK_SECRET_ENV = 'GITHUB_RELEASE_WEBHOOK_SECRET';
483
+ /** The GitHub organisation whose release webhook `platform:github-release-hook` makes. */
484
+ export const GITHUB_RELEASE_HOOK_ORG = 'wtfalch';
485
+ /** Archon's route that takes release webhooks, under its public host. */
486
+ export const GITHUB_RELEASE_HOOK_PATH = '/api/github/releases';
487
+ /** The step key. */
488
+ export const PLATFORM_GITHUB_RELEASE_HOOK_KEY = 'platform:github-release-hook';
489
+ /** Scope key: the id of the organisation hook the step made or adopted. */
490
+ export const PLATFORM_GITHUB_RELEASE_HOOK_ID_SCOPE_KEY = 'github_release_hook_id';
491
+ /** Scope key: the hash of the hook's url and secret as last written. The secret itself is never recorded. */
492
+ export const PLATFORM_GITHUB_RELEASE_HOOK_HASH_SCOPE_KEY = 'github_release_hook_hash';
493
+ /** Why the step is blocked while Archon's env cannot be read from Coolify. */
494
+ export const GITHUB_RELEASE_HOOK_COOLIFY_READ_FAILED = "the GitHub release webhook is blocked, not planned: could not read Archon's env from Coolify";
495
+ /** Why the step is blocked while the token is not held. */
496
+ export const GITHUB_RELEASE_HOOK_TOKEN_MISSING = 'the GitHub release webhook is blocked, not planned: hold the GitHub organisation webhooks token in Archon, Shared, Keys';
467
497
  /**
468
498
  * #169: `platform:<service>` for the platform's own mail server (Stalwart),
469
499
  * mirroring {@link PLATFORM_AUTH_KEY} exactly.
@@ -730,7 +760,8 @@ export function appSettingsOf(entry) {
730
760
  return {
731
761
  folder: entry.folder,
732
762
  port: entry.port,
733
- autoDeploy: entry.autoDeploy,
763
+ // A blue/green entry deploys only through the release runner.
764
+ autoDeploy: entry.blueGreen ? false : entry.autoDeploy,
734
765
  ...(entry.buildPack === 'static' ? { staticFolderIn: 'base_directory' } : {}),
735
766
  };
736
767
  }
@@ -1440,8 +1471,25 @@ export function planPlatform(platform, live, options = {}) {
1440
1471
  step: { description: archonBg.step.description, destroys: false, migrates: false },
1441
1472
  });
1442
1473
  }
1443
- if (archon.step && blocked.archonRunsInsideApp)
1474
+ let releaseHook;
1475
+ // The hook step makes the secret itself, so with the app recorded it goes before a last-placed
1476
+ // platform:archon (the host redeploys itself there and would stop before a later step). Without the
1477
+ // app it goes after platform:archon, which makes the app.
1478
+ if (archon.step && blocked.archonRunsInsideApp) {
1479
+ const hookFirst = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]);
1480
+ const hookStep = () => planGithubReleaseHook(platform, recordedIds, blocked.githubReleaseHook, blocked.githubReleaseHookReady ?? false, blocked.githubReleaseHookHash, true);
1481
+ releaseHook = hookStep();
1482
+ if (hookFirst && releaseHook.step)
1483
+ plannedSteps.push(releaseHook.step);
1444
1484
  plannedSteps.push(archon.step);
1485
+ if (!hookFirst && releaseHook.step)
1486
+ plannedSteps.push(releaseHook.step);
1487
+ }
1488
+ else {
1489
+ releaseHook = planGithubReleaseHook(platform, recordedIds, blocked.githubReleaseHook, blocked.githubReleaseHookReady ?? false, blocked.githubReleaseHookHash, Boolean(archon.step));
1490
+ if (releaseHook.step)
1491
+ plannedSteps.push(releaseHook.step);
1492
+ }
1445
1493
  // After Archon's own step: a repair that fails must not hold up the work before it.
1446
1494
  const idTokenUserinfo = planArchonIdTokenUserinfo(platform, recordedIds, blocked.archonIdTokenUserinfo ?? false);
1447
1495
  if (idTokenUserinfo)
@@ -1574,6 +1622,9 @@ export function planPlatform(platform, live, options = {}) {
1574
1622
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1575
1623
  for (const description of uptime.blocked)
1576
1624
  unmanaged.push({ description, key: 'uptime' });
1625
+ if (releaseHook.blocked) {
1626
+ unmanaged.push({ description: releaseHook.blocked, key: 'github-release-hook' });
1627
+ }
1577
1628
  return {
1578
1629
  plannedSteps: plannedSteps.map(withStepSystem),
1579
1630
  unmanaged,
@@ -2931,6 +2982,53 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2931
2982
  * source does not exist yet blocks nothing: the step still delivers
2932
2983
  * everything that exists, so it comes back in `notes`.
2933
2984
  */
2985
+ /** The hash of a hook's url and secret. The planner and the step both compute it, and it is the only trace of the secret that is recorded. */
2986
+ export function githubReleaseHookHash(url, secret) {
2987
+ return createHash('sha256').update(`${url}\n${secret}`).digest('hex');
2988
+ }
2989
+ /** The url GitHub delivers release webhooks to. */
2990
+ export function githubReleaseHookUrl(hostname) {
2991
+ return `https://${hostname}${GITHUB_RELEASE_HOOK_PATH}`;
2992
+ }
2993
+ /**
2994
+ * The organisation's release webhook, once the token is held and Archon is applied or planned in this
2995
+ * same plan (`platform:archon` runs first and mints the secret). Planned when no hook is recorded, or the
2996
+ * recorded hash of (url, secret) differs from the one now.
2997
+ * Blocked, not planned, while the token is not held; nothing else is held back by that.
2998
+ */
2999
+ function planGithubReleaseHook(platform, recordedIds, executorBlocked, tokenHeld, currentHash, archonPlanned) {
3000
+ if (!platform.archon.enabled)
3001
+ return { step: null, blocked: null };
3002
+ if (!recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)] && !archonPlanned) {
3003
+ return { step: null, blocked: null };
3004
+ }
3005
+ if (executorBlocked)
3006
+ return { step: null, blocked: executorBlocked };
3007
+ if (!tokenHeld)
3008
+ return { step: null, blocked: null };
3009
+ if (currentHash &&
3010
+ recordedIds[PLATFORM_GITHUB_RELEASE_HOOK_ID_SCOPE_KEY] &&
3011
+ recordedIds[PLATFORM_GITHUB_RELEASE_HOOK_HASH_SCOPE_KEY] === currentHash) {
3012
+ return { step: null, blocked: null };
3013
+ }
3014
+ const url = githubReleaseHookUrl(platform.archon.hostname);
3015
+ return {
3016
+ step: {
3017
+ action: {
3018
+ system: 'github',
3019
+ kind: 'release-hook',
3020
+ key: PLATFORM_GITHUB_RELEASE_HOOK_KEY,
3021
+ desired: { org: GITHUB_RELEASE_HOOK_ORG, url, events: ['release'] },
3022
+ },
3023
+ step: {
3024
+ description: `make the ${GITHUB_RELEASE_HOOK_ORG} organisation's release webhook to ${url}`,
3025
+ destroys: false,
3026
+ migrates: false,
3027
+ },
3028
+ },
3029
+ blocked: null,
3030
+ };
3031
+ }
2934
3032
  function planUptime(platform, recordedIds, executorBlocked, heldOptional, archonApplied, uptimeHeld = {
2935
3033
  token: true,
2936
3034
  access: true,
@@ -21,6 +21,7 @@ import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platf
21
21
  import type { PlatformOwnerInput } from './platform-org.js';
22
22
  import type { SigninDbBackupPort } from './signin-db-backup.js';
23
23
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
24
+ import { type PlatformGithubReleaseHookDeps } from './github-release-hook.js';
24
25
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
25
26
  import { type PlatformUptimeDeps } from './uptime-worker.js';
26
27
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -324,6 +325,11 @@ export interface FoundryPlatformStepExecutorOptions {
324
325
  * unset, the step is reported blocked.
325
326
  */
326
327
  oikosIngress?: PlatformOikosIngressDeps;
328
+ /**
329
+ * The `github:release-hook` step. Optional like `auth`; without it the step is not planned and
330
+ * nothing is reported.
331
+ */
332
+ githubReleaseHook?: PlatformGithubReleaseHookDeps;
327
333
  }
328
334
  export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor {
329
335
  private readonly cloudflare;
@@ -345,6 +351,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
345
351
  private readonly uptime?;
346
352
  private readonly accessProvider?;
347
353
  private readonly oikosIngress?;
354
+ private readonly githubReleaseHook?;
348
355
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
349
356
  private readonly platformPorts;
350
357
  /** The port placed on the platform's destination, made once per port so a server is listed once. */
@@ -388,6 +395,9 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
388
395
  pending: boolean;
389
396
  blocked?: string;
390
397
  };
398
+ githubReleaseHook?: string;
399
+ githubReleaseHookReady?: boolean;
400
+ githubReleaseHookHash?: string;
391
401
  archonRunsInsideApp?: boolean;
392
402
  archonDeployRunning?: string;
393
403
  archonIdTokenUserinfo?: boolean;
@@ -28,7 +28,7 @@ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAI
28
28
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
29
29
  import { recordOwnDnsHostname } from './own-dns.js';
30
30
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
31
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY, PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY, PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_USER_ROLE, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
31
+ import { AUTH_ZITADEL_RUN_OPTIONS, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_GITHUB_RELEASE_HOOK_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY, PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY, PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_USER_ROLE, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
32
32
  import { PLATFORM_ARCHON_IN_APP_SCOPE_KEY, archonInAppBack, archonInAppBind, archonInAppHost, archonInAppPoint, archonInAppUnbind, } from './sign-in-archon.js';
33
33
  import { LOGIN_HOST_BINDINGS_ENV } from './sign-in-bindings.js';
34
34
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
@@ -36,6 +36,7 @@ import { SIGN_IN_BASE_PATH, SIGN_IN_BUILD_ARG_ENV, SIGN_IN_LOGIN_APP_SCOPE_KEY,
36
36
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
37
37
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
38
38
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
39
+ import { githubReleaseHookPlanInputs, provisionGithubReleaseHook, } from './github-release-hook.js';
39
40
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
40
41
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
41
42
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
@@ -144,6 +145,7 @@ export class FoundryPlatformStepExecutor {
144
145
  uptime;
145
146
  accessProvider;
146
147
  oikosIngress;
148
+ githubReleaseHook;
147
149
  constructor(cloudflare, declaredOrgStore, options = {}) {
148
150
  this.cloudflare = cloudflare;
149
151
  this.declaredOrgStore = declaredOrgStore;
@@ -164,6 +166,7 @@ export class FoundryPlatformStepExecutor {
164
166
  this.uptime = options.uptime;
165
167
  this.accessProvider = options.accessProvider;
166
168
  this.oikosIngress = options.oikosIngress;
169
+ this.githubReleaseHook = options.githubReleaseHook;
167
170
  }
168
171
  platformPorts = new WeakMap();
169
172
  /** The port placed on the platform's destination, made once per port so a server is listed once. */
@@ -470,6 +473,15 @@ export class FoundryPlatformStepExecutor {
470
473
  blocked.heartbeatsApiKeyMissing = true;
471
474
  }
472
475
  await this.uptimeBlocked(platform, recordedIds, blocked, owner);
476
+ if (this.githubReleaseHook && platform.archon.enabled) {
477
+ const inputs = await githubReleaseHookPlanInputs(this.githubReleaseHook, platform.archon.hostname, recordedIds);
478
+ if (inputs.blocked)
479
+ blocked.githubReleaseHook = inputs.blocked;
480
+ if (inputs.ready)
481
+ blocked.githubReleaseHookReady = true;
482
+ if (inputs.hash)
483
+ blocked.githubReleaseHookHash = inputs.hash;
484
+ }
473
485
  // #604: only once edge and sign-in are applied (the planner reports
474
486
  // that case itself) and no provider is recorded yet.
475
487
  if (platform.edge.enabled &&
@@ -693,12 +705,15 @@ export class FoundryPlatformStepExecutor {
693
705
  const redeployFailed = this.archon.runsInsideApp === true &&
694
706
  Boolean(deployment) &&
695
707
  (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
708
+ const webhookSecretDrifted = !rollingUpdateDrifted &&
709
+ (await archonWebhookSecretDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
696
710
  if (accessDrifted ||
697
711
  brokerDrifted ||
698
712
  authOrgDrifted ||
699
713
  authzDrifted ||
700
714
  bridgeDrifted ||
701
715
  rollingUpdateDrifted ||
716
+ webhookSecretDrifted ||
702
717
  redeployFailed)
703
718
  result.archon = true;
704
719
  }
@@ -923,6 +938,28 @@ export class FoundryPlatformStepExecutor {
923
938
  }
924
939
  return provisionPlatformAccessProvider(context, this.accessProvider);
925
940
  }
941
+ if (action.system === 'github' &&
942
+ action.kind === 'release-hook' &&
943
+ action.key === PLATFORM_GITHUB_RELEASE_HOOK_KEY) {
944
+ if (!this.githubReleaseHook) {
945
+ throw new Error('platform:github-release-hook needs FoundryPlatformStepExecutor to be constructed with its github release hook dependencies');
946
+ }
947
+ const hookDeps = this.githubReleaseHook;
948
+ return provisionGithubReleaseHook(context, hookDeps, async (appId) => {
949
+ const minted = await ensureGeneratedEnv(hookDeps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, () => randomBytes(32).toString('hex'));
950
+ if (minted) {
951
+ await hookDeps.coolify.bulkUpsertEnvs(appId, [
952
+ { key: GITHUB_RELEASE_WEBHOOK_SECRET_ENV, value: minted },
953
+ ]);
954
+ return minted;
955
+ }
956
+ const live = await currentEnvValue(hookDeps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV);
957
+ if (!live) {
958
+ throw new Error(`platform:github-release-hook: Archon lost its ${GITHUB_RELEASE_WEBHOOK_SECRET_ENV} between two reads`);
959
+ }
960
+ return live;
961
+ });
962
+ }
926
963
  if (action.system === 'heartbeats' && action.kind === 'reconcile') {
927
964
  if (!this.heartbeats) {
928
965
  throw new Error('heartbeats:reconcile needs FoundryPlatformStepExecutor to be constructed with its heartbeats dependencies (#540)');
@@ -3890,6 +3927,8 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
3890
3927
  { name: 'vipps-agreement', path: '/vipps/agreement' },
3891
3928
  { name: 'usage-tick', path: '/api/usage/tick' },
3892
3929
  { name: 'reporting-collect', path: '/api/reporting/collect' },
3930
+ { name: 'release-health', path: '/api/reporting/release-health' },
3931
+ { name: 'health', path: '/api/health' },
3893
3932
  { name: 'org-changes', path: '/api/orgs/*/changes' },
3894
3933
  { name: 'org-settings', path: '/api/orgs/*/settings' },
3895
3934
  { name: 'org-apps', path: '/api/orgs/*/apps' },
@@ -4456,6 +4495,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4456
4495
  const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
4457
4496
  if (cookieSecret)
4458
4497
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
4498
+ // The secret of the organisation's release webhook: minted once, never rotated, a hand-set value kept.
4499
+ const webhookSecret = await ensureGeneratedEnv(deps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, () => randomBytes(32).toString('hex'));
4500
+ if (webhookSecret)
4501
+ envs.push({ key: GITHUB_RELEASE_WEBHOOK_SECRET_ENV, value: webhookSecret });
4459
4502
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
4460
4503
  if (runtimeUrl)
4461
4504
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
@@ -4492,6 +4535,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4492
4535
  if (effectiveCookieSecret) {
4493
4536
  archonEnvHashOverrides.set('AUTH_COOKIE_SECRET', effectiveCookieSecret);
4494
4537
  }
4538
+ const effectiveWebhookSecret = webhookSecret ??
4539
+ (await currentEnvValue(deps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV));
4540
+ if (effectiveWebhookSecret) {
4541
+ archonEnvHashOverrides.set(GITHUB_RELEASE_WEBHOOK_SECRET_ENV, effectiveWebhookSecret);
4542
+ runtime.onSecret?.(effectiveWebhookSecret);
4543
+ }
4495
4544
  const effectiveRuntimeUrl = runtimeUrl ?? (await currentEnvValue(deps.coolify, appId, 'DATABASE_URL'));
4496
4545
  if (effectiveRuntimeUrl) {
4497
4546
  archonEnvHashOverrides.set('DATABASE_URL', effectiveRuntimeUrl);
@@ -4939,6 +4988,9 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4939
4988
  // when no deploy was needed this run, left exactly as it already was --
4940
4989
  // `envsHashChanged` false means it already equals `envsHash`).
4941
4990
  await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, envsHash);
4991
+ if (effectiveWebhookSecret) {
4992
+ await record(PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, createHash('sha256').update(effectiveWebhookSecret).digest('hex'));
4993
+ }
4942
4994
  // Last, after every write the applied values depend on, so a run that failed
4943
4995
  // halfway plans again.
4944
4996
  await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
@@ -5724,6 +5776,26 @@ async function authAppsNotRunning(coolify, recordedIds) {
5724
5776
  }
5725
5777
  return false;
5726
5778
  }
5779
+ /**
5780
+ * Archon's live release webhook secret is missing, or is not the one its last deploy recorded
5781
+ * (`PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY`): the release hook step wrote it after Archon's
5782
+ * last deploy, so the running Archon does not have it. A Coolify read error counts as not drifted.
5783
+ */
5784
+ async function archonWebhookSecretDrifted(coolify, appId, recorded) {
5785
+ if (!appId)
5786
+ return false;
5787
+ try {
5788
+ const live = await currentEnvValue(coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV);
5789
+ if (!live)
5790
+ return true;
5791
+ return (recorded[PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY] !==
5792
+ createHash('sha256').update(live).digest('hex'));
5793
+ }
5794
+ catch (error) {
5795
+ console.error(`platform:archon: could not read Archon's webhook secret from Coolify (${error instanceof Error ? error.name : 'error'})`);
5796
+ return false;
5797
+ }
5798
+ }
5727
5799
  async function archonRollingUpdateDrifted(coolify, appId) {
5728
5800
  if (!appId)
5729
5801
  return false;
@@ -51,7 +51,19 @@ export declare function entryRoutes(org: DeclaredOrg, recorded?: Readonly<Record
51
51
  hostname: string;
52
52
  service: string;
53
53
  }>;
54
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
54
+ /**
55
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
56
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
57
+ * the Coolify proxy origin it had, so none appears here.
58
+ */
59
+ export declare function builtinRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
60
+ hostname: string;
61
+ service: string;
62
+ }>;
63
+ /**
64
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
65
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
66
+ */
55
67
  export declare function entryRoutesMarker(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string;
56
68
  /**
57
69
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { PRIMARY_SERVER_SCOPE_KEY, balancerApplied, lbAppName, liveAppNameFor, } from './blue-green.js';
2
+ import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
3
3
  import { entryHostnames, wildcardHostname } from './hostnames.js';
4
4
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
5
5
  import { appNameFor } from './platform-diff.js';
@@ -85,9 +85,32 @@ export function entryRoutes(org, recorded = {}) {
85
85
  routes.push(wildcard);
86
86
  return routes;
87
87
  }
88
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
88
+ /**
89
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
90
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
91
+ * the Coolify proxy origin it had, so none appears here.
92
+ */
93
+ export function builtinRoutes(org, recorded = {}) {
94
+ const routes = [];
95
+ for (const app of BUILTIN_APPS) {
96
+ const subject = builtinSubject(org, app, recorded);
97
+ if (!subject || !balancerApplied(subject.id, recorded, recorded[PRIMARY_SERVER_SCOPE_KEY])) {
98
+ continue;
99
+ }
100
+ for (const hostname of subject.hostnames) {
101
+ routes.push({ hostname, service: lbOrigin(subject) });
102
+ }
103
+ }
104
+ return routes;
105
+ }
106
+ /**
107
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
108
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
109
+ */
89
110
  export function entryRoutesMarker(org, recorded = {}) {
90
- return JSON.stringify(entryRoutes(org, recorded));
111
+ const builtin = builtinRoutes(org, recorded);
112
+ const routes = entryRoutes(org, recorded);
113
+ return JSON.stringify(builtin.length > 0 ? [...routes, ...builtin] : routes);
91
114
  }
92
115
  /**
93
116
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -21,7 +21,7 @@ export interface ReleaseHost {
21
21
  export interface ReleaseHostInput {
22
22
  subject: BlueGreenSubject;
23
23
  context: ReleaseContext;
24
- coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
24
+ coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
25
25
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
26
26
  };
27
27
  /** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
@@ -1,6 +1,6 @@
1
1
  import { checkColorAddress, readReleaseHealth } from '../release/health.js';
2
2
  import { runRelease } from '../release/run.js';
3
- import { applyBlueGreenWeights } from './blue-green-apply.js';
3
+ import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
4
4
  import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
5
5
  import { waitForNewDeployment } from './deploy-health.js';
6
6
  import { isNotFoundError } from './executor.js';
@@ -19,21 +19,23 @@ export function createReleaseHost(input) {
19
19
  const id = subject.id;
20
20
  const recorded = () => context.recordedIds();
21
21
  /** Every Coolify app of `color`, one per server that has the structure applied. */
22
- const colorApps = (color) => {
22
+ const colorAppsOnServers = (color) => {
23
23
  const ids = recorded();
24
24
  const apps = [];
25
25
  if (color === 'blue')
26
- apps.push(input.primaryBlueAppId);
26
+ apps.push({ appId: input.primaryBlueAppId, server: input.primaryUuid });
27
27
  const prefix = `bg.${id}.app.`;
28
28
  for (const key of Object.keys(ids).sort()) {
29
29
  if (!key.startsWith(prefix) || !key.endsWith(`.${color}`))
30
30
  continue;
31
31
  const value = ids[key];
32
+ const server = key.slice(prefix.length, key.length - color.length - 1);
32
33
  if (value)
33
- apps.push(value);
34
+ apps.push({ appId: value, server });
34
35
  }
35
36
  return apps;
36
37
  };
38
+ const colorApps = (color) => colorAppsOnServers(color).map((a) => a.appId);
37
39
  const primaryColorApp = (color) => color === 'blue' ? input.primaryBlueAppId : recorded()[bgKey.app(id, input.primaryUuid, color)];
38
40
  /**
39
41
  * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
@@ -66,21 +68,39 @@ export function createReleaseHost(input) {
66
68
  const deployTo = async (color, digest) => {
67
69
  if (!COMMIT.test(digest))
68
70
  throw new Error(`${input.label}: the release is not a built commit`);
69
- const token = await input.healthToken();
70
- if (!token)
71
- throw new Error(`${input.label}: the health token is not held`);
72
- input.secret(token);
73
- const apps = colorApps(color);
71
+ const token = subject.reporting ? await input.healthToken() : null;
72
+ if (subject.reporting) {
73
+ if (!token)
74
+ throw new Error(`${input.label}: the health token is not held`);
75
+ input.secret(token);
76
+ }
77
+ const apps = colorAppsOnServers(color);
74
78
  if (apps.length === 0)
75
79
  throw new Error(`${input.label}: ${color} has no app recorded`);
76
- for (const appId of apps) {
80
+ for (const { appId, server } of apps) {
77
81
  await input.renew();
78
82
  await coolify.updateApplication(appId, { git_commit_sha: digest });
83
+ // The color takes blue's env as it is now, so a change made after the structure apply (sign-in, mail,
84
+ // credentials) reaches it before it serves. Blue on the primary is the source itself.
85
+ let source = [];
86
+ if (appId !== input.primaryBlueAppId) {
87
+ try {
88
+ source = await coolify.listEnvs(input.primaryBlueAppId);
89
+ }
90
+ catch {
91
+ throw new Error(`${input.label}: reading the env of the blue app failed`);
92
+ }
93
+ }
94
+ const refreshed = copiedEnv({ label: input.label, secret: input.secret }, subject, source, {
95
+ uuid: server,
96
+ primaryUuid: input.primaryUuid,
97
+ });
98
+ const reporting = token ? reportingEnv({ color, release: digest, healthToken: token }) : [];
79
99
  try {
80
- await coolify.bulkUpsertEnvs(appId, reportingEnv({ color, release: digest, healthToken: token }).map((e) => ({
81
- ...e,
82
- literal: true,
83
- })));
100
+ const envs = [...refreshed, ...reporting];
101
+ if (envs.length > 0) {
102
+ await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
103
+ }
84
104
  }
85
105
  catch {
86
106
  // A Coolify error can echo the request body, which holds the token.
@@ -128,6 +148,7 @@ export function createReleaseHost(input) {
128
148
  address: await checkColorAddress({
129
149
  ...addressOf(color),
130
150
  healthPath: subject.healthPath,
151
+ expectJson: subject.reporting && subject.healthPath === '/api/health',
131
152
  ...(input.fetch ? { fetchImpl: input.fetch } : {}),
132
153
  }),
133
154
  };
@@ -221,6 +242,7 @@ export async function runSubjectRelease(input) {
221
242
  entry: subject.config,
222
243
  now: input.now,
223
244
  deps: input.host.deps,
245
+ ...(subject.reporting ? {} : { hardOnly: true }),
224
246
  });
225
247
  for (const [key, value] of Object.entries(result.writes)) {
226
248
  if (value === null)