@omega.js/desktop 0.50.0 → 0.51.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 (267) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/css/tokens/_index.scss +1 -1
  3. package/dist/assets/themes/base/_includes/frontend/sections/account-section-header.html +4 -1
  4. package/dist/assets/themes/base/_includes/frontend/sections/footer.html +13 -6
  5. package/dist/assets/themes/base/_includes/frontend/sections/nav.html +10 -8
  6. package/dist/assets/themes/base/_includes/global/sections/account.html +3 -1
  7. package/dist/assets/themes/base/_includes/global/sections/app-sidebar.html +12 -8
  8. package/dist/assets/themes/base/_includes/global/sections/app-topbar.html +10 -6
  9. package/dist/assets/themes/base/_includes/global/sections/page-header.html +8 -4
  10. package/dist/assets/themes/base/_layouts/backend/pages/dashboard/index.html +24 -24
  11. package/dist/assets/themes/base/_layouts/frontend/pages/about.html +10 -10
  12. package/dist/assets/themes/base/_layouts/frontend/pages/account/index.html +35 -35
  13. package/dist/assets/themes/base/_layouts/frontend/pages/alternatives/index.html +4 -4
  14. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signin.html +1 -1
  15. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signup.html +4 -4
  16. package/dist/assets/themes/base/_layouts/frontend/pages/contact.html +3 -3
  17. package/dist/assets/themes/base/_layouts/frontend/pages/download.html +34 -33
  18. package/dist/assets/themes/base/_layouts/frontend/pages/extension/index.html +11 -11
  19. package/dist/assets/themes/base/_layouts/frontend/pages/legal/document.html +1 -1
  20. package/dist/assets/themes/base/_layouts/frontend/pages/status.html +1 -1
  21. package/dist/assets/themes/base/_layouts/frontend/pages/team/index.html +9 -7
  22. package/dist/assets/themes/base/_layouts/frontend/pages/team/member.html +5 -3
  23. package/dist/assets/themes/base/_sections/about/letter/section.html +1 -1
  24. package/dist/assets/themes/base/_sections/about/letter/section.json5 +4 -4
  25. package/dist/assets/themes/base/_sections/marketing/bento/section.html +1 -1
  26. package/dist/assets/themes/base/_sections/marketing/bento/section.json5 +15 -15
  27. package/dist/assets/themes/base/_sections/marketing/cta/section.json5 +1 -1
  28. package/dist/assets/themes/base/_sections/marketing/hero/section.html +6 -6
  29. package/dist/assets/themes/base/_sections/marketing/hero/section.json5 +10 -10
  30. package/dist/assets/themes/base/_sections/marketing/product-demo/section.html +1 -1
  31. package/dist/assets/themes/base/_sections/marketing/product-demo/section.json5 +2 -2
  32. package/dist/assets/themes/base/_sections/marketing/stats/section.html +1 -1
  33. package/dist/assets/themes/base/_sections/marketing/stats/section.json5 +5 -5
  34. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.html +1 -1
  35. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.json5 +2 -2
  36. package/dist/assets/themes/neobrutalism/_layouts/frontend/pages/index.html +10 -10
  37. package/dist/assets/themes/newsflash/_layouts/frontend/pages/index.html +10 -10
  38. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.html +2 -2
  39. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.json5 +4 -4
  40. package/dist/build.js +69 -28
  41. package/dist/cli-run.js +20 -13
  42. package/dist/cli.js +12 -7
  43. package/dist/commands/build.js +0 -2
  44. package/dist/commands/deploy.js +116 -44
  45. package/dist/commands/finalize-release.js +5 -4
  46. package/dist/commands/launch.js +12 -7
  47. package/dist/commands/lib/deploy-precheck.js +66 -34
  48. package/dist/commands/lib/ensure-target.js +71 -11
  49. package/dist/commands/package.js +0 -1
  50. package/dist/commands/publish.js +11 -4
  51. package/dist/commands/release.js +99 -252
  52. package/dist/commands/runner.js +42 -5
  53. package/dist/commands/sign-windows.js +46 -15
  54. package/dist/commands/test.js +4 -3
  55. package/dist/commands/validate-certs.js +291 -115
  56. package/dist/config/page-template.html +4 -0
  57. package/dist/defaults/.github/workflows/build.yml +144 -64
  58. package/dist/defaults/AGENTS.md +3 -2
  59. package/dist/defaults/_.gitignore +4 -2
  60. package/dist/defaults/config/certs/README.md +25 -45
  61. package/dist/defaults/config/omega.json5 +66 -32
  62. package/dist/defaults/hooks/deploy/pre.js +10 -0
  63. package/dist/defaults/src/assets/scss/main.scss +12 -2
  64. package/dist/defaults/src/integrations/tray/index.js +1 -1
  65. package/dist/gulp/main.js +13 -25
  66. package/dist/gulp/tasks/audit.js +18 -6
  67. package/dist/gulp/tasks/build-config.js +181 -60
  68. package/dist/gulp/tasks/bundle.js +108 -36
  69. package/dist/gulp/tasks/release.js +86 -4
  70. package/dist/gulp/tasks/sass.js +11 -0
  71. package/dist/hooks/lib/notarize-tools.js +137 -0
  72. package/dist/hooks/notarize-artifacts.js +57 -0
  73. package/dist/hooks/notarize.js +59 -8
  74. package/dist/lib/auth-persistence.js +25 -0
  75. package/dist/lib/client-bridge.js +11 -8
  76. package/dist/lib/deep-link.js +15 -8
  77. package/dist/lib/protocol.js +7 -1
  78. package/dist/lib/restart-manager/install.js +6 -3
  79. package/dist/lib/sign-helpers/auto-unlock.js +65 -27
  80. package/dist/lib/sign-helpers/console-lock.js +34 -0
  81. package/dist/lib/sign-helpers/exec-with-limit.js +68 -0
  82. package/dist/lib/sign-helpers/resolve-icons.js +6 -4
  83. package/dist/lib/tray.js +7 -6
  84. package/dist/main.js +16 -0
  85. package/dist/preload.js +8 -0
  86. package/dist/renderer.js +16 -4
  87. package/dist/runner/job-started.js +104 -0
  88. package/dist/test/fixtures/consumer-app/config/omega.json5 +7 -0
  89. package/dist/test/fixtures/consumer-app/package.json +1 -1
  90. package/dist/test/fixtures/consumer-app/src/assets/js/components/main/index.js +7 -1
  91. package/dist/test/harness/main-entry.js +2 -1
  92. package/dist/test/harness/renderer-preload.js +12 -3
  93. package/dist/test/runners/boot.js +101 -11
  94. package/dist/test/runners/electron.js +1 -1
  95. package/dist/test/suites/boot/consumer-app-boots.test.js +54 -0
  96. package/dist/test/suites/build/audit.test.js +45 -6
  97. package/dist/test/suites/build/auth-persistence-resolve.test.js +119 -0
  98. package/dist/test/suites/build/auto-unlock.test.js +105 -0
  99. package/dist/test/suites/build/boot-runner-timeout.test.js +253 -0
  100. package/dist/test/suites/build/brand-scss.test.js +106 -0
  101. package/dist/test/suites/build/build-config.test.js +146 -36
  102. package/dist/test/suites/build/build-json-bake.test.js +245 -0
  103. package/dist/test/suites/build/build-verbs.test.js +48 -15
  104. package/dist/test/suites/build/build-workflow-jobs.test.js +190 -0
  105. package/dist/test/suites/build/cli.test.js +32 -10
  106. package/dist/test/suites/build/config-schema.test.js +4 -4
  107. package/dist/test/suites/build/console-lock.test.js +51 -0
  108. package/dist/test/suites/build/defaults-scaffold.test.js +71 -2
  109. package/dist/test/suites/build/deploy-direct.test.js +241 -0
  110. package/dist/test/suites/build/deploy-dispatch.test.js +287 -0
  111. package/dist/test/suites/build/deploy-hook.test.js +169 -0
  112. package/dist/test/suites/build/ensure-target.test.js +14 -2
  113. package/dist/test/suites/build/env-delivery.test.js +19 -10
  114. package/dist/test/suites/build/env-watch.test.js +18 -7
  115. package/dist/test/suites/build/esm-only-dependency.test.js +127 -0
  116. package/dist/test/suites/build/exec-with-limit.test.js +53 -0
  117. package/dist/test/suites/build/finalize-release.test.js +2 -2
  118. package/dist/test/suites/build/get-config.test.js +120 -8
  119. package/dist/test/suites/build/github-utils.test.js +12 -6
  120. package/dist/test/suites/build/license-stamp.test.js +6 -4
  121. package/dist/test/suites/build/manager.test.js +88 -68
  122. package/dist/test/suites/build/manifest-deps.test.js +116 -0
  123. package/dist/test/suites/build/merge-line-files.test.js +27 -4
  124. package/dist/test/suites/build/notarize-artifacts.test.js +135 -0
  125. package/dist/test/suites/build/notarize-tools.test.js +38 -0
  126. package/dist/test/suites/build/notarize.test.js +207 -0
  127. package/dist/test/suites/build/release-pipeline.test.js +38 -60
  128. package/dist/test/suites/build/release-skipped-upload.test.js +107 -0
  129. package/dist/test/suites/build/resolve-icons.test.js +35 -35
  130. package/dist/test/suites/build/runner-job-guard.test.js +182 -0
  131. package/dist/test/suites/build/runner.test.js +25 -2
  132. package/dist/test/suites/build/sentry.test.js +8 -3
  133. package/dist/test/suites/build/setup-scripts.test.js +3 -0
  134. package/dist/test/suites/build/sign-windows.test.js +92 -8
  135. package/dist/test/suites/build/test-stealth.test.js +17 -11
  136. package/dist/test/suites/build/url-helpers.test.js +37 -17
  137. package/dist/test/suites/build/validate-certs.test.js +428 -55
  138. package/dist/test/suites/build/validate-config.test.js +3 -3
  139. package/dist/test/suites/main/auth-flow.test.js +12 -0
  140. package/dist/test/suites/main/auth-persistence.test.js +14 -17
  141. package/dist/test/suites/main/auto-updater.test.js +2 -2
  142. package/dist/test/suites/main/boot-sequence.test.js +1 -1
  143. package/dist/test/suites/main/client-bridge.integration.test.js +5 -82
  144. package/dist/test/suites/main/client-bridge.test.js +19 -6
  145. package/dist/test/suites/main/deep-link.test.js +54 -0
  146. package/dist/test/suites/main/startup-paths-and-ua.test.js +1 -1
  147. package/dist/test/suites/main/url-helpers.test.js +81 -72
  148. package/dist/test/suites/renderer/cross-context-helpers.test.js +19 -16
  149. package/dist/utils/build-pipeline.js +10 -10
  150. package/dist/utils/github.js +12 -51
  151. package/dist/utils/load-env.js +66 -0
  152. package/dist/utils/mode-helpers.js +43 -111
  153. package/dist/utils/platform.js +37 -0
  154. package/dist/utils/runner-env.js +2 -1
  155. package/dist/utils/runner-job-guard.js +149 -0
  156. package/dist/utils/ship-keys.js +52 -0
  157. package/dist/utils/test-stealth.js +5 -3
  158. package/dist/utils/url-helpers.js +33 -17
  159. package/dist/vendor/config/bundle-id.js +53 -0
  160. package/dist/vendor/config/client-config.js +141 -0
  161. package/dist/vendor/config/company.js +334 -15
  162. package/dist/vendor/config/dev-facts.js +48 -0
  163. package/dist/vendor/config/env-delivery.js +231 -9
  164. package/dist/vendor/config/env-retired.js +137 -0
  165. package/dist/vendor/config/env-rules.js +22 -3
  166. package/dist/vendor/config/env-schema.js +234 -117
  167. package/dist/vendor/config/env.js +55 -26
  168. package/dist/vendor/config/environment.js +189 -0
  169. package/dist/vendor/config/hooks.js +13 -11
  170. package/dist/vendor/config/index.js +121 -44
  171. package/dist/vendor/config/load.js +366 -115
  172. package/dist/vendor/config/merge.js +2 -2
  173. package/dist/vendor/config/order.js +3 -3
  174. package/dist/vendor/config/platforms.js +276 -0
  175. package/dist/vendor/config/repo.js +226 -104
  176. package/dist/vendor/config/retired-keys.js +232 -27
  177. package/dist/vendor/config/schema.js +525 -99
  178. package/dist/vendor/config/site-global.js +63 -53
  179. package/dist/vendor/config/targets.js +187 -0
  180. package/dist/vendor/config/validate.js +119 -59
  181. package/dist/vendor/devkit/argv.js +118 -0
  182. package/dist/vendor/devkit/attach-log-file.js +21 -13
  183. package/dist/vendor/devkit/brand-tokens.js +278 -0
  184. package/dist/vendor/devkit/brand-version.js +264 -0
  185. package/dist/vendor/devkit/build-json.js +91 -0
  186. package/dist/vendor/devkit/bundle.js +48 -0
  187. package/dist/vendor/devkit/certificate-expiry.js +108 -0
  188. package/dist/vendor/devkit/certs.js +16 -194
  189. package/dist/vendor/devkit/ci-workflows.js +124 -8
  190. package/dist/vendor/devkit/cli-router.js +3 -3
  191. package/dist/vendor/devkit/defaults-engine.js +69 -7
  192. package/dist/vendor/devkit/deploy-follow.js +297 -0
  193. package/dist/vendor/devkit/deploy-precheck.js +23 -8
  194. package/dist/vendor/devkit/deploy-record.js +11 -27
  195. package/dist/vendor/devkit/deploy-snapshot.js +661 -0
  196. package/dist/vendor/devkit/deploy.js +445 -75
  197. package/dist/vendor/devkit/git-auth.js +73 -0
  198. package/dist/vendor/devkit/git-remote.js +95 -0
  199. package/dist/vendor/devkit/github-repo.js +290 -0
  200. package/dist/vendor/devkit/local.js +47 -0
  201. package/dist/vendor/devkit/merge-line-files.js +23 -16
  202. package/dist/vendor/devkit/omega-bin.js +18 -3
  203. package/dist/vendor/devkit/pack-local.js +391 -0
  204. package/dist/vendor/devkit/preludes/index.js +120 -0
  205. package/dist/vendor/devkit/preludes/origin-heal.js +156 -0
  206. package/dist/vendor/devkit/service-account.js +43 -0
  207. package/dist/vendor/devkit/ship-plan.js +112 -0
  208. package/dist/vendor/devkit/signing-env.js +180 -0
  209. package/dist/vendor/devkit/signing-tree.js +92 -0
  210. package/dist/vendor/devkit/target-seams.js +142 -0
  211. package/dist/vendor/devkit/target-secrets.js +235 -50
  212. package/dist/vendor/devkit/test/esm-only-fixture.js +48 -0
  213. package/dist/vendor/devkit/test/fixtures/esm-only-package/browser.js +19 -0
  214. package/dist/vendor/devkit/test/fixtures/esm-only-package/index.js +20 -0
  215. package/dist/vendor/devkit/test/fixtures/esm-only-package/package.json +13 -0
  216. package/dist/vendor/monitoring/env.js +20 -10
  217. package/dist/vendor/monitoring/main.js +1 -1
  218. package/dist/vendor/monitoring/preload.js +1 -1
  219. package/dist/vendor/monitoring/renderer.js +1 -1
  220. package/docs/analytics.md +1 -1
  221. package/docs/auto-updater.md +5 -5
  222. package/docs/boot-sequence.md +1 -1
  223. package/docs/build-system.md +15 -7
  224. package/docs/client-bridge.md +9 -7
  225. package/docs/config-schema.md +4 -4
  226. package/docs/css.md +8 -2
  227. package/docs/deep-link.md +12 -4
  228. package/docs/environment-detection.md +32 -24
  229. package/docs/hooks.md +3 -1
  230. package/docs/icons.md +7 -7
  231. package/docs/index.md +61 -23
  232. package/docs/installer-options.md +24 -21
  233. package/docs/logging.md +5 -5
  234. package/docs/releasing.md +25 -17
  235. package/docs/runner.md +40 -5
  236. package/docs/shared/brands.md +12 -6
  237. package/docs/shared/breaking-changes.md +375 -21
  238. package/docs/shared/config.md +757 -199
  239. package/docs/shared/deploys.md +194 -91
  240. package/docs/shared/icons.md +18 -0
  241. package/docs/shared/local-dev.md +24 -6
  242. package/docs/shared/logging.md +9 -6
  243. package/docs/shared/monitoring.md +27 -13
  244. package/docs/shared/publishing.md +3 -3
  245. package/docs/shared/rulings.md +2 -2
  246. package/docs/shared/testing.md +1 -1
  247. package/docs/shared/theming.md +26 -1
  248. package/docs/shared/translation.md +49 -7
  249. package/docs/shared/updates.md +1 -1
  250. package/docs/signing.md +59 -33
  251. package/docs/test-framework.md +10 -5
  252. package/docs/themes.md +15 -1
  253. package/package.json +18 -12
  254. package/bin/omega-desktop +0 -2
  255. package/dist/commands/push-secrets.js +0 -141
  256. package/dist/test/suites/build/deliver-certs.test.js +0 -95
  257. package/dist/test/suites/build/derive-signing-env.test.js +0 -122
  258. package/dist/test/suites/build/push-secrets.test.js +0 -226
  259. package/dist/test/suites/build/resolve-signing-cert.test.js +0 -342
  260. package/dist/utils/deliver-certs.js +0 -69
  261. package/dist/utils/derive-signing-env.js +0 -56
  262. package/dist/utils/resolve-signing-cert.js +0 -175
  263. package/dist/vendor/config/desktop-artifacts.js +0 -110
  264. package/dist/vendor/config/instances.js +0 -208
  265. /package/dist/defaults/config/icons/{macos → mac}/dmg.png +0 -0
  266. /package/dist/defaults/config/icons/{macos → mac}/icon.png +0 -0
  267. /package/dist/defaults/config/icons/{macos → mac}/tray.png +0 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * deploy-snapshot: push a brand folder to its repo as a MIRROR, then wait for
