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