@monster-orange/den 0.0.0 → 0.1.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 (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * Release service — deploy + verify for the web Release tab.
3
+ *
4
+ * A faithful TypeScript port of infra-scripts/release-all.sh, made self-contained
5
+ * (den talks to `gh` directly, exactly like github-service.ts) and structured
6
+ * so the web UI can render a sortable table and stream a live deploy.
7
+ *
8
+ * Two capabilities:
9
+ * - verifyEnv(env): read-only. For each configured repo, compare latest `main`
10
+ * against the currently-deployed commit for the env (via the GitHub
11
+ * Deployments API), including the deploy-run cross-check that the script
12
+ * relies on (see verifyOne). Returns structured rows.
13
+ * - deploy(plan): dispatch CD workflows wave-by-wave with a stagger, gate each
14
+ * wave on the previous concluding success, and monitor until terminal —
15
+ * streaming progress events to a callback (the SSE route forwards them).
16
+ */
17
+
18
+ import { execFile } from "child_process";
19
+ import { promisify } from "util";
20
+ import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
21
+ import {
22
+ readReleaseConfig,
23
+ type ReleaseConfig,
24
+ type ReleaseRepo,
25
+ type ResolvedReleaseTarget,
26
+ resolveReleaseTarget,
27
+ } from "../../shared/den-config.js";
28
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
29
+ import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
30
+
31
+ import { product } from "../../shared/product.js";
32
+ const execFileAsync = promisify(execFile);
33
+
34
+ const GH_MAX_BUFFER = 16 * 1024 * 1024;
35
+ /** Verify results are cached briefly so a page reload / sort doesn't re-hit gh. */
36
+ const VERIFY_CACHE_TTL_MS = 45_000;
37
+ /**
38
+ * Live-run polling is deliberately short-lived — the Release page polls it to
39
+ * keep "is this system deploying right now" fresh — but still cached so several
40
+ * open tabs cost one round of gh calls rather than one each.
41
+ */
42
+ const RUNS_CACHE_TTL_MS = 10_000;
43
+ /** GitHub needs a moment to register a workflow_dispatch before it's listable. */
44
+ const DISPATCH_SETTLE_MS = 5_000;
45
+
46
+ // ── Structured result types (consumed by the Release table + SSE stream) ──────
47
+
48
+ export type ReleaseStatusKind =
49
+ | "up-to-date"
50
+ | "behind"
51
+ | "off-main"
52
+ | "ahead"
53
+ | "no-deploy"
54
+ | "conflict"
55
+ | "error";
56
+
57
+ export interface ReleaseStatus {
58
+ kind: ReleaseStatusKind;
59
+ /** Human text mirroring the script's STATUS column (with glyph). */
60
+ text: string;
61
+ aheadBy?: number;
62
+ behindBy?: number;
63
+ /** Soft "a newer deploy is failing/in-flight on top of the live one" note. */
64
+ warn?: string;
65
+ }
66
+
67
+ export interface ReleaseRow {
68
+ repo: string;
69
+ gh: string; // "org/repo"
70
+ wave: number;
71
+ workflow: string;
72
+ /** GitHub Environment checked (differs from the den env when mapped). */
73
+ environment: string;
74
+ /** Branch this env ships from; `mainSha` is its head. */
75
+ sourceBranch: string;
76
+ mainSha: string | null;
77
+ mainShort: string | null;
78
+ deployedSha: string | null;
79
+ deployedShort: string | null;
80
+ /** Deployed ref (branch or tag). */
81
+ branch: string | null;
82
+ /** ISO timestamp of the live deployment. */
83
+ deployedAt: string | null;
84
+ runId: string | null;
85
+ runUrl: string | null;
86
+ /** Where the deployed commit was read: version endpoint, GitHub deployment, workflow run. */
87
+ source?: string;
88
+ /**
89
+ * GitHub login of whoever triggered the deploy — the CD run's
90
+ * `triggering_actor` (falls back to the run `actor`, then the deployment's
91
+ * `creator`). null when no run/creator can be resolved.
92
+ */
93
+ deployer: string | null;
94
+ status: ReleaseStatus;
95
+ }
96
+
97
+ /** One repo to deploy, as resolved by the route from config + request. */
98
+ /** One CD workflow run, flattened to what the Release table needs. */
99
+ export interface CdRun {
100
+ id: string;
101
+ url: string;
102
+ /** queued | in_progress | completed | … (GitHub's run status) */
103
+ status: string;
104
+ conclusion: string | null;
105
+ title: string;
106
+ event: string;
107
+ createdAt: string;
108
+ /**
109
+ * Target environment, when it can be determined — else null.
110
+ *
111
+ * GitHub exposes NO environment on a run: not on the run object, not on the
112
+ * jobs API, and `workflow_dispatch` inputs are not returned. The only signal
113
+ * is the run's display title, which comes from the workflow's `run-name:`.
114
+ * Repos that set one emit "… → sandbox" (control-plane, shop-web); the rest
115
+ * show a commit subject or just "CD", and yield null here.
116
+ *
117
+ * Adding a `run-name:` carrying the environment to the remaining repos' cd.yml
118
+ * would make this exact everywhere — and makes the Actions list readable as a
119
+ * side effect.
120
+ */
121
+ envHint: string | null;
122
+ }
123
+
124
+ /**
125
+ * Live CD state for one repo.
126
+ *
127
+ * NOTE: this is intentionally NOT filtered by environment. A run object does not
128
+ * carry the environment it targets — `workflow_dispatch` inputs are not exposed
129
+ * on the run, and only some repos encode it in `run-name` (control-plane emits
130
+ * "[wf-dispatch] main → sandbox"; files-api shows the commit subject and
131
+ * billing-api just "CD"). Guessing from the title would be wrong for most
132
+ * repos, so a run in flight blocks redeploying that repo regardless of target:
133
+ * dispatching a second CD run over a live one is unsafe either way, since they
134
+ * share the repo and usually the same concurrency group.
135
+ */
136
+ export interface RepoRunState {
137
+ repo: string;
138
+ /** Newest run that has not finished, if any. */
139
+ running: CdRun | null;
140
+ /** Newest finished run — used to retire a stale failure pill. */
141
+ last: CdRun | null;
142
+ }
143
+
144
+ export interface DeployPlanEntry {
145
+ repo: string;
146
+ workflow: string;
147
+ ref: string;
148
+ wave: number;
149
+ /** workflow_dispatch inputs; unset means `environment=<env>`. */
150
+ inputs?: Record<string, string>;
151
+ }
152
+
153
+ export type ReleaseEvent =
154
+ | { type: "log"; text: string; level?: "info" | "warn" | "error" }
155
+ | { type: "phase"; name: string }
156
+ | {
157
+ type: "run";
158
+ repo: string;
159
+ runId: string | null;
160
+ url: string | null;
161
+ status: string; // queued | in_progress | completed | dispatching | skipped
162
+ conclusion: string | null;
163
+ }
164
+ | { type: "done"; ok: boolean; summary: string };
165
+
166
+ type Emit = (ev: ReleaseEvent) => void;
167
+
168
+ // ── gh helpers (async so per-repo verify fans out in parallel) ────────────────
169
+
170
+ async function gh(
171
+ args: string[],
172
+ timeout = GH_API_TIMEOUT_MS,
173
+ ): Promise<string> {
174
+ const { stdout } = await execFileAsync("gh", args, {
175
+ encoding: "utf-8",
176
+ timeout,
177
+ maxBuffer: GH_MAX_BUFFER,
178
+ });
179
+ return stdout.trim();
180
+ }
181
+
182
+ /** Like gh() but returns "" instead of throwing — for best-effort reads. */
183
+ async function ghSafe(
184
+ args: string[],
185
+ timeout = GH_API_TIMEOUT_MS,
186
+ ): Promise<string> {
187
+ try {
188
+ return await gh(args, timeout);
189
+ } catch {
190
+ return "";
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Best-effort environment for a run, from its display title.
196
+ *
197
+ * Repos that set a `run-name:` end it with the target, e.g.
198
+ * "[wf-dispatch] main → sandbox" or "[main-merge] main → staging". Only a name
199
+ * that matches a CONFIGURED environment is accepted, so a branch or commit
200
+ * subject containing an arrow can't be mistaken for one. Returns null when the
201
+ * title carries no usable environment, which the caller must treat as "unknown"
202
+ * rather than "not this env".
203
+ */
204
+ export function envFromTitle(
205
+ title: string | null | undefined,
206
+ environments: string[],
207
+ ): string | null {
208
+ const m = /→\s*([A-Za-z0-9_-]+)\s*$/.exec(title ?? "");
209
+ if (!m) return null;
210
+ const candidate = m[1].toLowerCase();
211
+ return environments.some((e) => e.toLowerCase() === candidate)
212
+ ? candidate
213
+ : null;
214
+ }
215
+
216
+ const short = (sha: string | null): string | null =>
217
+ sha ? sha.slice(0, 7) : null;
218
+
219
+ function delay(ms: number): Promise<void> {
220
+ return new Promise((resolve) => setTimeout(resolve, ms));
221
+ }
222
+
223
+ /** Sleep in small slices so an aborted stream stops promptly mid-stagger/poll. */
224
+ async function abortableDelay(
225
+ ms: number,
226
+ aborted: () => boolean,
227
+ ): Promise<void> {
228
+ const step = 500;
229
+ let waited = 0;
230
+ while (waited < ms && !aborted()) {
231
+ await delay(Math.min(step, ms - waited));
232
+ waited += step;
233
+ }
234
+ }
235
+
236
+ /** Why a deploy was refused when `release.deploy` is off. */
237
+ export const DEPLOYS_OFF_MESSAGE = `Deploys are off: the Release page is read-only. Set release.deploy: true in ${product().configFile} to enable them.`;
238
+
239
+ export class ReleaseService {
240
+ private verifyCache = new Map<
241
+ string,
242
+ { rows: ReleaseRow[]; fetchedAt: number }
243
+ >();
244
+
245
+ private runsCache: { rows: RepoRunState[]; fetchedAt: number } | null = null;
246
+ private runsInFlight: Promise<RepoRunState[]> | null = null;
247
+
248
+ /**
249
+ * The effective release config: config/den.yml + user.yml, plus every
250
+ * workspace repo whose manifest has a `release:` block. Re-read each call
251
+ * so edits apply.
252
+ */
253
+ config(): ReleaseConfig {
254
+ const cfg = readReleaseConfig();
255
+ const known = new Set(cfg.repos.map((r) => r.name));
256
+ return {
257
+ ...cfg,
258
+ repos: [
259
+ ...cfg.repos,
260
+ ...manifestReleaseRepos().filter((r) => !known.has(r.name)),
261
+ ],
262
+ };
263
+ }
264
+
265
+ private org(cfg = this.config()): string {
266
+ return cfg.org ?? "";
267
+ }
268
+
269
+ slug(repo: string, cfg = this.config()): string {
270
+ const gh = cfg.repos.find((r) => r.name === repo)?.gh;
271
+ return gh && gh.includes("/") ? gh : `${this.org(cfg)}/${repo}`;
272
+ }
273
+
274
+ /**
275
+ * Live CD-run state for every configured repo, for the Release page's poll.
276
+ *
277
+ * One gh call per repo returns the last few runs, from which we take the
278
+ * newest unfinished run (a deploy is in flight — someone else's dispatch, a
279
+ * push to main, or a re-run from the Actions tab) and the newest finished one
280
+ * (so a stale failure pill can be retired once a later run succeeded).
281
+ *
282
+ * Cached for RUNS_CACHE_TTL_MS, and concurrent callers share one in-flight
283
+ * fetch — several open tabs polling must not multiply the gh traffic against
284
+ * the API rate limit.
285
+ */
286
+ async listRepoRuns(forceRefresh = false): Promise<RepoRunState[]> {
287
+ const fresh =
288
+ this.runsCache &&
289
+ Date.now() - this.runsCache.fetchedAt < RUNS_CACHE_TTL_MS;
290
+ if (!forceRefresh && fresh) return this.runsCache!.rows;
291
+ if (this.runsInFlight) return this.runsInFlight;
292
+
293
+ const cfg = this.config();
294
+ const repos = (cfg.repos ?? []).filter(
295
+ (r) => r.enabled !== false && r.workflow,
296
+ );
297
+
298
+ this.runsInFlight = Promise.all(
299
+ repos.map(async (repo): Promise<RepoRunState> => {
300
+ const slug = this.slug(repo.name, cfg);
301
+ // Every workflow this repo deploys with, across environments.
302
+ const workflows = [
303
+ ...new Set(
304
+ cfg.environments.flatMap(
305
+ (e) => resolveReleaseTarget(repo, e, cfg).workflows,
306
+ ),
307
+ ),
308
+ ];
309
+ const runs: CdRun[] = [];
310
+ for (const raw of await Promise.all(
311
+ workflows.map((wf) =>
312
+ ghSafe([
313
+ "api",
314
+ "-X",
315
+ "GET",
316
+ `repos/${slug}/actions/workflows/${encodeURIComponent(wf)}/runs?per_page=10`,
317
+ "--jq",
318
+ "[.workflow_runs[] | {id:(.id|tostring), url:.html_url, status:.status, conclusion:.conclusion, title:.display_title, event:.event, createdAt:.created_at}]",
319
+ ]),
320
+ ),
321
+ )) {
322
+ try {
323
+ const parsed: unknown = JSON.parse(raw || "[]");
324
+ if (Array.isArray(parsed))
325
+ for (const r of parsed as CdRun[])
326
+ runs.push({
327
+ ...r,
328
+ envHint: envFromTitle(r.title, cfg.environments),
329
+ });
330
+ } catch {
331
+ /* gh error / non-JSON — treat as "nothing known", not as "idle" */
332
+ }
333
+ }
334
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
335
+ return {
336
+ repo: repo.name,
337
+ running: runs.find((r) => r.status !== "completed") ?? null,
338
+ last: runs.find((r) => r.status === "completed") ?? null,
339
+ };
340
+ }),
341
+ )
342
+ .then((rows) => {
343
+ this.runsCache = { rows, fetchedAt: Date.now() };
344
+ return rows;
345
+ })
346
+ .finally(() => {
347
+ this.runsInFlight = null;
348
+ });
349
+
350
+ return this.runsInFlight;
351
+ }
352
+
353
+ /**
354
+ * Verify every configured repo for `env`, newest-main vs currently-deployed.
355
+ * Cached for VERIFY_CACHE_TTL_MS unless forceRefresh is set. Repos are checked
356
+ * concurrently — each is an independent sequence of gh reads.
357
+ */
358
+ async verifyEnv(env: string, forceRefresh = false): Promise<ReleaseRow[]> {
359
+ const cached = this.verifyCache.get(env);
360
+ if (
361
+ !forceRefresh &&
362
+ cached &&
363
+ Date.now() - cached.fetchedAt < VERIFY_CACHE_TTL_MS
364
+ ) {
365
+ return cached.rows;
366
+ }
367
+ const cfg = this.config();
368
+ // One row per GitHub environment a repo maps this env onto (one per
369
+ // tenant for cell-per-customer repos).
370
+ const rows = await Promise.all(
371
+ cfg.repos.flatMap((repo) => {
372
+ const target = resolveReleaseTarget(repo, env, cfg);
373
+ return target.environments.map((ghEnv) =>
374
+ this.verifyOne(env, repo, cfg, ghEnv, target),
375
+ );
376
+ }),
377
+ );
378
+ this.verifyCache.set(env, { rows, fetchedAt: Date.now() });
379
+ return rows;
380
+ }
381
+
382
+ /** Invalidate the verify cache for an env (call after a deploy dispatches). */
383
+ invalidate(env?: string): void {
384
+ if (env) this.verifyCache.delete(env);
385
+ else this.verifyCache.clear();
386
+ }
387
+
388
+ /**
389
+ * Verify a single repo. Ported from release-all.sh verify_one_repo, including
390
+ * the two conflict checks:
391
+ * 1) HARD: the newest `success` deployment may belong to a CD run that
392
+ * ultimately FAILED (a late-failing run leaves stale success records) —
393
+ * resolve the owning run via the status log_url and check its conclusion.
394
+ * 2) SOFT: a deployment newer than the live one exists but hasn't succeeded
395
+ * (a failed/in-flight redeploy) — the live SHA is still real, so annotate.
396
+ */
397
+ private async verifyOne(
398
+ env: string,
399
+ repo: ReleaseRepo,
400
+ cfg: ReleaseConfig,
401
+ ghEnv: string,
402
+ target: ResolvedReleaseTarget,
403
+ ): Promise<ReleaseRow> {
404
+ const ghSlug = this.slug(repo.name, cfg);
405
+ const src = target.branch;
406
+ const base = {
407
+ repo: repo.name,
408
+ gh: ghSlug,
409
+ wave: repo.wave,
410
+ workflow: target.workflows[0],
411
+ environment: ghEnv,
412
+ sourceBranch: src,
413
+ };
414
+ const errorRow = (
415
+ status: ReleaseStatus,
416
+ mainSha: string | null = null,
417
+ deployedSha: string | null = null,
418
+ branch: string | null = null,
419
+ deployedAt: string | null = null,
420
+ runId: string | null = null,
421
+ deployer: string | null = null,
422
+ ): ReleaseRow => ({
423
+ ...base,
424
+ mainSha,
425
+ mainShort: short(mainSha),
426
+ deployedSha,
427
+ deployedShort: short(deployedSha),
428
+ branch,
429
+ deployedAt,
430
+ runId,
431
+ runUrl: runId
432
+ ? `https://github.com/${ghSlug}/actions/runs/${runId}`
433
+ : null,
434
+ deployer,
435
+ status,
436
+ });
437
+
438
+ // --- latest main sha ---
439
+ const mainSha = await ghSafe([
440
+ "api",
441
+ "-X",
442
+ "GET",
443
+ `repos/${ghSlug}/branches/${encodeURIComponent(src)}`,
444
+ "--jq",
445
+ ".commit.sha",
446
+ ]);
447
+ if (!mainSha || mainSha === "null") {
448
+ return errorRow({ kind: "error", text: `could not read ${src} branch` });
449
+ }
450
+
451
+ // --- manifest sources: a version endpoint, else the release workflow's
452
+ // newest successful run on the source branch. A `deployment` source (or a repo from
453
+ // config/den.yml with no sources) reads GitHub deployments below.
454
+ const source = repo.sources?.[env];
455
+ if (repo.sources && !source) {
456
+ return errorRow(
457
+ {
458
+ kind: "no-deploy",
459
+ text: `? no release source for ${env} in ${product().manifestYml}`,
460
+ },
461
+ mainSha,
462
+ );
463
+ }
464
+ if (source?.version) {
465
+ const sha = await readVersionSha(source.version, source.field ?? "build");
466
+ if (sha.ok) {
467
+ return {
468
+ ...errorRow({ kind: "up-to-date", text: "" }, mainSha, sha.sha),
469
+ source: "version endpoint",
470
+ status: await classifyAgainstMain(
471
+ ghSlug,
472
+ mainSha,
473
+ sha.sha,
474
+ undefined,
475
+ src,
476
+ ),
477
+ };
478
+ }
479
+ if (!source.deployment && !repo.workflow) {
480
+ return {
481
+ ...errorRow(
482
+ { kind: "error", text: `✗ version endpoint: ${sha.error}` },
483
+ mainSha,
484
+ ),
485
+ source: "version endpoint",
486
+ };
487
+ }
488
+ }
489
+ if (source && !source.deployment) {
490
+ const run = await ghSafe([
491
+ "api",
492
+ "-X",
493
+ "GET",
494
+ `repos/${ghSlug}/actions/workflows/${encodeURIComponent(repo.workflow)}/runs?branch=${encodeURIComponent(src)}&status=success&per_page=1`,
495
+ "--jq",
496
+ '.workflow_runs[0] | [(.head_sha // ""), (.created_at // ""), ((.id // "")|tostring), (.triggering_actor.login // .actor.login // "")] | @tsv',
497
+ ]);
498
+ const [sha = "", at = "", rid = "", actor = ""] = run.split("\t");
499
+ if (!repo.workflow || !sha) {
500
+ return {
501
+ ...errorRow(
502
+ { kind: "no-deploy", text: `? no successful deploy run on ${src}` },
503
+ mainSha,
504
+ ),
505
+ source: "workflow run",
506
+ };
507
+ }
508
+ return {
509
+ ...errorRow(
510
+ { kind: "up-to-date", text: "" },
511
+ mainSha,
512
+ sha,
513
+ src,
514
+ at || null,
515
+ rid || null,
516
+ actor || null,
517
+ ),
518
+ source: "workflow run",
519
+ status: await classifyAgainstMain(ghSlug, mainSha, sha, undefined, src),
520
+ };
521
+ }
522
+ // A `deployment` source names the GitHub environment outright.
523
+ const deployEnv = source?.deployment ?? ghEnv;
524
+
525
+ // --- deployments for env, newest first ---
526
+ const deploymentsRaw = await ghSafe([
527
+ "api",
528
+ "-X",
529
+ "GET",
530
+ // Wider page: preview/CI deployment records can bury the real CD deploy, and
531
+ // we filter to the CD workflow below, so fetch enough history to find it.
532
+ `repos/${ghSlug}/deployments?environment=${encodeURIComponent(deployEnv)}&per_page=50`,
533
+ ]);
534
+ if (!deploymentsRaw) {
535
+ return errorRow(
536
+ { kind: "conflict", text: "✗ deployments API error" },
537
+ mainSha,
538
+ );
539
+ }
540
+ let deployments: Array<{
541
+ id: number;
542
+ sha: string;
543
+ created_at: string;
544
+ ref: string;
545
+ creator?: { login?: string };
546
+ }> = [];
547
+ try {
548
+ deployments = JSON.parse(deploymentsRaw);
549
+ } catch {
550
+ deployments = [];
551
+ }
552
+
553
+ // Scan deployments newest-first. CRITICAL: GitHub's Deployments API returns
554
+ // records from EVERY workflow that touched this environment — including PR
555
+ // "preview" CI. jobs-runtime's ci-pulumi-preview.yml, for example,
556
+ // registers deployment records for sandbox+dev+staging+prod on a feature
557
+ // branch (a `pulumi preview` dry-run), which are NOT real rollouts but whose
558
+ // status is `success`. Trusting them shows a feature branch as "deployed"
559
+ // when the real CD (cd.yml) shipped `main`. So we resolve each deployment's
560
+ // owning run and keep ONLY those from this repo's configured CD workflow.
561
+ // We track: the newest CD attempt of any state (for the soft "newer deploy
562
+ // failing" note) and the newest CD deployment whose latest status is
563
+ // `success` (the live one), plus that run's conclusion + deployer.
564
+ let deployedSha = "";
565
+ let deployedAt = "";
566
+ let deployedRef = "";
567
+ let deployedCreator = "";
568
+ let runId: string | null = null;
569
+ let runConcl = "";
570
+ let runActor = "";
571
+ let newestState = "";
572
+ let newestAt = "";
573
+ let firstCd = true;
574
+ for (const d of deployments) {
575
+ if (!d?.id) continue;
576
+ const srow = await ghSafe([
577
+ "api",
578
+ "-X",
579
+ "GET",
580
+ `repos/${ghSlug}/deployments/${d.id}/statuses?per_page=1`,
581
+ "--jq",
582
+ '[(.[0].state // ""), (.[0].log_url // .[0].target_url // "")] | @tsv',
583
+ ]);
584
+ const [state = "", logurl = ""] = srow.split("\t");
585
+
586
+ // Attribute the deployment to its owning workflow run; without a run id we
587
+ // can't tell whether it's a real CD deploy, so skip it.
588
+ const rm = logurl.match(/runs\/(\d+)/);
589
+ if (!rm) continue;
590
+ const rid = rm[1];
591
+ // One call: run conclusion (cross-check), deployer, and workflow path.
592
+ const runRow = await ghSafe([
593
+ "api",
594
+ "-X",
595
+ "GET",
596
+ `repos/${ghSlug}/actions/runs/${rid}`,
597
+ "--jq",
598
+ '[(.conclusion // .status // ""), (.triggering_actor.login // .actor.login // ""), (.path // "")] | @tsv',
599
+ ]);
600
+ const [rConcl = "", rActor = "", rPath = ""] = runRow.split("\t");
601
+ // Keep only deployments from THIS repo's configured CD workflow; preview /
602
+ // CI / other workflows that register env deployments are ignored.
603
+ const rWorkflow = rPath.split("/").pop() || "";
604
+ const wanted = target.workflows.filter(Boolean);
605
+ // No workflow configured (a manifest-only repo): any deployment counts.
606
+ if (wanted.length && !wanted.includes(rWorkflow)) continue;
607
+
608
+ if (firstCd) {
609
+ newestState = state;
610
+ newestAt = d.created_at;
611
+ firstCd = false;
612
+ }
613
+ // `success` = active; `inactive` = it DID deploy but was later superseded
614
+ // (GitHub auto-inactivates the active deployment when a newer one lands —
615
+ // including a dry-run preview, which doesn't actually change the env). Both
616
+ // mean this CD ref rolled out; the run-conclusion cross-check below guards
617
+ // validity. failure/in_progress/queued are not live and fall through.
618
+ if (state === "success" || state === "inactive") {
619
+ deployedSha = d.sha;
620
+ deployedAt = d.created_at;
621
+ deployedRef = d.ref;
622
+ deployedCreator = d.creator?.login ?? "";
623
+ runId = rid;
624
+ runConcl = rConcl;
625
+ runActor = rActor;
626
+ base.workflow = rWorkflow;
627
+ break;
628
+ }
629
+ }
630
+
631
+ if (!deployedSha) {
632
+ let note = `no successful ${repo.workflow || "CD"} deployment to ${deployEnv}`;
633
+ if (newestState && newestState !== "success") {
634
+ note = `last CD deploy ${newestState} (none succeeded)`;
635
+ }
636
+ return errorRow({ kind: "no-deploy", text: `? ${note}` }, mainSha);
637
+ }
638
+
639
+ // CONFLICT 1 (hard): reported success actually belongs to a failed run.
640
+ if (runConcl && runConcl !== "success") {
641
+ return errorRow(
642
+ { kind: "conflict", text: `✗ deploy run ${runConcl} — NOT rolled out` },
643
+ mainSha,
644
+ deployedSha,
645
+ deployedRef,
646
+ deployedAt,
647
+ runId,
648
+ runActor || deployedCreator || null,
649
+ );
650
+ }
651
+
652
+ // CONFLICT 2 (soft): a newer, not-yet-successful deploy sits on top.
653
+ let warn: string | undefined;
654
+ if (newestState && newestState !== "success" && newestAt > deployedAt) {
655
+ warn = `⚠ newer deploy ${newestState} @ ${newestAt.slice(0, 19)}Z`;
656
+ }
657
+
658
+ // Classify deployed-vs-main.
659
+ const status = await classifyAgainstMain(
660
+ ghSlug,
661
+ mainSha,
662
+ deployedSha,
663
+ warn,
664
+ src,
665
+ );
666
+
667
+ return {
668
+ ...base,
669
+ mainSha,
670
+ mainShort: short(mainSha),
671
+ deployedSha,
672
+ deployedShort: short(deployedSha),
673
+ branch: deployedRef || null,
674
+ deployedAt: deployedAt || null,
675
+ runId,
676
+ runUrl: runId
677
+ ? `https://github.com/${ghSlug}/actions/runs/${runId}`
678
+ : null,
679
+ deployer: runActor || deployedCreator || null,
680
+ source: "GitHub deployment",
681
+ status,
682
+ };
683
+ }
684
+
685
+ // ── Dispatch + monitor ──────────────────────────────────────────────────────
686
+
687
+ /** List branches for a repo (for the deploy-time branch selector). */
688
+ async listBranches(repo: string): Promise<string[]> {
689
+ const ghSlug = this.slug(repo);
690
+ const raw = await ghSafe([
691
+ "api",
692
+ "--paginate",
693
+ `repos/${ghSlug}/branches?per_page=100`,
694
+ "--jq",
695
+ "[.[].name]",
696
+ ]);
697
+ if (!raw) return [];
698
+ const names = raw
699
+ .split("\n")
700
+ .filter(Boolean)
701
+ .flatMap((line) => {
702
+ try {
703
+ return JSON.parse(line) as string[];
704
+ } catch {
705
+ return [];
706
+ }
707
+ });
708
+ // main first, then the rest sorted.
709
+ const rest = names.filter((n) => n !== "main").sort();
710
+ return names.includes("main") ? ["main", ...rest] : rest;
711
+ }
712
+
713
+ /**
714
+ * Dispatch one repo's CD workflow and resolve the run id.
715
+ * Ports dispatch_entry: run the workflow, wait for GitHub to register it, then
716
+ * find the workflow_dispatch run created at/after our start time.
717
+ */
718
+ private async dispatchOne(
719
+ env: string,
720
+ entry: DeployPlanEntry,
721
+ cfg: ReleaseConfig,
722
+ emit: Emit,
723
+ ): Promise<string | null> {
724
+ const ghSlug = this.slug(entry.repo, cfg);
725
+ const startIso = new Date().toISOString();
726
+ emit({
727
+ type: "log",
728
+ text: `[dispatch] ${entry.repo} @ ${entry.ref} → ${env}`,
729
+ });
730
+ emit({
731
+ type: "run",
732
+ repo: entry.repo,
733
+ runId: null,
734
+ url: null,
735
+ status: "dispatching",
736
+ conclusion: null,
737
+ });
738
+
739
+ try {
740
+ await gh([
741
+ "workflow",
742
+ "run",
743
+ entry.workflow,
744
+ "--repo",
745
+ ghSlug,
746
+ "--ref",
747
+ entry.ref,
748
+ ...Object.entries(entry.inputs ?? { environment: env }).flatMap(
749
+ ([k, v]) => ["-f", `${k}=${v}`],
750
+ ),
751
+ ]);
752
+ } catch (e) {
753
+ emit({
754
+ type: "log",
755
+ level: "error",
756
+ text: ` [error] failed to dispatch ${entry.repo}: ${(e as Error).message}`,
757
+ });
758
+ emit({
759
+ type: "run",
760
+ repo: entry.repo,
761
+ runId: null,
762
+ url: null,
763
+ status: "completed",
764
+ conclusion: "dispatch-failed",
765
+ });
766
+ throw e;
767
+ }
768
+
769
+ await delay(DISPATCH_SETTLE_MS);
770
+
771
+ const runId = await ghSafe([
772
+ "run",
773
+ "list",
774
+ "--repo",
775
+ ghSlug,
776
+ "--workflow",
777
+ entry.workflow,
778
+ "--limit",
779
+ "10",
780
+ "--json",
781
+ "databaseId,createdAt,event",
782
+ "--jq",
783
+ `[.[] | select(.event=="workflow_dispatch" and .createdAt >= "${startIso}")][0].databaseId`,
784
+ ]);
785
+
786
+ if (!runId || runId === "null") {
787
+ emit({
788
+ type: "log",
789
+ level: "warn",
790
+ text: ` [warn] could not resolve run id for ${entry.repo}; monitoring skipped.`,
791
+ });
792
+ emit({
793
+ type: "run",
794
+ repo: entry.repo,
795
+ runId: null,
796
+ url: null,
797
+ status: "skipped",
798
+ conclusion: null,
799
+ });
800
+ return null;
801
+ }
802
+
803
+ const url = `https://github.com/${ghSlug}/actions/runs/${runId}`;
804
+ emit({ type: "log", text: ` → run ${runId} (${url})` });
805
+ emit({
806
+ type: "run",
807
+ repo: entry.repo,
808
+ runId,
809
+ url,
810
+ status: "queued",
811
+ conclusion: null,
812
+ });
813
+ return runId;
814
+ }
815
+
816
+ /** gh run view → {status, conclusion, url}. */
817
+ private async runStatus(
818
+ repo: string,
819
+ runId: string,
820
+ cfg: ReleaseConfig,
821
+ ): Promise<{ status: string; conclusion: string | null; url: string }> {
822
+ const ghSlug = this.slug(repo, cfg);
823
+ const row = await ghSafe([
824
+ "run",
825
+ "view",
826
+ runId,
827
+ "--repo",
828
+ ghSlug,
829
+ "--json",
830
+ "status,conclusion,url",
831
+ "--jq",
832
+ '[.status, (.conclusion // ""), .url] | @tsv',
833
+ ]);
834
+ const [status = "unknown", conclusion = "", url = ""] = row.split("\t");
835
+ return { status, conclusion: conclusion || null, url };
836
+ }
837
+
838
+ /**
839
+ * Poll the given repo→runId map until every run reaches a terminal state,
840
+ * emitting a `run` event per repo each poll. Returns the list of repos whose
841
+ * runs did NOT conclude success (empty = all good). Repos with a null runId
842
+ * are treated as failures (we can't confirm they deployed), matching the
843
+ * script's gate behaviour.
844
+ */
845
+ private async monitor(
846
+ runIds: Map<string, string | null>,
847
+ cfg: ReleaseConfig,
848
+ emit: Emit,
849
+ aborted: () => boolean,
850
+ ): Promise<string[]> {
851
+ const failed: string[] = [];
852
+ // eslint-disable-next-line no-constant-condition
853
+ while (true) {
854
+ if (aborted()) return failed;
855
+ let allTerminal = true;
856
+ failed.length = 0;
857
+
858
+ for (const [repo, runId] of runIds) {
859
+ if (!runId) {
860
+ emit({
861
+ type: "run",
862
+ repo,
863
+ runId: null,
864
+ url: null,
865
+ status: "skipped",
866
+ conclusion: "unresolved-run-id",
867
+ });
868
+ failed.push(repo);
869
+ continue;
870
+ }
871
+ const { status, conclusion, url } = await this.runStatus(
872
+ repo,
873
+ runId,
874
+ cfg,
875
+ );
876
+ emit({
877
+ type: "run",
878
+ repo,
879
+ runId,
880
+ url,
881
+ status,
882
+ conclusion,
883
+ });
884
+ if (status !== "completed") {
885
+ allTerminal = false;
886
+ } else if (conclusion && conclusion !== "success") {
887
+ failed.push(repo);
888
+ }
889
+ }
890
+
891
+ if (allTerminal) return failed;
892
+ await abortableDelay(cfg.pollSeconds * 1000, aborted);
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Execute a deploy plan wave-by-wave. Within a wave, dispatch each entry with
898
+ * a stagger; after each non-final wave, gate on every run concluding success
899
+ * (abort remaining waves on any failure); finally monitor the last wave until
900
+ * terminal. Streams progress via `emit`. Returns true iff every run succeeded.
901
+ */
902
+ async deploy(
903
+ env: string,
904
+ plan: DeployPlanEntry[],
905
+ emit: Emit,
906
+ aborted: () => boolean,
907
+ ): Promise<boolean> {
908
+ const cfg = this.config();
909
+ if (!cfg.deploy) {
910
+ emit({
911
+ type: "done",
912
+ ok: false,
913
+ summary: DEPLOYS_OFF_MESSAGE,
914
+ });
915
+ return false;
916
+ }
917
+ this.invalidate(env); // deployed state is about to change
918
+
919
+ const waves = [...new Set(plan.map((p) => p.wave))].sort((a, b) => a - b);
920
+ const allRunIds = new Map<string, string | null>();
921
+ let lastFailed: string[] = [];
922
+
923
+ for (let wi = 0; wi < waves.length; wi++) {
924
+ if (aborted()) {
925
+ emit({ type: "done", ok: false, summary: "Aborted." });
926
+ return false;
927
+ }
928
+ const wave = waves[wi];
929
+ const entries = plan.filter((p) => p.wave === wave);
930
+ emit({
931
+ type: "phase",
932
+ name: `Wave ${wave} — dispatch (${entries.map((e) => e.repo).join(", ")})`,
933
+ });
934
+
935
+ const waveRunIds = new Map<string, string | null>();
936
+ for (let i = 0; i < entries.length; i++) {
937
+ if (aborted()) {
938
+ emit({ type: "done", ok: false, summary: "Aborted." });
939
+ return false;
940
+ }
941
+ let runId: string | null = null;
942
+ try {
943
+ runId = await this.dispatchOne(env, entries[i], cfg, emit);
944
+ } catch {
945
+ emit({
946
+ type: "done",
947
+ ok: false,
948
+ summary: `Dispatch failed for ${entries[i].repo}; aborting.`,
949
+ });
950
+ return false;
951
+ }
952
+ waveRunIds.set(entries[i].repo, runId);
953
+ allRunIds.set(entries[i].repo, runId);
954
+ if (i < entries.length - 1) {
955
+ emit({
956
+ type: "log",
957
+ text: ` (waiting ${cfg.staggerSeconds}s before next dispatch)`,
958
+ });
959
+ await abortableDelay(cfg.staggerSeconds * 1000, aborted);
960
+ }
961
+ }
962
+
963
+ // Gate: wait for this wave to finish before the next dispatches.
964
+ const isLast = wi === waves.length - 1;
965
+ emit({
966
+ type: "phase",
967
+ name: isLast
968
+ ? `Wave ${wave} — monitor`
969
+ : `Wave ${wave} — gate (waiting for success before next wave)`,
970
+ });
971
+ const failed = await this.monitor(waveRunIds, cfg, emit, aborted);
972
+ if (aborted()) {
973
+ emit({ type: "done", ok: false, summary: "Aborted." });
974
+ return false;
975
+ }
976
+ lastFailed = failed;
977
+ if (failed.length > 0 && !isLast) {
978
+ emit({
979
+ type: "log",
980
+ level: "error",
981
+ text: `[gate] wave ${wave} failed: ${failed.join(", ")}. Aborting subsequent waves.`,
982
+ });
983
+ emit({
984
+ type: "done",
985
+ ok: false,
986
+ summary: `Wave ${wave} failed (${failed.join(", ")}); later waves cancelled.`,
987
+ });
988
+ return false;
989
+ }
990
+ }
991
+
992
+ // Earlier waves abort on failure, so the only outstanding failures are the
993
+ // final wave's — and monitor() already counts unresolved run ids as failures.
994
+ const ok = lastFailed.length === 0;
995
+ emit({
996
+ type: "done",
997
+ ok,
998
+ summary: ok
999
+ ? `All ${allRunIds.size} deployment(s) succeeded.`
1000
+ : `${lastFailed.length} deployment(s) did not succeed: ${lastFailed.join(", ")}.`,
1001
+ });
1002
+ return ok;
1003
+ }
1004
+ }
1005
+
1006
+ export const releaseService = new ReleaseService();
1007
+
1008
+ /** How a deployed commit relates to main (same / behind / ahead / off-main). */
1009
+ export async function classifyAgainstMain(
1010
+ ghSlug: string,
1011
+ mainSha: string,
1012
+ deployedSha: string,
1013
+ warn?: string,
1014
+ /** The branch the env ships from ("behind <branch>"). */
1015
+ src = "main",
1016
+ ): Promise<ReleaseStatus> {
1017
+ let status: ReleaseStatus;
1018
+ if (deployedSha === mainSha) {
1019
+ status = { kind: "up-to-date", text: "✓ up-to-date", warn };
1020
+ } else {
1021
+ const cmpRaw = await ghSafe([
1022
+ "api",
1023
+ "-X",
1024
+ "GET",
1025
+ `repos/${ghSlug}/compare/${deployedSha}...${encodeURIComponent(src)}`,
1026
+ ]);
1027
+ if (!cmpRaw) {
1028
+ status = {
1029
+ kind: "conflict",
1030
+ text: "✗ drift (compare API error)",
1031
+ warn,
1032
+ };
1033
+ } else {
1034
+ let cmp: { status?: string; ahead_by?: number; behind_by?: number } = {};
1035
+ try {
1036
+ cmp = JSON.parse(cmpRaw);
1037
+ } catch {
1038
+ /* fall through to unknown */
1039
+ }
1040
+ const aheadBy = cmp.ahead_by ?? 0;
1041
+ const behindBy = cmp.behind_by ?? 0;
1042
+ switch (cmp.status) {
1043
+ case "ahead": // main is ahead → deployed is behind main
1044
+ status = {
1045
+ kind: "behind",
1046
+ text: `✗ behind ${src} by ${aheadBy} commit(s)`,
1047
+ aheadBy,
1048
+ warn,
1049
+ };
1050
+ break;
1051
+ case "diverged":
1052
+ status = {
1053
+ kind: "off-main",
1054
+ text: `⚠ off-${src} (${src} +${aheadBy}/-${behindBy})`,
1055
+ aheadBy,
1056
+ behindBy,
1057
+ warn,
1058
+ };
1059
+ break;
1060
+ case "behind": // main is behind → deployed is ahead of main
1061
+ status = {
1062
+ kind: "ahead",
1063
+ text: `⚠ ahead of ${src} by ${behindBy} commit(s)`,
1064
+ behindBy,
1065
+ warn,
1066
+ };
1067
+ break;
1068
+ default:
1069
+ status = {
1070
+ kind: "conflict",
1071
+ text: `✗ drift (status=${cmp.status ?? "?"})`,
1072
+ warn,
1073
+ };
1074
+ }
1075
+ }
1076
+ }
1077
+ return status;
1078
+ }
1079
+
1080
+ /**
1081
+ * The commit a service reports at a JSON version endpoint (`field` names the
1082
+ * key; a nested key may use dots). A value that is not a hex SHA is an error:
1083
+ * a semver says which release, not which commit.
1084
+ */
1085
+ export async function readVersionSha(
1086
+ url: string,
1087
+ field: string,
1088
+ timeoutMs = 10_000,
1089
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
1090
+ try {
1091
+ const res = await fetch(url, {
1092
+ signal: AbortSignal.timeout(timeoutMs),
1093
+ headers: { accept: "application/json" },
1094
+ redirect: "manual",
1095
+ });
1096
+ if (res.status !== 200) return { ok: false, error: `HTTP ${res.status}` };
1097
+ let v: unknown = await res.json();
1098
+ for (const k of field.split(".")) {
1099
+ v =
1100
+ v && typeof v === "object"
1101
+ ? (v as Record<string, unknown>)[k]
1102
+ : undefined;
1103
+ }
1104
+ if (typeof v !== "string" || !/^[0-9a-f]{7,40}$/i.test(v))
1105
+ return { ok: false, error: `no commit SHA at "${field}"` };
1106
+ return { ok: true, sha: v.toLowerCase() };
1107
+ } catch (e) {
1108
+ return { ok: false, error: (e as Error).message };
1109
+ }
1110
+ }
1111
+
1112
+ /** Workspace repos whose manifest has a `release:` block, as release repos. */
1113
+ export function manifestReleaseRepos(): ReleaseRepo[] {
1114
+ const catalog = readCatalog();
1115
+ return listReposWithManifests(null, catalog)
1116
+ .filter((r) => r.manifest.release)
1117
+ .map((r) => ({
1118
+ name: r.repo,
1119
+ gh: catalog[r.repo]?.gh,
1120
+ workflow: r.manifest.release?.workflow ?? "",
1121
+ wave: 0,
1122
+ sources: (r.manifest.release?.environments ??
1123
+ {}) as ReleaseRepo["sources"],
1124
+ }));
1125
+ }