3
+ * GitHub to see the workflow there
4
+ * ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)).
5
+ *
6
+ * A workflow only ever runs from code GitHub already has. A brand that is not
7
+ * its own repo's toplevel (a brand inside this monorepo) and a brand carrying
8
+ * `file:` links (whose packed tarballs must never enter its real history) both
9
+ * hit the same wall: there is nothing to dispatch against. So the deploy pushes
10
+ * what it has, as it is (Ian, 2026-09-10: "we just push the contents of the
11
+ * repository whenever we need to use it"), and dispatches that.
12
+ *
13
+ * The commit is built OUT OF THE WAY: a temporary index, no branch, no stash,
14
+ * no checkout, nothing a `git status` or a `git branch` can see, and the working
15
+ * tree the developer is looking at is never touched. Its ROOT is the BRAND
16
+ * FOLDER, never the enclosing repo's, so the mirror is a brand checkout and not
17
+ * a copy of whatever tree the brand happens to live in, and it is PARENTLESS:
18
+ * a mirror carries no history, which is also what keeps a public brand repo
19
+ * from receiving the private tree it was cut from. That promise is the whole
20
+ * module's ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)): the
21
+ * sync that used to run first, committing and pushing the developer's own
22
+ * branch with `git add -A`, is gone, and nothing here commits anything a
23
+ * developer did not.
24
+ *
25
+ * ONE write ever reaches a repo's DEFAULT branch, and it is `pushWorkflowFiles`
26
+ * below: GitHub registers a workflow from the default branch (the listing and
27
+ * the dispatch both read the file from there, and the `ref` only picks the
28
+ * checkout the run uses), so the composed `.github/workflows/*.yml` have to BE
29
+ * there for the deploy branch to be buildable at all. It compares bytes first
30
+ * and pushes only what is missing or changed, through the git data API, so no
31
+ * working tree, index or local branch is involved in that either.
32
+ *
33
+ * What rides is what the brand's own ignore rules allow: tracked files plus
34
+ * untracked-not-ignored ones, which is exactly how the staged `omega_modules/`
35
+ * tarballs and the regenerated lockfile reach the runner.
36
+ *
37
+ * The COMPANY layer rides the same way, resolved here and generated as one file
38
+ * ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)): the machine that
39
+ * dispatches has the machine registry and the company tree, and the runner has
40
+ * neither, so what the runner gets is the ANSWER. No runner ever reads
41
+ * `company/`. The env half needs nothing: the deploy precheck publishes the COMPOSED
42
+ * target env (company then brand then target), so the company's `.env` values
43
+ * are already repo secrets by the time a workflow runs.
44
+ *
45
+ * The token travels in the environment (`http.extraheader` through
46
+ * GIT_CONFIG_*), never in argv, where every `ps` on the machine would read it.
47
+ * That delivery, and the scrub a failure is rethrown through, are
48
+ * `@omega.js/devkit/git-auth`'s: the web deploy's gh-pages push is the other
49
+ * lane pushing to a repo its checkout is not authenticated for, and one
50
+ * spelling of the rule is what keeps a token out of BOTH
51
+ * ([#883](https://github.com/Omega-JS-Stack/omega/issues/883)).
52
+ */
53
+
54
+ // Libraries
55
+ const os = require('node:os');
56
+ const path = require('node:path');
57
+ const { execFileSync } = require('node:child_process');
58
+ const { setTimeout: delay } = require('node:timers/promises');
59
+ const jetpack = require('fs-jetpack');
60
+ const { resolveCompany, COMPANY_RESOLVED_FILE } = require('../config/index.js');
61
+ const { gitAuthEnv, scrubToken } = require('./git-auth.js');
62
+
63
+ // Constants
64
+ const API_BASE = 'https://api.github.com';
65
+ const SNAPSHOT_MESSAGE = 'chore(deploy): snapshot the brand for this run';
66
+ // Where GitHub reads a workflow from, in the repo and on disk alike.
67
+ const WORKFLOWS_DIR = '.github/workflows';
68
+ // GitHub indexes a freshly pushed workflow a few seconds after the push, and a
69
+ // dispatch before that 404s: about a minute of polling, then a loud failure.
70
+ const POLL_ATTEMPTS = 20;
71
+ const POLL_INTERVAL_MS = 3000;
72
+
73
+ /** Run a command and capture its stdout (a file and an argument array, never a shell string). */
74
+ function execCommand(file, args, options = {}) {
75
+ return execFileSync(file, args, { encoding: 'utf8', ...options });
76
+ }
77
+
78
+ /**
79
+ * A sha as every GitHub surface shows it: the first seven characters. Spelled
80
+ * ONCE here, in the leaf every deploy module already imports, so a snapshot sha
81
+ * reads the same in a dispatch line, a follow line and a brand root's log.
82
+ *
83
+ * @param {string} sha - The full commit sha.
84
+ * @returns {string} its short form
85
+ */
86
+ function shortSha(sha) {
87
+ return String(sha).slice(0, 7);
88
+ }
89
+
90
+ /**
91
+ * Push the brand folder to `<owner>/<repo>` at `ref` as a force-pushed mirror.
92
+ *
93
+ * @param {object} options - Options.
94
+ * @param {string} options.brandRoot - The brand folder to snapshot.
95
+ * @param {string} options.owner - The mirror repo's owner.
96
+ * @param {string} options.repo - The mirror repo's bare name.
97
+ * @param {string} options.ref - The branch to overwrite (`main` for a mirror).
98
+ * @param {string} options.token - The GitHub token, carried in the env.
99
+ * @param {string} [options.message] - The snapshot commit's message.
100
+ * @param {string[]} [options.require] - Brand-relative paths that MUST ride
101
+ * (the staged `omega_modules/` and lockfile of a linked brand).
102
+ * @param {Function} [options.execFn] - The command runner (tests).
103
+ * @param {string} [options.indexFile] - The temporary index to build in.
104
+ * @returns {string} The pushed commit sha.
105
+ */
106
+ function pushSnapshot(options) {
107
+ const {
108
+ brandRoot,
109
+ owner,
110
+ repo,
111
+ ref,
112
+ token,
113
+ message = SNAPSHOT_MESSAGE,
114
+ execFn = execCommand,
115
+ indexFile = path.join(os.tmpdir(), `omega-snapshot-index-${process.pid}`),
116
+ } = options;
117
+
118
+ for (const key of ['brandRoot', 'owner', 'repo', 'ref', 'token']) {
119
+ if (!options[key]) {
120
+ throw new Error(`pushSnapshot: missing ${key}`);
121
+ }
122
+ }
123
+
124
+ const git = (...args) => execFn('git', args, { cwd: brandRoot, env: { ...process.env, GIT_INDEX_FILE: indexFile } });
125
+
126
+ // Resolved HERE, received THERE (#677): generated before the tree is staged,
127
+ // removed the moment the push is done.
128
+ const companyLayer = writeCompanyLayer(brandRoot);
129
+
130
+ try {
131
+ // What rides is what the brand's ignore rules allow, so a rule that covers
132
+ // the staged files is a snapshot the runner cannot install: the push would
133
+ // succeed and `npm ci` would die minutes later, a machine away, on a file:
134
+ // spec pointing at nothing. Checked BEFORE the push, so the deploy stops
135
+ // here with the fix in its hand.
136
+ assertNotIgnored({ git, paths: [...(options.require || []), ...(companyLayer ? [COMPANY_RESOLVED_FILE] : [])] });
137
+
138
+ const toplevel = git('rev-parse', '--show-toplevel').trim();
139
+ // Index paths are always repo-root relative, so the prefix is what turns
140
+ // the enclosing repo's tree into the brand's own.
141
+ const prefix = path.relative(toplevel, path.resolve(brandRoot)).split(path.sep).join('/');
142
+
143
+ // Seed the temporary index from HEAD, then stage the brand folder into it:
144
+ // `git add` writes only the index GIT_INDEX_FILE names, and seeding is what
145
+ // keeps a tracked-but-ignored file (there ON PURPOSE) in the snapshot.
146
+ // A brand whose repo has no commit yet has nothing to seed from.
147
+ if (hasCommit(git)) {
148
+ git('read-tree', 'HEAD');
149
+ }
150
+ git('add', '-A', '--', '.');
151
+
152
+ const tree = git('write-tree').trim();
153
+ const brandTree = prefix ? subtreeOf(git, tree, prefix) : tree;
154
+ // Parentless: a mirror is a snapshot, not a history, and the tree it was
155
+ // cut from is nobody's business on the brand's own repo.
156
+ const sha = git('commit-tree', brandTree, '-m', message).trim();
157
+
158
+ // Pushed BY SHA, so no local branch is ever created here. Force: two
159
+ // snapshots are siblings, never a fast-forward.
160
+ try {
161
+ execFn('git', ['push', '--force', `https://github.com/${owner}/${repo}.git`, `${sha}:refs/heads/${ref}`], {
162
+ cwd: brandRoot,
163
+ env: {
164
+ ...process.env,
165
+ GIT_INDEX_FILE: indexFile,
166
+ ...gitAuthEnv(token),
167
+ },
168
+ });
169
+ } catch (error) {
170
+ // git's own message quotes what it was given, and a caller's message is
171
+ // never trusted with the credential this lane was careful not to pass.
172
+ const failure = new Error(`Pushing the snapshot to ${owner}/${repo} failed: ${scrubToken(error && error.message, token)}`);
173
+ failure.status = error && error.status;
174
+ throw failure;
175
+ }
176
+
177
+ return sha;
178
+ } finally {
179
+ if (companyLayer) jetpack.remove(companyLayer);
180
+ jetpack.remove(indexFile);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Generate the resolved company layer beside the brand config, for a brand that
186
+ * HAS a company somewhere else ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)).
187
+ *
188
+ * A brand with no company, and the company brand itself (`company: { id: 'self' }`,
189
+ * whose `company/` folder is part of its own repo), both resolve off-laptop with
190
+ * no help and get no file.
191
+ *
192
+ * @param {string} brandRoot - The brand folder being snapshotted.
193
+ * @returns {string|null} The generated file, or null when there was nothing to carry.
194
+ */
195
+ function writeCompanyLayer(brandRoot) {
196
+ const { id, name, url, images, webhooks, config } = resolveCompany(brandRoot);
197
+ if (!id || id === 'self') return null;
198
+
199
+ const file = path.join(brandRoot, COMPANY_RESOLVED_FILE);
200
+ const layer = { config: config || {}, company: { id, name, url, images, webhooks } };
201
+
202
+ jetpack.write(file, `// GENERATED by omega deploy (#677): the company layer this run resolved.\n// Never edit, never commit: the snapshot carries it and then removes it.\n${JSON.stringify(layer, null, 2)}\n`);
203
+
204
+ return file;
205
+ }
206
+
207
+ /**
208
+ * Refuse a snapshot whose ignore rules would drop the files it exists to carry
209
+ * ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)).
210
+ *
211
+ * A brand that ignores build output by name (`omega_modules/`, a committed-by-
212
+ * nobody `package-lock.json`) catches the staged packages with it, and the
213
+ * snapshot then carries `file:` specs pointing at tarballs that never travelled.
214
+ *
215
+ * @param {object} options - Options.
216
+ * @param {Function} options.git - The bound git runner (cwd = the brand root).
217
+ * @param {string[]} options.paths - Brand-relative paths that must ride.
218
+ * @throws {Error} Naming every ignored path and the rule to drop.
219
+ */
220
+ function assertNotIgnored({ git, paths }) {
221
+ const ignored = paths.filter((entry) => isIgnored(git, entry));
222
+
223
+ if (ignored.length) {
224
+ throw new Error(`This brand's git ignore rules hide ${ignored.join(' and ')}, which the deploy just staged and the snapshot has to carry. A pushed snapshot without them installs nothing on the runner (every linked package is a file: spec pointing at a tarball that never travelled). Drop the rule from the .gitignore that matches (\`git check-ignore -v ${ignored[0]}\` names the file and line), or un-ignore it with a \`!${ignored[0]}\` line, then re-run the deploy.`);
225
+ }
226
+ }
227
+
228
+ /** Is one path ignored here? `git check-ignore` answers by exit code (1 = no). */
229
+ function isIgnored(git, entry) {
230
+ try {
231
+ git('check-ignore', '-q', '--', entry);
232
+ return true;
233
+ } catch (error) {
234
+ if (error.status === 1) {
235
+ return false;
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ /** Does this repo have a commit to seed the index from? */
242
+ function hasCommit(git) {
243
+ try {
244
+ git('rev-parse', '--verify', 'HEAD');
245
+ return true;
246
+ } catch (e) {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The tree object of one directory inside a tree: the brand folder, promoted to
253
+ * the root of what gets committed.
254
+ * @param {Function} git - The bound git runner.
255
+ * @param {string} tree - The enclosing tree's id.
256
+ * @param {string} prefix - The brand folder, repo-root relative.
257
+ * @returns {string} The subtree's id.
258
+ */
259
+ function subtreeOf(git, tree, prefix) {
260
+ // `--full-tree`: a path is read from the REPO ROOT, and this runs inside the
261
+ // brand folder, where the prefix names nothing.
262
+ const entry = git('ls-tree', '--full-tree', tree, '--', prefix).trim();
263
+ const match = entry.match(/^\d+ tree ([0-9a-f]+)\t/);
264
+
265
+ if (!match) {
266
+ throw new Error(`Could not read the brand folder ${prefix} out of the snapshot tree (got: ${entry || 'nothing'})`);
267
+ }
268
+
269
+ return match[1];
270
+ }
271
+
272
+ /** The headers every GitHub REST call here carries. */
273
+ function ghHeaders(token) {
274
+ return {
275
+ Accept: 'application/vnd.github+json',
276
+ Authorization: `Bearer ${token}`,
277
+ 'X-GitHub-Api-Version': '2022-11-28',
278
+ 'User-Agent': 'omega-deploy',
279
+ };
280
+ }
281
+
282
+ /**
283
+ * The composed workflow files a brand carries, the ones GitHub has to have on
284
+ * its DEFAULT branch before any dispatch of them resolves
285
+ * ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)).
286
+ *
287
+ * Read from disk, never from git: every verb's scaffold composes these into
288
+ * `<brandRoot>/.github/workflows/` before the delivery runs, so what is on disk
289
+ * IS this run's CI.
290
+ *
291
+ * @param {string} brandRoot - The brand folder being deployed.
292
+ * @returns {Array<{ name: string, path: string, content: string }>} One entry
293
+ * per composed workflow, its `path` repo-root relative.
294
+ */
295
+ function composedWorkflowFiles(brandRoot) {
296
+ const dir = path.join(brandRoot, ...WORKFLOWS_DIR.split('/'));
297
+
298
+ return (jetpack.list(dir) || [])
299
+ .filter((name) => name.endsWith('.yml'))
300
+ .sort()
301
+ .map((name) => ({
302
+ name,
303
+ path: `${WORKFLOWS_DIR}/${name}`,
304
+ content: jetpack.read(path.join(dir, name)),
305
+ }))
306
+ .filter((file) => typeof file.content === 'string');
307
+ }
308
+
309
+ /**
310
+ * A repo's DEFAULT branch: the ONE read of it, shared by every step that needs
311
+ * the name ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)).
312
+ *
313
+ * @param {object} options - Options.
314
+ * @param {string} options.owner - The repo's owner.
315
+ * @param {string} options.repo - The repo's bare name.
316
+ * @param {string} options.token - The GitHub token.
317
+ * @param {Function} [options.fetchFn] - Injectable fetch (tests).
318
+ * @returns {Promise<string|null>} The branch name, and null for a repo GitHub
319
+ * does not have yet (the snapshot push that follows says so loudly enough).
320
+ * @throws {Error} On any answer but 200 or 404: a reachable repo that will not
321
+ * report its default branch is not something to guess at.
322
+ */
323
+ async function defaultBranchOf({ owner, repo, token, fetchFn = fetch }) {
324
+ const response = await fetchFn(`${API_BASE}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
325
+
326
+ if (response.status === 404) {
327
+ return null;
328
+ }
329
+ if (response.status !== 200) {
330
+ throw new Error(`Could not read ${owner}/${repo}'s default branch (${response.status}): the deploy needs it to know where the composed workflows go.`);
331
+ }
332
+
333
+ return (await response.json()).default_branch || null;
334
+ }
335
+
336
+ /**
337
+ * Push the composed workflow files to the repo's DEFAULT branch, and ONLY them
338
+ * ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)).
339
+ *
340
+ * GitHub registers a workflow from the default branch, so a deploy branch
341
+ * nobody can dispatch is a deploy that cannot run. That is the whole reason
342
+ * this write exists, and it is the only write a deploy ever makes to a default
343
+ * branch (Ian, 2026-09-14: "if all we are pushing to main is the gh workflow
344
+ * files that's fine"). Each file's bytes are compared with what the branch
345
+ * holds; when every one matches, nothing is written at all.
346
+ *
347
+ * Through the git DATA api, never a working-tree commit: blobs, a tree on top
348
+ * of the branch's own, a commit whose parent is its head. An EMPTY repo (no
349
+ * head yet) gets a parentless commit and a created ref.
350
+ *
351
+ * @param {object} options - Options.
352
+ * @param {string} options.brandRoot - The brand folder whose workflows these are.
353
+ * @param {string} options.owner - The repo's owner.
354
+ * @param {string} options.repo - The repo's bare name.
355
+ * @param {string} options.branch - The repo's default branch.
356
+ * @param {string} options.token - The GitHub token.
357
+ * @param {Function} [options.fetchFn] - Injectable fetch (tests).
358
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
359
+ * @returns {Promise<{ pushed: string[], sha: string|null }>} What was written.
360
+ */
361
+ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchFn = fetch, logger }) {
362
+ const files = composedWorkflowFiles(brandRoot);
363
+
364
+ if (files.length === 0) {
365
+ if (logger) logger.log(`No composed workflow in ${brandRoot}/${WORKFLOWS_DIR}: ${branch} is untouched.`);
366
+ return { pushed: [], sha: null };
367
+ }
368
+
369
+ const call = async (route, init) => {
370
+ const response = await fetchFn(`${API_BASE}/repos/${owner}/${repo}${route}`, { headers: ghHeaders(token), ...init });
371
+ return response;
372
+ };
373
+ const read = async (response, expected, what) => {
374
+ if (!expected.includes(response.status)) {
375
+ const body = await response.text().catch(() => '');
376
+ throw new Error(`${what} on ${owner}/${repo} failed (${response.status}): ${body || response.statusText || 'unknown error'}`);
377
+ }
378
+ // Every ACCEPTED non-2xx reads as "not there": a 404 for a file the branch
379
+ // does not hold, a 409 for a repo with no commits at all.
380
+ return response.status < 300 ? response.json() : null;
381
+ };
382
+
383
+ // What the branch is missing or holds differently. The contents API answers
384
+ // per file, which is also how a 404 tells the two apart from a whole branch
385
+ // that is not there yet.
386
+ const changed = [];
387
+
388
+ for (const file of files) {
389
+ const current = await read(
390
+ await call(`/contents/${file.path}?ref=${encodeURIComponent(branch)}`),
391
+ [200, 404],
392
+ `Reading ${file.name}`,
393
+ );
394
+
395
+ if (!current) {
396
+ changed.push({ ...file, reason: 'missing' });
397
+ } else if (Buffer.from(current.content || '', 'base64').toString('utf8') !== file.content) {
398
+ changed.push({ ...file, reason: 'changed' });
399
+ }
400
+ }
401
+
402
+ if (changed.length === 0) {
403
+ if (logger) logger.log(`${owner}/${repo}#${branch} already carries ${files.map((file) => file.name).join(', ')}: nothing pushed to ${branch}.`);
404
+ return { pushed: [], sha: null };
405
+ }
406
+
407
+ // The head this commit sits on. A repo with no commit at all answers 404, and
408
+ // a freshly created one answers 409 `Git Repository is empty.`; both mean no
409
+ // head, so the first commit is parentless with no base tree to build on.
410
+ const head = await read(await call(`/git/ref/heads/${encodeURIComponent(branch)}`), [200, 404, 409], `Reading ${branch}`);
411
+ const parents = head ? [head.object.sha] : [];
412
+ const baseTree = head
413
+ ? (await read(await call(`/git/commits/${head.object.sha}`), [200], 'Reading the head commit')).tree.sha
414
+ : null;
415
+
416
+ const tree = [];
417
+ for (const file of changed) {
418
+ const blob = await read(
419
+ await call('/git/blobs', { method: 'POST', body: JSON.stringify({ content: Buffer.from(file.content).toString('base64'), encoding: 'base64' }) }),
420
+ [201],
421
+ `Writing ${file.name}`,
422
+ );
423
+ tree.push({ path: file.path, mode: '100644', type: 'blob', sha: blob.sha });
424
+ }
425
+
426
+ const names = changed.map((file) => file.name).join(', ');
427
+ const written = await read(
428
+ await call('/git/trees', { method: 'POST', body: JSON.stringify({ ...(baseTree ? { base_tree: baseTree } : {}), tree }) }),
429
+ [201],
430
+ 'Writing the tree',
431
+ );
432
+ const commit = await read(
433
+ await call('/git/commits', { method: 'POST', body: JSON.stringify({ message: `chore(ci): compose ${names}`, tree: written.sha, parents }) }),
434
+ [201],
435
+ 'Writing the commit',
436
+ );
437
+
438
+ await read(
439
+ head
440
+ ? await call(`/git/refs/heads/${encodeURIComponent(branch)}`, { method: 'PATCH', body: JSON.stringify({ sha: commit.sha }) })
441
+ : await call('/git/refs', { method: 'POST', body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: commit.sha }) }),
442
+ head ? [200] : [201],
443
+ `Updating ${branch}`,
444
+ );
445
+
446
+ if (logger) {
447
+ logger.log(`Pushed ${changed.map((file) => `${file.name} (${file.reason})`).join(', ')} to ${owner}/${repo}#${branch} as ${shortSha(commit.sha)}: GitHub registers a workflow from the default branch.`);
448
+ }
449
+
450
+ return { pushed: changed.map((file) => file.name), sha: commit.sha };
451
+ }
452
+
453
+ /**
454
+ * Point a repo's DEFAULT branch back at `main` when published output has taken
455
+ * it over ([#872](https://github.com/Omega-JS-Stack/omega/issues/872),
456
+ * [#922](https://github.com/Omega-JS-Stack/omega/issues/922)).
457
+ *
458
+ * GitHub registers a workflow from the DEFAULT branch: the listing the wait
459
+ * polls and the dispatch that follows both read the file from there, and the
460
+ * `ref` only picks which checkout the run uses. A brand whose first web deploy
461
+ * created `gh-pages` on an empty repo has GitHub pointing at the published
462
+ * site, where `pushWorkflowFiles` would compose the workflows only for the next
463
+ * web deploy's force-push to wipe them. Built output is never the default
464
+ * branch (the same rule the manager's repo ensure applies), and a deploy is
465
+ * often what CREATES that state, so the fix belongs here too.
466
+ *
467
+ * It runs from the LANE's first step, where the default branch is read, before
468
+ * a single workflow file is written, and on the name that read returned: never
469
+ * a second read (the one-read rule from
470
+ * [#915](https://github.com/Omega-JS-Stack/omega/issues/915)). Once per repo,
471
+ * because after the flip the default IS `main`, and a dry run never reaches it.
472
+ *
473
+ * @param {object} options - Options.
474
+ * @param {string} options.owner - The repo's owner.
475
+ * @param {string} options.repo - The repo's bare name.
476
+ * @param {string|null} options.current - The default branch already read.
477
+ * @param {string} options.token - The GitHub token.
478
+ * @param {Function} [options.fetchFn] - Injectable fetch (tests).
479
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
480
+ * @returns {Promise<string|null>} The branch the rest of the lane uses:
481
+ * `current` untouched, and `main` once a gh-pages default has been healed.
482
+ * @throws {Error} When the heal fails: composing onto gh-pages is the broken
483
+ * outcome it exists to prevent, so the deploy never continues past it.
484
+ */
485
+ async function healDefaultBranch({ owner, repo, current, token, fetchFn = fetch, logger }) {
486
+ if (current !== 'gh-pages') {
487
+ return current;
488
+ }
489
+
490
+ const url = `${API_BASE}/repos/${owner}/${repo}`;
491
+ const headers = ghHeaders(token);
492
+ const byHand = 'Set the default branch by hand and re-run the deploy.';
493
+
494
+ // The branch the default moves TO may not exist at all: a repo whose first
495
+ // web deploy created gh-pages on an empty repo carries that one branch, so
496
+ // `main` is created from the head it holds.
497
+ const existing = await fetchFn(`${url}/git/ref/heads/main`, { headers });
498
+
499
+ if (existing.status === 404) {
500
+ const source = await fetchFn(`${url}/git/ref/heads/gh-pages`, { headers });
501
+
502
+ if (source.status !== 200) {
503
+ throw new Error(`${owner}/${repo} has no main branch and reading gh-pages to create one from failed (${source.status}). ${byHand}`);
504
+ }
505
+
506
+ const created = await fetchFn(`${url}/git/refs`, {
507
+ method: 'POST',
508
+ headers,
509
+ body: JSON.stringify({ ref: 'refs/heads/main', sha: (await source.json()).object.sha }),
510
+ });
511
+
512
+ if (created.status !== 201) {
513
+ throw new Error(`${owner}/${repo} has no main branch and creating one from gh-pages failed (${created.status}). ${byHand}`);
514
+ }
515
+ } else if (existing.status !== 200) {
516
+ throw new Error(`Could not read ${owner}/${repo}'s main branch (${existing.status}), so the deploy cannot tell whether the default branch can move there. ${byHand}`);
517
+ }
518
+
519
+ const patch = await fetchFn(url, { method: 'PATCH', headers, body: JSON.stringify({ default_branch: 'main' }) });
520
+
521
+ if (patch.status !== 200) {
522
+ throw new Error(`${owner}/${repo}'s default branch is gh-pages, where no workflow lives, and moving it back to main failed (${patch.status}). ${byHand}`);
523
+ }
524
+
525
+ if (logger) {
526
+ logger.log(`${owner}/${repo}'s default branch was gh-pages (published output), and GitHub registers workflows from the default branch: moved it back to main.`);
527
+ }
528
+
529
+ return 'main';
530
+ }
531
+
532
+ /**
533
+ * Wait for GitHub to REGISTER a workflow on the repo the snapshot just reached.
534
+ *
535
+ * GitHub takes a few seconds to index a workflow it has just received, and a
536
+ * dispatch before that answers `404: workflow not found`. So the push is
537
+ * followed by a bounded wait rather than by a hand-run second attempt.
538
+ *
539
+ * @param {object} options - Options.
540
+ * @param {string} options.owner - The repo's owner.
541
+ * @param {string} options.repo - The repo's bare name.
542
+ * @param {string} options.workflow - The workflow file name.
543
+ * @param {string} options.ref - The ref the snapshot went to (for the failure).
544
+ * @param {string} options.token - The GitHub token.
545
+ * @param {Function} [options.fetchFn] - Injectable fetch (tests).
546
+ * @param {number} [options.attempts] - The poll budget.
547
+ * @param {number} [options.delayMs] - The wait between polls.
548
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
549
+ * @returns {Promise<void>} Resolves once GitHub answers for the workflow.
550
+ */
551
+ async function waitForWorkflow(options) {
552
+ const {
553
+ owner, repo, workflow, ref, token, logger,
554
+ fetchFn = fetch,
555
+ attempts = POLL_ATTEMPTS,
556
+ delayMs = POLL_INTERVAL_MS,
557
+ } = options;
558
+
559
+ const url = `${API_BASE}/repos/${owner}/${repo}/actions/workflows/${workflow}`;
560
+
561
+ for (let attempt = 1; attempt <= attempts; attempt++) {
562
+ const response = await fetchFn(url, { headers: ghHeaders(token) });
563
+
564
+ if (response.status === 200) {
565
+ return;
566
+ }
567
+
568
+ if (logger) {
569
+ logger.log(`[${attempt}/${attempts}] ${workflow} is not registered on ${owner}/${repo} yet...`);
570
+ }
571
+
572
+ // The last attempt has nothing left to wait for.
573
+ if (attempt < attempts) {
574
+ await delay(delayMs);
575
+ }
576
+ }
577
+
578
+ throw new Error(`${workflow} is still not registered on ${owner}/${repo} (pushed to ${ref}) ${Math.round((attempts * delayMs) / 1000)}s after the push. GitHub indexes a pushed workflow within seconds, so this is a push that did not carry the file, or a repo whose default branch is not where the workflow landed. Check https://github.com/${owner}/${repo}/actions`);
579
+ }
580
+
581
+ /**
582
+ * Wait for a ref to RESOLVE to the sha this run just pushed
583
+ * ([#902](https://github.com/Omega-JS-Stack/omega/issues/902)).
584
+ *
585
+ * A force-push returns as soon as GitHub accepts it, but GitHub resolves the
586
+ * branch on its own side: a dispatch sent in the same second can still resolve
587
+ * `main` to the PREVIOUS commit, and the run then builds a tree this deploy
588
+ * never pushed, green, with nothing in its log saying so. So the push is
589
+ * followed by a bounded wait on the ref itself, exactly as the workflow
590
+ * listing is waited for above.
591
+ *
592
+ * @param {object} options - Options.
593
+ * @param {string} options.owner - The repo's owner.
594
+ * @param {string} options.repo - The repo's bare name.
595
+ * @param {string} options.ref - The branch the snapshot went to.
596
+ * @param {string} options.sha - The sha the push returned.
597
+ * @param {string} options.token - The GitHub token.
598
+ * @param {Function} [options.fetchFn] - Injectable fetch (tests).
599
+ * @param {number} [options.attempts] - The poll budget.
600
+ * @param {number} [options.delayMs] - The wait between polls.
601
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
602
+ * @returns {Promise<void>} Resolves once the ref carries the pushed sha.
603
+ * @throws {Error} When the budget runs out on another sha.
604
+ */
605
+ async function waitForRef(options) {
606
+ const {
607
+ owner, repo, ref, sha, token, logger,
608
+ fetchFn = fetch,
609
+ attempts = POLL_ATTEMPTS,
610
+ delayMs = POLL_INTERVAL_MS,
611
+ } = options;
612
+
613
+ const url = `${API_BASE}/repos/${owner}/${repo}/git/ref/heads/${ref}`;
614
+ let last = null;
615
+
616
+ for (let attempt = 1; attempt <= attempts; attempt++) {
617
+ const response = await fetchFn(url, { headers: ghHeaders(token) });
618
+
619
+ // A ref GitHub has not created yet (a first push) answers 404, which is a
620
+ // miss like any other resolution that is not this run's.
621
+ if (response.status === 200) {
622
+ const seen = ((await response.json()).object || {}).sha;
623
+
624
+ if (seen === sha) {
625
+ return;
626
+ }
627
+
628
+ last = seen ? `sha ${seen}` : 'no sha at all';
629
+
630
+ if (logger) {
631
+ logger.log(`[${attempt}/${attempts}] ${owner}/${repo}#${ref} still resolves to ${shortSha(seen)}, waiting for ${shortSha(sha)}...`);
632
+ }
633
+ } else {
634
+ last = `status ${response.status}`;
635
+
636
+ if (logger) {
637
+ logger.log(`[${attempt}/${attempts}] ${owner}/${repo}#${ref} answered ${response.status}, waiting for ${shortSha(sha)}...`);
638
+ }
639
+ }
640
+
641
+ // The last attempt has nothing left to wait for.
642
+ if (attempt < attempts) {
643
+ await delay(delayMs);
644
+ }
645
+ }
646
+
647
+ throw new Error(`${owner}/${repo}#${ref} still does not carry the snapshot this deploy pushed (${sha}) ${Math.round((attempts * delayMs) / 1000)}s after the push: GitHub last reported ${last}. Dispatching now would build a tree this deploy did not push. Check https://github.com/${owner}/${repo}/commits/${ref}`);
648
+ }
649
+
650
+ module.exports = {
651
+ pushSnapshot,
652
+ pushWorkflowFiles,
653
+ composedWorkflowFiles,
654
+ defaultBranchOf,
655
+ healDefaultBranch,
656
+ waitForWorkflow,
657
+ waitForRef,
658
+ ghHeaders,
659
+ shortSha,
660
+ SNAPSHOT_MESSAGE,
661
+ };