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