@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
@@ -13,47 +13,92 @@ const path = require('node:path');
13
13
  const { execSync, execFileSync } = require('node:child_process');
14
14
 
15
15
  const { findBrandRoot, discoverTargets, frameworkPackagesOf } = require('./local.js');
16
+ const { stageLocalPackages, STAGING_DIR } = require('./pack-local.js');
17
+ const {
18
+ pushSnapshot,
19
+ pushWorkflowFiles,
20
+ composedWorkflowFiles,
21
+ defaultBranchOf,
22
+ healDefaultBranch,
23
+ waitForWorkflow,
24
+ waitForRef,
25
+ ghHeaders,
26
+ shortSha,
27
+ } = require('./deploy-snapshot.js');
28
+ // The remote-url parse this module published before the boot prelude needed it
29
+ // too (#890): it lives in the remote boundary now, re-exported here unchanged.
30
+ const { parseRemoteUrl } = require('./git-remote.js');
16
31
 
17
32
  const API_BASE = 'https://api.github.com';
18
33
 
19
- /**
20
- * Parse a git remote URL into { owner, repo }.
21
- * Handles ssh (git@github.com:o/r.git), https (https://github.com/o/r.git),
22
- * and .git-less forms.
23
- * @param {string} url - remote URL
24
- * @returns {{ owner: string, repo: string }|null}
25
- */
26
- function parseRemoteUrl(url) {
27
- const match = (url || '').trim().match(/github\.com[/:]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
28
- if (!match) return null;
29
- return { owner: match[1], repo: match[2] };
30
- }
34
+ // The ONE branch a deploy ever builds from
35
+ // ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)): every brand's
36
+ // snapshot lands here, nested or not, linked or not, and CI only ever runs what
37
+ // this branch holds. A brand's real history never carries packed tarballs, and
38
+ // whoever starts a deploy (a laptop, the admin publish) puts the files it needs
39
+ // on the same branch. Exported, so the backend's admin publish never types it.
40
+ const SNAPSHOT_REF = 'omega-deploy';
31
41
 
32
42
  /**
33
- * The repo a CI dispatch addresses: the BRAND's own repo, from its config
34
- * ([#799](https://github.com/Omega-JS-Stack/omega/issues/799)). Every framework's
43
+ * The repo a CI dispatch addresses: the brand's SOURCE monorepo, from its
44
+ * config ([#799](https://github.com/Omega-JS-Stack/omega/issues/799),
45
+ * [#883](https://github.com/Omega-JS-Stack/omega/issues/883)). Every framework's
35
46
  * deploy verb asks this instead of `resolveRepo` above, because a git remote
36
47
  * answers the repo the working tree sits in: inside a brand monorepo nested in
37
48
  * another repo (a brand inside the framework monorepo, a target checked out
38
49
  * under someone else's tree) that is the ENCLOSING repo, so the dispatch went to
39
- * a workflow that was never there.
50
+ * a workflow that was never there. The workflows live on the SOURCE repo in
51
+ * every case, whatever a target publishes to.
40
52
  *
41
53
  * Half an address addresses nothing, so it throws rather than POST to
42
54
  * `undefined/<name>`.
43
55
  *
44
56
  * @param {object} config - Composed omega config for the target being deployed.
45
- * @returns {{ owner: string, repo: string }} the brand repo's owner and bare name
57
+ * @returns {{ owner: string, repo: string }} the source repo's owner and bare name
46
58
  * @throws {Error} when the config names no repo
47
59
  */
48
60
  function dispatchRepo(config) {
49
- const { brandRepo } = require('../config/index.js');
50
- const { owner, name } = brandRepo(config);
61
+ const { sourceRepo } = require('../config/index.js');
62
+ const source = sourceRepo(config);
51
63
 
52
- if (!owner || !name) {
53
- throw new Error('Could not determine the brand repo to dispatch on. Set repo.providers.github.org (and repo.providers.github.repo when the repo name is not <brand.id>-omega) in config/omega.json5.');
64
+ if (!source) {
65
+ throw new Error('Could not determine the brand repo to dispatch on. Set repo.org (and brand.id) in config/omega.json5: the source repo is <brand.id>-omega under that org.');
54
66
  }
55
67
 
56
- return { owner, repo: name };
68
+ return { owner: source.owner, repo: source.name };
69
+ }
70
+
71
+ /**
72
+ * The whole CI dispatch ADDRESS for a target: the repo above, plus the workflow
73
+ * file the target's scaffold actually wrote
74
+ * ([#847](https://github.com/Omega-JS-Stack/omega/issues/847)). Inside a brand
75
+ * monorepo the target's CI lives in the BRAND ROOT's workflows dir under a
76
+ * per-target name (`desktop-build.yml`, #265), and standalone it keeps the
77
+ * framework's own name, so every deploy verb composed `dispatchRepo` with
78
+ * `composedWorkflowName` by hand and desktop kept a local helper its siblings
79
+ * lacked. One helper now, called by all four.
80
+ *
81
+ * @param {object} options
82
+ * @param {string} options.projectRoot - The target dir being deployed.
83
+ * @param {object} options.config - Composed omega config for that target.
84
+ * @param {string} options.workflow - The framework's workflow file name (e.g. publish.yml).
85
+ * @returns {{ owner: string, repo: string, workflow: string }} the repo and the workflow to dispatch
86
+ * @throws {Error} when the config names no repo
87
+ */
88
+ function dispatchTarget({ projectRoot, config, workflow }) {
89
+ const { composedWorkflowName } = require('./ci-workflows.js');
90
+ const { resolveSeedMode } = require('../config/index.js');
91
+ const { owner, repo } = dispatchRepo(config);
92
+
93
+ return {
94
+ owner,
95
+ repo,
96
+ workflow: composedWorkflowName({
97
+ targetDir: projectRoot,
98
+ brandRoot: resolveSeedMode(projectRoot).brandRoot,
99
+ workflow,
100
+ }),
101
+ };
57
102
  }
58
103
 
59
104
  /**
@@ -139,12 +184,7 @@ async function dispatchWorkflow(plan, options = {}) {
139
184
  const fetchFn = options.fetchFn || fetch;
140
185
  const response = await fetchFn(plan.url, {
141
186
  method: plan.method,
142
- headers: {
143
- Accept: 'application/vnd.github+json',
144
- Authorization: `Bearer ${options.token}`,
145
- 'X-GitHub-Api-Version': '2022-11-28',
146
- 'User-Agent': 'omega-deploy',
147
- },
187
+ headers: ghHeaders(options.token),
148
188
  body: JSON.stringify(plan.body),
149
189
  });
150
190
 
@@ -156,51 +196,316 @@ async function dispatchWorkflow(plan, options = {}) {
156
196
  }
157
197
 
158
198
  /**
159
- * The one deploy path: resolve repo + token, build the plan, dispatch — or
160
- * return the plan untouched when dryRun is set.
199
+ * The LANE half of the two lines a dispatch prints, one spelling for all four
200
+ * verbs: `<mode> lane, ref <ref>`, plus the snapshot sha when the brand root
201
+ * pushed one for this run ([#901](https://github.com/Omega-JS-Stack/omega/issues/901)),
202
+ * so a fan-out's log shows every target dispatching the SAME snapshot.
203
+ *
204
+ * @param {{ mode: string, ref: string }} lane - The resolved lane.
205
+ * @param {string} [snapshot] - The sha the run's snapshot sits at.
206
+ * @returns {string} the parenthesised label, without its parentheses
207
+ */
208
+ function laneLabel(lane, snapshot) {
209
+ return `${lane.mode} lane, ref ${lane.ref}${snapshot ? ` @ ${shortSha(snapshot)}` : ''}`;
210
+ }
211
+
212
+ // The things a lane DOES, in one injectable seam (the `steps` shape
213
+ // `deploy-precheck` uses): the tests drive the order without a push, a pack or
214
+ // a network call, and the defaults are the real modules.
215
+ const LANE_STEPS = {
216
+ defaultBranch: defaultBranchOf,
217
+ heal: healDefaultBranch,
218
+ behind: assertNotBehind,
219
+ workflows: pushWorkflowFiles,
220
+ stage: stageLocalPackages,
221
+ push: pushSnapshot,
222
+ waitRef: waitForRef,
223
+ wait: waitForWorkflow,
224
+ };
225
+
226
+ /**
227
+ * The DELIVERY half of a lane: how this brand's code reaches GitHub before the
228
+ * dispatch that runs it. ONE implementation for its two callers
229
+ * ([#901](https://github.com/Omega-JS-Stack/omega/issues/901)): a target's own
230
+ * `deployViaDispatch` below, and the brand-root fan-out, which runs it once for
231
+ * the whole run rather than letting every target of a concurrent group perform
232
+ * it at the same brand root at the same moment.
233
+ *
234
+ * What the lane delivers, in order
235
+ * ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)):
236
+ * 1. the DEFAULT branch read once, and healed when published output has taken
237
+ * it over ([#922](https://github.com/Omega-JS-Stack/omega/issues/922)): a
238
+ * `gh-pages` default moves back to `main` here, before step 2 writes a
239
+ * workflow file to a branch the next web deploy force-pushes over;
240
+ * 2. the BEHIND check, for a brand that is its own repo's toplevel: a checkout
241
+ * behind the remote default branch refuses, because the force-push below
242
+ * would overwrite the deploy branch with a tree that lacks what the admin
243
+ * publish committed there;
244
+ * 3. the composed WORKFLOW FILES to the default branch, and only when they
245
+ * differ, because GitHub registers a workflow from that branch alone. This
246
+ * is the one write a deploy makes outside the deploy branch: the local tree
247
+ * is never committed, and what the developer has not committed stays theirs;
248
+ * 4. the linked packages PACKED (tarballs are files in the tree, so they ride
249
+ * the snapshot like anything else untracked and not ignored), the brand
250
+ * folder force-pushed to the deploy branch, and the tree put back.
251
+ *
252
+ * A brand outside git (`repo: false`) delivers nothing at all: there is no
253
+ * index to snapshot from, so the dispatch runs on whatever the branch holds.
254
+ *
255
+ * @param {object} options - Options.
256
+ * @param {object} options.lane - The lane `resolveDeployLane` returned.
257
+ * @param {string} [options.owner] - The snapshot's repo owner (snapshot lane).
258
+ * @param {string} [options.repo] - The snapshot's repo name (snapshot lane).
259
+ * @param {string} [options.ref] - The ref the snapshot lands on (default: the lane's).
260
+ * @param {string} [options.token] - GitHub token for the snapshot push.
261
+ * @param {string} [options.message] - Snapshot commit message.
262
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
263
+ * @param {function} [options.fetchFn] - Injectable fetch (the ref wait, tests).
264
+ * @param {object} [options.steps] - Lane step overrides (tests).
265
+ * @returns {Promise<{ sha: string|null }>} the sha the snapshot landed at, and
266
+ * null on a lane that pushes none
267
+ */
268
+ async function deliverLane(options) {
269
+ const lane = options.lane;
270
+ const logger = options.logger;
271
+ const steps = { ...LANE_STEPS, ...(options.steps || {}) };
272
+ const ref = options.ref || lane.ref;
273
+
274
+ if (lane.mode !== 'snapshot') {
275
+ return { sha: null };
276
+ }
277
+
278
+ // Read ONCE, used twice: the branch a behind checkout is measured against,
279
+ // and the branch the composed workflows are pushed to. Null is a repo GitHub
280
+ // does not have yet, and the snapshot push below is where that says itself.
281
+ // A dry run returns before this whole function, so it reads nothing here and
282
+ // heals nothing below ([#922](https://github.com/Omega-JS-Stack/omega/issues/922)).
283
+ const current = await steps.defaultBranch({
284
+ owner: options.owner,
285
+ repo: options.repo,
286
+ token: options.token,
287
+ fetchFn: options.fetchFn,
288
+ });
289
+
290
+ // The gh-pages HEAL, on the name just read and before anything is written to
291
+ // it (#922): published output is where the next web deploy's force-push wipes
292
+ // whatever the compose step put there, so the default branch moves back to
293
+ // main first and the rest of the lane uses the healed name.
294
+ const branch = await steps.heal({
295
+ owner: options.owner,
296
+ repo: options.repo,
297
+ current,
298
+ token: options.token,
299
+ fetchFn: options.fetchFn,
300
+ logger,
301
+ });
302
+
303
+ if (branch) {
304
+ // A NESTED brand's git toplevel is somebody else's repo (this monorepo, for
305
+ // the playground), so there is no checkout of the brand's own repo to be
306
+ // behind: nothing to compare, and nothing to refuse.
307
+ if (!lane.nested) {
308
+ steps.behind({ cwd: lane.brandRoot, branch, logger });
309
+ }
310
+
311
+ await steps.workflows({
312
+ brandRoot: lane.brandRoot,
313
+ owner: options.owner,
314
+ repo: options.repo,
315
+ branch,
316
+ token: options.token,
317
+ fetchFn: options.fetchFn,
318
+ logger,
319
+ });
320
+ } else if (logger) {
321
+ logger.log(`${options.owner}/${options.repo} answered no default branch (a repo nobody has created yet): no workflow compare, and the snapshot push speaks for itself.`);
322
+ }
323
+
324
+ if (logger) {
325
+ logger.log(`Snapshotting ${lane.brandRoot} to ${options.owner}/${options.repo}#${ref}${lane.linked ? ' (packing its linked packages)' : ''}`);
326
+ }
327
+
328
+ const staging = lane.linked
329
+ ? await steps.stage({ dir: lane.brandRoot, log: logger ? (line) => logger.log(line) : undefined })
330
+ : null;
331
+
332
+ let sha;
333
+
334
+ try {
335
+ sha = steps.push({
336
+ brandRoot: lane.brandRoot,
337
+ owner: options.owner,
338
+ repo: options.repo,
339
+ ref,
340
+ token: options.token,
341
+ message: options.message,
342
+ // What the stage just wrote has to reach the runner, so the push
343
+ // refuses a tree whose ignore rules would drop it.
344
+ require: staging ? [STAGING_DIR, 'package-lock.json'] : [],
345
+ });
346
+ } finally {
347
+ // ALWAYS, and as early as possible: the push carried the staged shape, so
348
+ // the developer's tree goes back before anything else can fail.
349
+ if (staging) {
350
+ await staging.restore();
351
+ }
352
+ }
353
+
354
+ // Then the ref itself (#902): GitHub resolves the branch on its side, so a
355
+ // dispatch sent in the same second as the force-push can still resolve to the
356
+ // PREVIOUS commit and build a tree this deploy never pushed. Both callers get
357
+ // the wait from here, so every target of a run dispatches a settled ref.
358
+ await steps.waitRef({
359
+ owner: options.owner,
360
+ repo: options.repo,
361
+ ref,
362
+ sha,
363
+ token: options.token,
364
+ fetchFn: options.fetchFn,
365
+ logger,
366
+ });
367
+
368
+ return { sha };
369
+ }
370
+
371
+ /**
372
+ * The one deploy path: resolve repo + token, resolve the LANE, carry the code
373
+ * to GitHub the way that lane says, then dispatch. A dryRun returns the plan
374
+ * untouched instead.
375
+ *
376
+ * The lane runs only when the caller names a `dir` (a local tree). A
377
+ * server-side dispatch (the admin post route) has no working tree at all and
378
+ * addresses code GitHub already has, so it dispatches and nothing else.
379
+ *
161
380
  * @param {object} options
162
381
  * @param {string} options.workflow - workflow file name
382
+ * @param {string} [options.dir] - any dir inside the brand (turns the lane on)
163
383
  * @param {string} [options.cwd] - repo dir for remote resolution
164
384
  * @param {string} [options.owner] - explicit owner (skips git resolution)
165
385
  * @param {string} [options.repo] - explicit repo (skips git resolution)
166
- * @param {string} [options.ref] - branch (default main)
386
+ * @param {string} [options.ref] - branch (default: the lane's, else main)
167
387
  * @param {object} [options.inputs] - workflow inputs
168
388
  * @param {boolean} [options.dryRun] - build the plan but never send
389
+ * @param {string} [options.message] - snapshot commit message
390
+ * @param {string} [options.snapshot] - the sha a snapshot the CALLER already
391
+ * pushed to this lane's ref sits at (the brand-root fan-out's one push,
392
+ * [#901](https://github.com/Omega-JS-Stack/omega/issues/901)): the lane's
393
+ * delivery is done, so this run waits and dispatches against it
394
+ * @param {object} [options.logger] - logger with `log` (silent when omitted)
169
395
  * @param {object} [options.env] - env map for token resolution
170
396
  * @param {function} [options.fetchFn] - injectable fetch
171
397
  * @param {function} [options.execFn] - injectable exec
172
- * @returns {Promise<{ plan: object, dispatched: boolean }>}
398
+ * @param {object} [options.steps] - lane step overrides (tests)
399
+ * @returns {Promise<{ plan: object, dispatched: boolean, lane: object|null, sha: string|null }>}
400
+ * the plan, whether it was sent, the lane, and the sha this deploy dispatched
401
+ * against (the caller's `snapshot`, else the one the lane pushed, else null on
402
+ * a lane that pushes none, [#902](https://github.com/Omega-JS-Stack/omega/issues/902))
173
403
  */
174
404
  async function deployViaDispatch(options) {
175
405
  const target = options.owner && options.repo
176
406
  ? { owner: options.owner, repo: options.repo }
177
407
  : resolveRepo({ cwd: options.cwd, execFn: options.execFn });
178
408
 
409
+ const lane = options.dir ? resolveDeployLane({ dir: options.dir, execFn: options.execFn }) : null;
410
+ const logger = options.logger;
411
+ // What this deploy dispatches against, for the line it prints and for the
412
+ // follower's head check (#902): the caller's snapshot when it has one, else
413
+ // whatever the lane's own delivery pushed below.
414
+ let sha = options.snapshot || null;
415
+
416
+ // A sha only means something on the lane that pushes one (#901). A brand
417
+ // with no repo snapshots nothing, so a sha claiming to be on its ref is a
418
+ // ref this run never wrote: loud, at the boundary, before a step has run.
419
+ if (options.snapshot && !(lane && lane.mode === 'snapshot')) {
420
+ throw new Error(`--snapshot=${options.snapshot} is the snapshot lane's alone: this deploy takes the ${lane ? `${lane.mode} lane` : 'dispatch-only lane'}, which pushes no snapshot to skip.`);
421
+ }
422
+
179
423
  const plan = buildDispatch({
180
424
  owner: target.owner,
181
425
  repo: target.repo,
182
426
  workflow: options.workflow,
183
- ref: options.ref,
427
+ ref: options.ref || (lane ? lane.ref : null),
184
428
  inputs: options.inputs,
185
429
  });
186
430
 
187
431
  if (options.dryRun) {
188
- return { plan, dispatched: false };
432
+ // The plan IS the dry run, the lane included: what would happen, with
433
+ // neither git nor the network touched. The workflow half of it is read off
434
+ // DISK (the scaffold has already composed this run's files), so the preview
435
+ // names them and the branch they would go to without asking GitHub which of
436
+ // them differ (#915).
437
+ if (logger && lane && lane.mode === 'snapshot') {
438
+ const names = composedWorkflowFiles(lane.brandRoot).map((file) => file.name);
439
+
440
+ logger.log(names.length
441
+ ? `Would compare ${names.join(', ')} with ${target.owner}/${target.repo}'s default branch and push the ones missing or changed there in one \`chore(ci): compose\` commit (a dry run reads nothing, so which differ is unknown here); the brand folder itself goes to ${plan.body.ref}.`
442
+ : `No composed workflow in ${lane.brandRoot}/.github/workflows: the default branch would receive nothing, and the brand folder goes to ${plan.body.ref}.`);
443
+ }
444
+
445
+ return { plan, dispatched: false, lane, sha };
189
446
  }
190
447
 
191
448
  const token = options.token || resolveToken({ env: options.env, execFn: options.execFn });
449
+ const steps = { ...LANE_STEPS, ...(options.steps || {}) };
450
+
451
+ if (lane && lane.mode === 'snapshot' && options.snapshot) {
452
+ // The brand root pushed this run's snapshot ONCE, before it spawned a
453
+ // target (#901), so every step of the delivery is already done: the
454
+ // workflow compare, the packing and the push would only overwrite the very
455
+ // ref the other targets of this run are dispatching against. The wait and
456
+ // the dispatch below are the whole lane from here.
457
+ if (logger) {
458
+ logger.log(`Dispatching against the run's snapshot ${shortSha(options.snapshot)} on ${target.owner}/${target.repo}#${plan.body.ref} (the brand root pushed it once for every target)`);
459
+ }
460
+ } else if (lane) {
461
+ // Whatever this lane delivers, it is delivered in ONE place (#901), so a
462
+ // brand-root fan-out can run the very same delivery once for every target
463
+ // of a run instead of once per target.
464
+ const delivered = await deliverLane({
465
+ lane,
466
+ owner: target.owner,
467
+ repo: target.repo,
468
+ ref: plan.body.ref,
469
+ token,
470
+ message: options.message,
471
+ logger,
472
+ fetchFn: options.fetchFn,
473
+ steps,
474
+ });
475
+
476
+ sha = delivered.sha;
477
+ }
478
+
479
+ if (lane) {
480
+ // Both lanes wait the same way: GitHub indexes a workflow it has just
481
+ // received a few seconds late, and a brand's FIRST deploy is exactly the
482
+ // push that carries the composed file. The wait reads the default branch,
483
+ // which is where a dispatch reads the workflow from.
484
+ await steps.wait({
485
+ owner: target.owner,
486
+ repo: target.repo,
487
+ workflow: options.workflow,
488
+ ref: plan.body.ref,
489
+ token,
490
+ fetchFn: options.fetchFn,
491
+ logger,
492
+ });
493
+ }
494
+
192
495
  await dispatchWorkflow(plan, { token, fetchFn: options.fetchFn });
193
- return { plan, dispatched: true };
496
+ return { plan, dispatched: true, lane, sha };
194
497
  }
195
498
 
196
499
  /**
197
500
  * Find every `file:` @omega.js spec in the brand tree. npm resolves the
198
501
  * WHOLE workspace tree on any install, so one linked sibling target breaks a
199
502
  * CI install even when the deploying target is clean (the cp194 lesson —
200
- * linking is tree-wide, so detection is too). Deploy verbs use this to
201
- * AUTO-SELECT their local-artifact lane (mirrored rule, Ian 2026-07-20:
202
- * a linked brand ships the LOCAL framework — build here, ship the artifact;
203
- * CI dispatch is only for registry-clean trees).
503
+ * linking is tree-wide, so detection is too). `resolveDeployLane` reads it
504
+ * to pick the SNAPSHOT lane, which packs the linked frameworks so the runner
505
+ * installs the same code this tree runs
506
+ * ([#872](https://github.com/Omega-JS-Stack/omega/issues/872); this replaces
507
+ * the 2026-07-20 rule that a linked brand built and shipped the artifact from
508
+ * this machine, which is `--direct` now).
204
509
  * @param {object} [options]
205
510
  * @param {string} [options.dir] - Any directory inside the brand (default cwd).
206
511
  * @returns {string[]} One line per offender: `<manifest> → <name>: <spec>`.
@@ -222,65 +527,130 @@ function findLocalSpecs(options = {}) {
222
527
  }
223
528
 
224
529
  /**
225
- * Throw when the brand tree carries `file:` @omega.js specs — for lanes with
226
- * no local-artifact fallback where dispatching would only burn a CI run.
227
- * @param {object} [options]
530
+ * The LANE a deploy takes, derived once for every target
531
+ * ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)), and since
532
+ * [#915](https://github.com/Omega-JS-Stack/omega/issues/915) there is ONE of
533
+ * them: every brand with a git repo SNAPSHOTS its folder to `omega-deploy`, and
534
+ * CI only ever builds that branch, whoever started the deploy. The push lane
535
+ * (commit the developer's branch, push it, dispatch it) is gone, with the
536
+ * `git add -A` commit that swept work nobody meant to ship.
537
+ *
538
+ * Two facts still ride ON the lane, because the steps read them:
539
+ * - NESTED: the brand root is not the toplevel of the git repo it sits in (a
540
+ * brand inside this monorepo), so its git toplevel is somebody else's repo:
541
+ * the behind check has nothing to compare, and the secrets publisher skips
542
+ * its remote-mismatch guard.
543
+ * - LINKED: the brand tree carries a `file:` @omega.js spec anywhere. A runner
544
+ * can install none of those, so the packed tarballs have to travel with the
545
+ * snapshot.
546
+ *
547
+ * A brand outside git altogether (`repo` false) can snapshot nothing: its lane
548
+ * is the wait and the dispatch alone, on whatever the deploy branch holds.
549
+ *
550
+ * @param {object} [options] - Options.
228
551
  * @param {string} [options.dir] - Any directory inside the brand (default cwd).
229
- * @throws {Error} Listing every file:-spec'd @omega.js dependency, per target.
552
+ * @param {function} [options.execFn] - Injectable exec (tests).
553
+ * @returns {{ mode: string, ref: string, nested: boolean, linked: boolean, repo: boolean, brandRoot: string }}
554
+ * The lane, plus the brand root every step of it works from.
230
555
  */
231
- function assertNoLocalSpecs(options = {}) {
232
- const offenders = findLocalSpecs(options);
233
- if (offenders.length > 0) {
234
- throw new Error(
235
- 'Local file: packages are linked somewhere in this brand — CI cannot install them.\n'
236
- + ` ${offenders.join('\n ')}\n`
237
- + 'Deploy from the local-artifact lane, or restore registry specs first (omega i live).'
238
- );
556
+ function resolveDeployLane(options = {}) {
557
+ const brandRoot = findBrandRoot(options.dir || process.cwd());
558
+ const execFn = options.execFn || ((cmd, opts) => execSync(cmd, opts).toString());
559
+ const git = (command) => {
560
+ try {
561
+ return String(execFn(command, { cwd: brandRoot, stdio: ['ignore', 'pipe', 'ignore'] })).trim();
562
+ } catch (e) {
563
+ // A brand outside git at all: there is nothing to snapshot and nothing to
564
+ // push, so the lane is the plain one and the deploy speaks for itself.
565
+ return '';
566
+ }
567
+ };
568
+
569
+ const toplevel = git('git rev-parse --show-toplevel');
570
+ const repo = Boolean(toplevel);
571
+ const nested = repo && path.resolve(toplevel) !== path.resolve(brandRoot);
572
+ const linked = findLocalSpecs({ dir: brandRoot }).length > 0;
573
+
574
+ if (linked && !repo) {
575
+ // A linked brand has to SNAPSHOT (the packed tarballs travel no other way),
576
+ // and a snapshot is built out of a git index, so no repo is no lane. Said
577
+ // by name HERE, because the push would otherwise die a step later on a raw
578
+ // `fatal: not a git repository` out of `git check-ignore` (#872).
579
+ throw new Error(`${brandRoot} is not inside a git repo, and a linked brand needs a git repo to snapshot from: \`git init\` the brand, or restore registry versions with \`omega i live\`.`);
580
+ }
581
+ if (!repo) {
582
+ // Nothing to snapshot FROM, and nothing linked that would need to: the
583
+ // deploy is the wait and the dispatch, on whatever the branch already holds.
584
+ return { mode: 'dispatch', ref: SNAPSHOT_REF, nested, linked, repo, brandRoot };
239
585
  }
586
+
587
+ return { mode: 'snapshot', ref: SNAPSHOT_REF, nested, linked, repo, brandRoot };
240
588
  }
241
589
 
242
590
  /**
243
- * Commit + push the working tree before a dispatch deploy (D13: the push
244
- * itself triggers NOTHING — scaffolded workflows carry no push triggers).
245
- * Plain git via argument arrays: universal on consumer machines and
246
- * injection-safe for the message.
247
- * @param {object} [options]
248
- * @param {string} [options.cwd] - Repo directory (default process.cwd()).
249
- * @param {string} [options.message] - Commit message (default 'Deploy').
250
- * @param {object} [options.logger] - Logger with log (silent when omitted).
591
+ * Refuse a deploy from a checkout that is BEHIND the repo's default branch
592
+ * ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)).
593
+ *
594
+ * The delivery force-pushes this folder over `omega-deploy`. A checkout missing
595
+ * commits the default branch already carries would therefore publish a site
596
+ * without them, and the admin publish is exactly that case: it commits a post
597
+ * to the default branch and to the deploy branch, and a stale laptop would
598
+ * force it back off both. So the deploy stops here, with the fix in the line.
599
+ *
600
+ * Plain git via argument arrays, like every other git call in this lane. No
601
+ * `origin` remote, or a remote that has no such branch yet, is nothing to be
602
+ * behind: both proceed.
603
+ *
604
+ * @param {object} options - Options.
605
+ * @param {string} options.cwd - The brand root (its own repo's toplevel).
606
+ * @param {string} options.branch - The repo's default branch.
607
+ * @param {object} [options.logger] - Logger with `log` (silent when omitted).
608
+ * @param {function} [options.execFn] - Injectable exec (tests).
609
+ * @throws {Error} When `origin/<branch>` is not an ancestor of HEAD.
251
610
  */
252
- function syncWorkingTree(options = {}) {
253
- const cwd = options.cwd || process.cwd();
254
- const message = options.message || 'Deploy';
611
+ function assertNotBehind(options) {
612
+ const { cwd, branch, logger } = options;
613
+ const execFn = options.execFn || ((args, opts) => execFileSync('git', args, opts));
614
+ const git = (args) => execFn(args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
255
615
 
256
- execFileSync('git', ['add', '-A'], { cwd, stdio: 'inherit' });
257
-
258
- // `git diff --cached --quiet` exits 1 exactly when something is staged
259
- let hasStaged = false;
260
616
  try {
261
- execFileSync('git', ['diff', '--cached', '--quiet'], { cwd, stdio: 'ignore' });
617
+ git(['fetch', 'origin', branch]);
262
618
  } catch (e) {
263
- hasStaged = true;
264
- }
619
+ // ONLY the two answers that really mean there is nothing to compare
620
+ // against. A fetch that failed on the network, the token or the remote's
621
+ // permissions says nothing about this checkout, and proceeding on it would
622
+ // force-push a stale folder over everything the branch carries.
623
+ const said = `${e.stderr || ''}${e.message || ''}`;
265
624
 
266
- if (hasStaged) {
267
- execFileSync('git', ['commit', '-m', message], { cwd, stdio: 'inherit' });
268
- } else if (options.logger) {
269
- options.logger.log('Working tree clean — nothing to commit');
625
+ if (!/couldn't find remote ref|does not appear to be a git repository|No such remote/i.test(said)) {
626
+ throw new Error(`Could not fetch origin/${branch} in ${cwd}, so there is no telling whether this checkout is behind it, and the deploy force-pushes the checkout over ${SNAPSHOT_REF}. git said: ${said.trim() || 'nothing at all'}`);
627
+ }
628
+
629
+ if (logger) logger.log(`No origin/${branch} to compare against yet: nothing to be behind.`);
630
+ return;
270
631
  }
271
632
 
272
- execFileSync('git', ['push'], { cwd, stdio: 'inherit' });
633
+ try {
634
+ // Exits 0 exactly when the remote branch is already in this history.
635
+ git(['merge-base', '--is-ancestor', `origin/${branch}`, 'HEAD']);
636
+ } catch (e) {
637
+ throw new Error(`This checkout is behind origin/${branch}, and the deploy force-pushes it over ${SNAPSHOT_REF}: anything ${branch} carries that you do not (a post the admin published, a sibling's commit) would be deployed away. Run \`git pull\` in ${cwd}, then deploy again.`);
638
+ }
273
639
  }
274
640
 
275
641
  module.exports = {
276
642
  parseRemoteUrl,
277
643
  dispatchRepo,
644
+ dispatchTarget,
278
645
  resolveRepo,
279
646
  resolveToken,
280
647
  buildDispatch,
281
648
  dispatchWorkflow,
282
649
  deployViaDispatch,
650
+ deliverLane,
651
+ laneLabel,
283
652
  findLocalSpecs,
284
- assertNoLocalSpecs,
285
- syncWorkingTree,
653
+ resolveDeployLane,
654
+ assertNotBehind,
655
+ SNAPSHOT_REF,
286
656
  };