six-degrees 0.8.0 → 1.0.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 (180) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  13. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  14. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  15. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/data/backup/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/data/csv/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/data/restore/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/scraper/route.js +1 -1
  38. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/social/crm/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/social/messages/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/import.html +1 -1
  54. package/.next/standalone/.next/server/app/import.rsc +16 -16
  55. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +16 -16
  56. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
  57. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +15 -15
  58. package/.next/standalone/.next/server/app/index.html +1 -1
  59. package/.next/standalone/.next/server/app/index.rsc +16 -16
  60. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +15 -15
  61. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  62. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  63. package/.next/standalone/.next/server/app/insights/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/insights/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/app/insights.html +1 -1
  66. package/.next/standalone/.next/server/app/insights.rsc +16 -16
  67. package/.next/standalone/.next/server/app/insights.segments/_full.segment.rsc +16 -16
  68. package/.next/standalone/.next/server/app/insights.segments/_tree.segment.rsc +1 -1
  69. package/.next/standalone/.next/server/app/insights.segments/insights/__PAGE__.segment.rsc +15 -15
  70. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/launch.html +2 -2
  73. package/.next/standalone/.next/server/app/launch.rsc +16 -16
  74. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +16 -16
  75. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
  76. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +15 -15
  77. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  78. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  79. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  80. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  81. package/.next/standalone/.next/server/app/paths.html +1 -1
  82. package/.next/standalone/.next/server/app/paths.rsc +16 -16
  83. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +16 -16
  84. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
  85. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +15 -15
  86. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/profile.html +1 -1
  89. package/.next/standalone/.next/server/app/profile.rsc +16 -16
  90. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +16 -16
  91. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
  92. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +15 -15
  93. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  94. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  95. package/.next/standalone/.next/server/app/queue.html +1 -1
  96. package/.next/standalone/.next/server/app/queue.rsc +16 -16
  97. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +16 -16
  98. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
  99. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +15 -15
  100. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  101. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
  102. package/.next/standalone/.next/server/app/scores.html +1 -1
  103. package/.next/standalone/.next/server/app/scores.rsc +16 -16
  104. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +16 -16
  105. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
  106. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +15 -15
  107. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  108. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  109. package/.next/standalone/.next/server/app/settings.html +1 -1
  110. package/.next/standalone/.next/server/app/settings.rsc +16 -16
  111. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +16 -16
  112. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  113. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +15 -15
  114. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  115. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  116. package/.next/standalone/.next/server/app/setup.html +1 -1
  117. package/.next/standalone/.next/server/app/setup.rsc +17 -17
  118. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +17 -17
  119. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
  120. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +15 -15
  121. package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
  122. package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
  123. package/.next/standalone/.next/server/app/social.html +1 -1
  124. package/.next/standalone/.next/server/app/social.rsc +16 -16
  125. package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +16 -16
  126. package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +1 -1
  127. package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +15 -15
  128. package/.next/standalone/.next/server/chunks/_1_fynia._.js +7 -7
  129. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/lib_02iz_9q._.js +2 -2
  131. package/.next/standalone/.next/server/chunks/lib_05agvk3._.js +2 -2
  132. package/.next/standalone/.next/server/chunks/lib_0v84u66._.js +2 -2
  133. package/.next/standalone/.next/server/chunks/lib_db-client_0_dch35.js +1 -1
  134. package/.next/standalone/.next/server/chunks/{lib_rpc_0t552z6.js → lib_rpc_1hjjjr4.js} +1 -1
  135. package/.next/standalone/.next/server/chunks/ssr/_0_79idq._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +4 -4
  137. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  138. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  139. package/.next/standalone/.next/server/pages/404.html +1 -1
  140. package/.next/standalone/.next/server/pages/500.html +1 -1
  141. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  142. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  143. package/.next/standalone/.next/static/chunks/0hggugbh84m4t.js +21 -0
  144. package/.next/standalone/.next/static/chunks/1ods5h86raivz.js +1 -0
  145. package/.next/standalone/CHANGELOG.md +23 -0
  146. package/.next/standalone/README.md +15 -13
  147. package/.next/standalone/SECURITY.md +9 -4
  148. package/.next/standalone/app/api/scraper/route.js +9 -1
  149. package/.next/standalone/app/setup/page.js +55 -4
  150. package/.next/standalone/install.sh +7 -6
  151. package/.next/standalone/lib/scanner-setup.js +82 -3
  152. package/.next/standalone/lib/settings.js +3 -0
  153. package/.next/standalone/package-lock.json +2 -2
  154. package/.next/standalone/package.json +1 -1
  155. package/.next/standalone/scripts/build-app.mjs +167 -11
  156. package/.next/standalone/scripts/build-desktop.mjs +3 -2
  157. package/.next/standalone/scripts/ci-mac-signing.sh +128 -0
  158. package/.next/standalone/scripts/entitlements/electron.plist +23 -0
  159. package/.next/standalone/scripts/entitlements/node.plist +27 -0
  160. package/.next/standalone/scripts/mac-sign.mjs +563 -0
  161. package/.next/standalone/scripts/make-dmg-background.mjs +2 -1
  162. package/.next/standalone/scripts/set-signing-secrets.sh +96 -0
  163. package/.next/static/chunks/0hggugbh84m4t.js +21 -0
  164. package/.next/static/chunks/1ods5h86raivz.js +1 -0
  165. package/CHANGELOG.md +23 -0
  166. package/README.md +15 -13
  167. package/SECURITY.md +9 -4
  168. package/lib/scanner-setup.js +82 -3
  169. package/lib/settings.js +3 -0
  170. package/package.json +1 -1
  171. package/.next/standalone/.next/static/chunks/0838_s6w-i_ik.js +0 -1
  172. package/.next/standalone/.next/static/chunks/112mv-com-mqa.js +0 -21
  173. package/.next/static/chunks/0838_s6w-i_ik.js +0 -1
  174. package/.next/static/chunks/112mv-com-mqa.js +0 -21
  175. /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_buildManifest.js +0 -0
  176. /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_clientMiddlewareManifest.js +0 -0
  177. /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_ssgManifest.js +0 -0
  178. /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_buildManifest.js +0 -0
  179. /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_ssgManifest.js +0 -0
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env node
2
+ // Signing and notarizing the Mac app (docs/brain/DESKTOP.md D4). Used by
3
+ // scripts/build-app.mjs, and by scripts/ci-mac-signing.sh through the
4
+ // `release-plan` command at the bottom.
5
+ //
6
+ // Two ways, chosen by the environment (signingSetup):
7
+ //
8
+ // ad hoc no identity. Exactly what every build did before D4, and what
9
+ // forks, pull requests and local builds without a certificate
10
+ // still do: build-app.mjs runs its own `codesign --sign -` lines,
11
+ // untouched. Nothing in this file runs for it.
12
+ // Developer ID SIX_DEGREES_SIGN_IDENTITY names a "Developer ID Application"
13
+ // identity (its name or its SHA-1), SIX_DEGREES_SIGN_KEYCHAIN
14
+ // optionally the keychain holding it. Every program and library
15
+ // is signed on its own, inside out, never with --deep: codesign's
16
+ // --deep signs nested bundles but not the loose programs and
17
+ // libraries in Resources (the server's Node, the scanner's
18
+ // Python, native modules), and it gives everything the same
19
+ // entitlements. Each signature has the hardened runtime and a
20
+ // secure timestamp, both of which Apple's notary requires.
21
+ //
22
+ // Notarization, on top of Developer ID, when SIX_DEGREES_NOTARY_KEY (the path to
23
+ // an App Store Connect API key, AuthKey_….p8), SIX_DEGREES_NOTARY_KEY_ID and
24
+ // SIX_DEGREES_NOTARY_ISSUER are all set: notarytool submits, waits for Apple's
25
+ // answer, and on a refusal fetches Apple's log and prints its issues.
26
+ //
27
+ // The decisions are pure functions at the top (tests/mac-sign.test.mjs); the
28
+ // work below them runs codesign, notarytool, stapler and spctl. Nothing here
29
+ // ever prints a credential: notarytool's command line holds the key's id and
30
+ // issuer, so its errors are never shown as Node builds them ("Command failed:
31
+ // xcrun notarytool … --key-id …"), only its own output, with those values
32
+ // replaced by *** (scrub).
33
+
34
+ import { spawnSync, execFileSync } from 'node:child_process';
35
+ import {
36
+ readdirSync, existsSync, openSync, readSync, closeSync, statSync,
37
+ } from 'node:fs';
38
+ import path from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
42
+
43
+ /** The GitHub Actions secrets the release workflow signs with (scripts/set-signing-secrets.sh sets them). */
44
+ export const SECRET_NAMES = Object.freeze([
45
+ 'MACOS_SIGN_P12_BASE64',
46
+ 'MACOS_SIGN_P12_PASSWORD',
47
+ 'APPLE_API_KEY_P8_BASE64',
48
+ 'APPLE_API_KEY_ID',
49
+ 'APPLE_API_ISSUER_ID',
50
+ ]);
51
+
52
+ const NOTARY_VARS = Object.freeze(['SIX_DEGREES_NOTARY_KEY', 'SIX_DEGREES_NOTARY_KEY_ID', 'SIX_DEGREES_NOTARY_ISSUER']);
53
+
54
+ /**
55
+ * The entitlements, by who needs them. Only main executables carry
56
+ * entitlements; libraries get the hardened runtime and nothing else.
57
+ *
58
+ * electron the app and its helper apps: Electron's V8 compiles JavaScript as
59
+ * it runs, which the hardened runtime refuses without allow-jit
60
+ * (and allow-unsigned-executable-memory, which V8 still uses on
61
+ * Intel Macs).
62
+ * node the server's Node, and Playwright's driver when it has a Node of
63
+ * its own: the same V8. Checked here: Node 24 signed with the
64
+ * hardened runtime and no entitlements stops at once with "Failed
65
+ * to reserve virtual memory for CodeRange".
66
+ *
67
+ * Nobody gets com.apple.security.cs.disable-library-validation. Library
68
+ * validation lets a program load only libraries signed by Apple or by the same
69
+ * team, and every library in the app is signed here by the same identity: the
70
+ * Electron framework, the Python's extension modules, sharp's .node and its
71
+ * libvips. The app never loads a library from outside itself: the scanner's
72
+ * own environment (Install, Set up the scanner) is built from this computer's
73
+ * Python or a downloaded one, never from the app's (lib/scanner-python.js
74
+ * installSteps), so no pip-installed module is ever loaded by a program here.
75
+ *
76
+ * The scanner's Python gets none: CPython doesn't compile code at run time, and
77
+ * the scanner and its packages don't make executable memory (no ctypes
78
+ * callbacks). build-app.mjs runs it under the hardened runtime after signing
79
+ * (checkSignedRuntime), so a missing entitlement fails the build, not a scan.
80
+ */
81
+ export const ENTITLEMENTS = Object.freeze({
82
+ electron: path.join(HERE, 'entitlements', 'electron.plist'),
83
+ node: path.join(HERE, 'entitlements', 'node.plist'),
84
+ });
85
+
86
+ export class SigningError extends Error {}
87
+
88
+ const blank = (v) => v === undefined || v === null || String(v).trim() === '';
89
+
90
+ /**
91
+ * How this build signs, from the environment: { mode: 'ad-hoc' }, or
92
+ * { mode: 'developer-id', identity, keychain, notary } with notary null or
93
+ * { key, keyId, issuer }, or { error } for a setting that can't be right.
94
+ * `exists` checks a path (the key file, the keychain).
95
+ */
96
+ export function signingSetup(env = {}, { exists = () => true } = {}) {
97
+ const identity = String(env.SIX_DEGREES_SIGN_IDENTITY ?? '').trim();
98
+ const keychain = String(env.SIX_DEGREES_SIGN_KEYCHAIN ?? '').trim();
99
+ const notarySet = NOTARY_VARS.filter((name) => !blank(env[name]));
100
+ if (notarySet.length && notarySet.length < NOTARY_VARS.length) {
101
+ const missing = NOTARY_VARS.filter((name) => blank(env[name]));
102
+ return { error: `Notarization needs ${NOTARY_VARS.join(', ')}; ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set.` };
103
+ }
104
+ if (!identity || identity === '-') {
105
+ if (notarySet.length) {
106
+ return { error: 'Notarization needs a Developer ID signature, and SIX_DEGREES_SIGN_IDENTITY is not set (an ad hoc signature is never notarized).' };
107
+ }
108
+ return { mode: 'ad-hoc' };
109
+ }
110
+ if (keychain && !exists(keychain)) {
111
+ return { error: `SIX_DEGREES_SIGN_KEYCHAIN names a keychain that isn't there (${keychain}).` };
112
+ }
113
+ let notary = null;
114
+ if (notarySet.length) {
115
+ const key = String(env.SIX_DEGREES_NOTARY_KEY).trim();
116
+ // The key's path is said, never its contents: the path is no secret.
117
+ if (!exists(key)) return { error: `SIX_DEGREES_NOTARY_KEY names a file that isn't there (${key}).` };
118
+ notary = { key, keyId: String(env.SIX_DEGREES_NOTARY_KEY_ID).trim(), issuer: String(env.SIX_DEGREES_NOTARY_ISSUER).trim() };
119
+ }
120
+ return { mode: 'developer-id', identity, keychain: keychain || null, notary };
121
+ }
122
+
123
+ /** The major version, from "1.2.3" or "v1.2.3-beta.1"; NaN when there isn't one. */
124
+ function majorOf(version) {
125
+ const m = String(version ?? '').trim().replace(/^v/i, '').match(/^(\d+)\./);
126
+ return m ? Number(m[1]) : Number.NaN;
127
+ }
128
+
129
+ /**
130
+ * Must this release be signed and notarized? A real release (a tag, not a dry
131
+ * run) of 1.0.0 or later, its betas included: from 1.0 the download pages say
132
+ * it's signed, so a release must never quietly go out without it. Before 1.0,
133
+ * and on any dry run, a build without the secrets is signed ad hoc as before.
134
+ */
135
+ export function signingRequired({ version, dryRun }) {
136
+ if (dryRun) return false;
137
+ const major = majorOf(version);
138
+ return !Number.isFinite(major) || major >= 1;
139
+ }
140
+
141
+ /**
142
+ * What the release workflow does about signing (scripts/ci-mac-signing.sh):
143
+ * { sign: true } with all five secrets, { sign: false, note } with none when it
144
+ * may build ad hoc, or { error } (names only, never a value): some secrets but
145
+ * not all is always a mistake, and none at all when signing is required.
146
+ * `present` lists the secrets that are set and not empty.
147
+ */
148
+ export function releaseSigningPlan({ version, dryRun, present = [] }) {
149
+ const have = new Set(present);
150
+ const missing = SECRET_NAMES.filter((name) => !have.has(name));
151
+ const required = signingRequired({ version, dryRun });
152
+ if (!missing.length) return { sign: true, required };
153
+ if (missing.length < SECRET_NAMES.length) {
154
+ return {
155
+ error: `Some of the Mac signing secrets are set and some aren't. Missing: ${missing.join(', ')}. `
156
+ + 'Set all five (scripts/set-signing-secrets.sh), or none.',
157
+ required,
158
+ };
159
+ }
160
+ if (required) {
161
+ return {
162
+ error: `Version ${version} must be signed and notarized (every real release from 1.0.0 on is), and the signing secrets `
163
+ + `aren't set: ${SECRET_NAMES.join(', ')}. Set them with scripts/set-signing-secrets.sh, or do a dry run.`,
164
+ required,
165
+ };
166
+ }
167
+ return {
168
+ sign: false,
169
+ required,
170
+ note: `No signing secrets, so the Mac app is signed ad hoc, as before (fine for ${dryRun ? 'a dry run' : `${version}, before 1.0`}).`,
171
+ };
172
+ }
173
+
174
+ /** codesign's arguments for a Developer ID signature: hardened runtime, secure timestamp, never --deep. */
175
+ export function codesignArgs(setup, { entitlements = null, runtime = true } = {}) {
176
+ return [
177
+ '--force',
178
+ '--sign', setup.identity,
179
+ '--timestamp',
180
+ ...(runtime ? ['--options', 'runtime'] : []),
181
+ ...(setup.keychain ? ['--keychain', setup.keychain] : []),
182
+ ...(entitlements ? ['--entitlements', entitlements] : []),
183
+ ];
184
+ }
185
+
186
+ const BUNDLE_EXTENSIONS = ['.app', '.framework', '.xpc', '.appex'];
187
+ const MH_EXECUTE = 2;
188
+ const MH_DYLIB = 6;
189
+ const MH_BUNDLE = 8;
190
+
191
+ /**
192
+ * The bundle a file is the executable of, relative to the app ('' for the app
193
+ * itself), or null when it isn't one: `X.app/Contents/MacOS/<name>`, or
194
+ * `F.framework/Versions/<v>/F`. codesign treats such a file as its bundle, so it
195
+ * is signed when its bundle is, with the bundle's entitlements.
196
+ */
197
+ export function bundleOfExecutable(rel) {
198
+ const app = rel.match(/^(?:(.*\.app)\/)?Contents\/MacOS\/[^/]+$/);
199
+ if (app) return app[1] || '';
200
+ const fw = rel.match(/^(.*\/)?(([^/]+)\.framework)\/Versions\/[^/]+\/([^/]+)$/);
201
+ if (fw && fw[3] === fw[4]) return `${fw[1] || ''}${fw[2]}`;
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * The order and entitlements for signing an app, from what is in it. `entries`
207
+ * are paths relative to the app: { rel, kind: 'file', fileType } for each
208
+ * Mach-O file (fileType from its header: 2 a program, 6 a library, 8 a
209
+ * loadable module such as a Python extension or a .node), and
210
+ * { rel, kind: 'bundle' } for each code bundle inside (helper apps,
211
+ * frameworks). `mainIsMachO`: the app's own executable is a program (Electron)
212
+ * rather than a script (the classic launcher).
213
+ *
214
+ * Inside out: every loose program and library first (they depend on nothing
215
+ * around them), then the bundles, deepest first, so everything inside a bundle
216
+ * is signed before the bundle seals it, then the app last. A bundle's own
217
+ * executable is signed with its bundle, with the bundle's entitlements.
218
+ * Returns { files: [{ rel, entitlements }], bundles: [{ rel, entitlements }],
219
+ * app: { entitlements } }, entitlements being a key of ENTITLEMENTS or null.
220
+ */
221
+ export function signingPlan(entries, { mainIsMachO = true } = {}) {
222
+ const depth = (rel) => rel.split('/').length;
223
+ const files = [];
224
+ const bundles = [];
225
+ const bundleSet = new Set(entries.filter((e) => e.kind === 'bundle').map((e) => e.rel));
226
+ const signedWithBundle = (rel) => {
227
+ const root = bundleOfExecutable(rel);
228
+ return root === '' || (root !== null && bundleSet.has(root));
229
+ };
230
+ for (const e of entries) {
231
+ if (e.kind === 'bundle') {
232
+ bundles.push({ rel: e.rel, entitlements: e.rel.endsWith('.app') ? 'electron' : null });
233
+ } else if (e.kind === 'file' && [MH_EXECUTE, MH_DYLIB, MH_BUNDLE].includes(e.fileType) && !signedWithBundle(e.rel)) {
234
+ const program = e.fileType === MH_EXECUTE;
235
+ files.push({ rel: e.rel, entitlements: program && path.posix.basename(e.rel) === 'node' ? 'node' : null });
236
+ }
237
+ }
238
+ bundles.sort((a, b) => depth(b.rel) - depth(a.rel) || a.rel.localeCompare(b.rel));
239
+ files.sort((a, b) => depth(b.rel) - depth(a.rel) || a.rel.localeCompare(b.rel));
240
+ return { files, bundles, app: { entitlements: mainIsMachO ? 'electron' : null } };
241
+ }
242
+
243
+ /**
244
+ * spctl's verdict, from its -vvv output: { accepted, source, origin, disabled }.
245
+ * `disabled`: Gatekeeper's assessments are turned off on this machine
246
+ * ("override=security disabled"), so its "accepted" says nothing about the app.
247
+ */
248
+ export function parseSpctl(text) {
249
+ const s = String(text || '');
250
+ return {
251
+ accepted: /: accepted\s*$/m.test(s),
252
+ source: (s.match(/^source=(.+)$/m) || [null, null])[1]?.trim() || null,
253
+ origin: (s.match(/^origin=(.+)$/m) || [null, null])[1]?.trim() || null,
254
+ disabled: /^override=security disabled\s*$/m.test(s),
255
+ };
256
+ }
257
+
258
+ /** notarytool's --output-format json answer: { id, status, message }, or null. */
259
+ export function parseNotaryJson(text) {
260
+ const s = String(text || '');
261
+ const start = s.indexOf('{');
262
+ const end = s.lastIndexOf('}');
263
+ if (start < 0 || end <= start) return null;
264
+ try {
265
+ const j = JSON.parse(s.slice(start, end + 1));
266
+ if (!j || typeof j !== 'object') return null;
267
+ return { id: typeof j.id === 'string' ? j.id : null, status: typeof j.status === 'string' ? j.status : null, message: typeof j.message === 'string' ? j.message : null };
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /** The lines worth printing from a notarization log (`notarytool log`, JSON): one per issue, at most `max`. */
274
+ export function notaryIssueLines(log, { max = 40 } = {}) {
275
+ if (!log || typeof log !== 'object') return ['(Apple sent no readable log)'];
276
+ const issues = Array.isArray(log.issues) ? log.issues : [];
277
+ const lines = [];
278
+ if (log.statusSummary) lines.push(String(log.statusSummary));
279
+ for (const issue of issues.slice(0, max)) {
280
+ if (!issue || typeof issue !== 'object') continue;
281
+ const where = [issue.path, issue.architecture ? `(${issue.architecture})` : ''].filter(Boolean).join(' ');
282
+ lines.push(`${issue.severity || 'issue'}: ${where ? `${where}: ` : ''}${issue.message || 'no message'}`);
283
+ }
284
+ if (issues.length > max) lines.push(`… and ${issues.length - max} more`);
285
+ if (!issues.length && !lines.length) lines.push('(no issues listed)');
286
+ return lines;
287
+ }
288
+
289
+ /** `text` with every one of `secrets` (four characters or longer) replaced by ***. */
290
+ export function scrub(text, secrets = []) {
291
+ let s = String(text ?? '');
292
+ for (const secret of secrets) {
293
+ const v = String(secret ?? '');
294
+ if (v.length >= 4) s = s.split(v).join('***');
295
+ }
296
+ return s;
297
+ }
298
+
299
+ /** From `codesign -dv --verbose=2`: { authority, team, runtime, timestamp }. */
300
+ export function parseCodesignInfo(text) {
301
+ const s = String(text || '');
302
+ const team = (s.match(/^TeamIdentifier=(.+)$/m) || [null, null])[1]?.trim() || null;
303
+ return {
304
+ authority: (s.match(/^Authority=(.+)$/m) || [null, null])[1]?.trim() || null,
305
+ team: team && team !== 'not set' ? team : null,
306
+ runtime: /flags=0x[0-9a-f]+\([^)]*runtime[^)]*\)/.test(s),
307
+ timestamp: /^Timestamp=/m.test(s),
308
+ };
309
+ }
310
+
311
+ /** "Developer ID Application: Blake Burford (TEAMID)" → "Blake Burford". */
312
+ export function signerName(authority) {
313
+ return String(authority || '').replace(/^Developer ID Application:\s*/, '').replace(/\s*\([A-Z0-9]+\)\s*$/, '').trim() || null;
314
+ }
315
+
316
+ // ── the work ────────────────────────────────────────────────────────────────
317
+
318
+ /** A command's output, captured, never thrown: { status, stdout, stderr }. */
319
+ function capture(cmd, args, { env = process.env } = {}) {
320
+ const r = spawnSync(cmd, args, { env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
321
+ return { status: r.error ? null : r.status, stdout: r.stdout || '', stderr: r.error ? String(r.error.message) : (r.stderr || '') };
322
+ }
323
+
324
+ function pause(seconds) {
325
+ if (seconds > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000);
326
+ }
327
+
328
+ const lastLines = (text, n = 6) => String(text || '').trim().split('\n').slice(-n).join(' | ');
329
+
330
+ function readHead(file, offset, length) {
331
+ const fd = openSync(file, 'r');
332
+ try {
333
+ const buf = Buffer.alloc(length);
334
+ const n = readSync(fd, buf, 0, length, offset);
335
+ return buf.subarray(0, n);
336
+ } finally {
337
+ closeSync(fd);
338
+ }
339
+ }
340
+
341
+ /** A Mach-O file's type (2 program, 6 library, 8 loadable module, …), or null when it isn't one. Universal files by their first slice. */
342
+ export function machoFileType(file) {
343
+ let head;
344
+ try {
345
+ if (statSync(file).size < 32) return null;
346
+ head = readHead(file, 0, 32);
347
+ } catch {
348
+ return null;
349
+ }
350
+ const le = head.readUInt32LE(0);
351
+ if (le === 0xfeedfacf || le === 0xfeedface) return head.readUInt32LE(12);
352
+ const be = head.readUInt32BE(0);
353
+ if (be !== 0xcafebabe && be !== 0xcafebabf) return null;
354
+ const count = head.readUInt32BE(4);
355
+ if (count < 1 || count > 16) return null; // a Java class file starts with the same bytes
356
+ const offset = be === 0xcafebabf ? Number(head.readBigUInt64BE(16)) : head.readUInt32BE(16);
357
+ try {
358
+ const slice = readHead(file, offset, 16);
359
+ const m = slice.readUInt32LE(0);
360
+ return m === 0xfeedfacf || m === 0xfeedface ? slice.readUInt32LE(12) : null;
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ /** Is `dir` a code bundle codesign signs as one (a helper app, a framework)? */
367
+ function isCodeBundle(dir) {
368
+ if (!BUNDLE_EXTENSIONS.some((ext) => dir.endsWith(ext))) return false;
369
+ return [['Contents', 'Info.plist'], ['Resources', 'Info.plist'], ['Versions', 'Current']]
370
+ .some((p) => existsSync(path.join(dir, ...p)));
371
+ }
372
+
373
+ /**
374
+ * What is in the app, for signingPlan: every Mach-O regular file and every code
375
+ * bundle below it. Links aren't followed, so each file is seen once, at its
376
+ * real place (a framework's Versions/Current, Playwright's driver's link to
377
+ * the app's Node).
378
+ */
379
+ export function findCode(app) {
380
+ const entries = [];
381
+ const visit = (dir) => {
382
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
383
+ const at = path.join(dir, entry.name);
384
+ const rel = path.relative(app, at).split(path.sep).join('/');
385
+ if (entry.isDirectory()) {
386
+ if (isCodeBundle(at)) entries.push({ rel, kind: 'bundle' });
387
+ visit(at);
388
+ } else if (entry.isFile()) {
389
+ const fileType = machoFileType(at);
390
+ if (fileType !== null) entries.push({ rel, kind: 'file', fileType });
391
+ }
392
+ }
393
+ };
394
+ visit(app);
395
+ return entries;
396
+ }
397
+
398
+ /**
399
+ * Sign `app` with the Developer ID in `setup`, inside out (signingPlan), then
400
+ * check it the way release.yml and the in-app updater do. `entitlementsFiles`
401
+ * maps signingPlan's keys to plist files (ENTITLEMENTS; a test can pass its
402
+ * own). Prints one line per stage; codesign's own errors stop the build.
403
+ */
404
+ export function signDeveloperId(app, setup, { entitlementsFiles = ENTITLEMENTS, log = console.log, env = process.env } = {}) {
405
+ const exe = (() => {
406
+ try {
407
+ const name = execFileSync('/usr/bin/plutil', ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', path.join(app, 'Contents', 'Info.plist')]).toString().trim();
408
+ return path.join(app, 'Contents', 'MacOS', name);
409
+ } catch {
410
+ return null;
411
+ }
412
+ })();
413
+ const mainIsMachO = Boolean(exe && machoFileType(exe) === MH_EXECUTE);
414
+ const plan = signingPlan(findCode(app), { mainIsMachO });
415
+ const ent = (key) => (key ? entitlementsFiles[key] : null);
416
+ for (const key of new Set([...plan.files, ...plan.bundles, plan.app].map((p) => p.entitlements).filter(Boolean))) {
417
+ if (!existsSync(ent(key))) throw new SigningError(`The ${key} entitlements file is missing (${ent(key)}).`);
418
+ }
419
+ const sign = (paths, entitlements) => {
420
+ // Up to 32 at a time; each still gets its own timestamp from Apple. Its
421
+ // only chatter is "replacing existing signature", once a file: shown only
422
+ // with a failure, without those lines.
423
+ for (let i = 0; i < paths.length; i += 32) {
424
+ const r = capture('codesign', [...codesignArgs(setup, { entitlements: ent(entitlements) }), ...paths.slice(i, i + 32)], { env });
425
+ if (r.status !== 0) {
426
+ const why = r.stderr.split('\n').filter((l) => l.trim() && !/replacing existing signature$/.test(l)).slice(-6).join('\n ');
427
+ throw new SigningError(`codesign failed:\n ${why || `exit ${r.status}`}`);
428
+ }
429
+ }
430
+ };
431
+ // Loose programs and libraries, grouped by their entitlements (their order
432
+ // among themselves doesn't matter: none contains another).
433
+ for (const key of [null, 'node', 'electron']) {
434
+ const group = plan.files.filter((f) => f.entitlements === key).map((f) => path.join(app, f.rel));
435
+ if (group.length) sign(group, key);
436
+ }
437
+ log(` signed ${plan.files.length} programs and libraries one by one (${plan.files.filter((f) => f.entitlements).length} with entitlements)`);
438
+ for (const b of plan.bundles) sign([path.join(app, b.rel)], b.entitlements);
439
+ if (plan.bundles.length) log(` then ${plan.bundles.length} nested bundles, deepest first: ${plan.bundles.map((b) => path.basename(b.rel)).join(', ')}`);
440
+ sign([app], plan.app.entitlements);
441
+ const verify = capture('codesign', ['--verify', '--deep', '--strict', app], { env });
442
+ if (verify.status !== 0) throw new SigningError(`The signed app doesn't verify: ${lastLines(verify.stderr)}`);
443
+ const info = parseCodesignInfo(capture('codesign', ['-dv', '--verbose=2', app], { env }).stderr);
444
+ if (!info.runtime) throw new SigningError('The app was signed without the hardened runtime.');
445
+ log(` then the app: ${info.authority || setup.identity}${info.team ? `, team ${info.team}` : ''}, hardened runtime${info.timestamp ? ', timestamped' : ''}; verified`);
446
+ return { plan, info };
447
+ }
448
+
449
+ /** The signer of a signed file or app, from codesign: { authority, team, runtime, timestamp }. */
450
+ export function signatureInfo(file, { env = process.env } = {}) {
451
+ return parseCodesignInfo(capture('codesign', ['-dv', '--verbose=2', file], { env }).stderr);
452
+ }
453
+
454
+ /** Sign a disk image with the Developer ID (no runtime: that is for programs). */
455
+ export function signImage(image, setup, { env = process.env } = {}) {
456
+ const r = capture('codesign', [...codesignArgs(setup, { runtime: false }), image], { env });
457
+ if (r.status !== 0) throw new SigningError(`codesign couldn't sign ${path.basename(image)}: ${lastLines(r.stderr)}`);
458
+ const v = capture('codesign', ['--verify', '--strict', image], { env });
459
+ if (v.status !== 0) throw new SigningError(`${path.basename(image)}'s signature doesn't verify: ${lastLines(v.stderr)}`);
460
+ }
461
+
462
+ /**
463
+ * Notarize `file` (a zip of the app, or the signed disk image): submit, wait
464
+ * for Apple's answer, and return { id, status } once it is Accepted. On any
465
+ * other answer, Apple's log for the submission is fetched and its issues
466
+ * printed, and a SigningError is thrown. A submission that never got an id
467
+ * (the network, Apple's service) is tried again, `attempts` times in all.
468
+ */
469
+ export function notarize(file, notary, {
470
+ env = process.env, log = console.log, what = path.basename(file), attempts = 3, retrySeconds = 30, timeout = '1h',
471
+ } = {}) {
472
+ const creds = ['--key', notary.key, '--key-id', notary.keyId, '--issuer', notary.issuer];
473
+ const secrets = [notary.keyId, notary.issuer, notary.key];
474
+ let result = null;
475
+ let problem = '';
476
+ for (let i = 1; i <= attempts; i++) {
477
+ const r = capture('xcrun', ['notarytool', 'submit', file, ...creds, '--wait', '--timeout', timeout, '--output-format', 'json'], { env });
478
+ const parsed = parseNotaryJson(r.stdout);
479
+ if (parsed?.id && parsed.status) {
480
+ result = parsed;
481
+ break;
482
+ }
483
+ problem = scrub(lastLines(r.stderr) || lastLines(r.stdout) || `exit ${r.status}`, secrets);
484
+ log(` notarytool couldn't submit ${what} (try ${i} of ${attempts}): ${problem}`);
485
+ if (i < attempts) pause(retrySeconds);
486
+ }
487
+ if (!result) throw new SigningError(`Apple's notary service didn't take ${what}: ${problem}`);
488
+ log(` ${what}: submission ${result.id}, ${result.status}`);
489
+ if (result.status === 'Accepted') return result;
490
+ const l = capture('xcrun', ['notarytool', 'log', result.id, ...creds], { env });
491
+ let logJson = null;
492
+ try { logJson = JSON.parse(l.stdout); } catch { /* said below */ }
493
+ log(` Apple's log for ${result.id}:`);
494
+ for (const line of notaryIssueLines(logJson)) log(` ${scrub(line, secrets)}`);
495
+ if (!logJson && (l.stderr || l.stdout)) log(` ${scrub(lastLines(l.stderr || l.stdout), secrets)}`);
496
+ throw new SigningError(`Apple didn't notarize ${what} (${result.status}${result.message ? `: ${scrub(result.message, secrets)}` : ''}). Its issues are listed above.`);
497
+ }
498
+
499
+ /**
500
+ * Staple the notarization ticket to `file` (the app, or the disk image) and
501
+ * validate it. Right after a submission is accepted, Apple's ticket can take a
502
+ * moment to be served ("Record not found"), so it is tried a few times.
503
+ */
504
+ export function staple(file, { env = process.env, log = console.log, attempts = 5, retrySeconds = 20 } = {}) {
505
+ let last = '';
506
+ for (let i = 1; i <= attempts; i++) {
507
+ const r = capture('xcrun', ['stapler', 'staple', file], { env });
508
+ if (r.status === 0) {
509
+ const v = capture('xcrun', ['stapler', 'validate', file], { env });
510
+ if (v.status === 0) return;
511
+ last = lastLines(v.stderr || v.stdout);
512
+ } else {
513
+ last = lastLines(r.stderr || r.stdout);
514
+ }
515
+ log(` stapling ${path.basename(file)} didn't work yet (try ${i} of ${attempts}): ${last}`);
516
+ if (i < attempts) pause(retrySeconds);
517
+ }
518
+ throw new SigningError(`Couldn't staple the notarization ticket to ${path.basename(file)}: ${last}`);
519
+ }
520
+
521
+ /** Does `file` carry a valid stapled ticket? */
522
+ export function stapleValid(file, { env = process.env } = {}) {
523
+ return capture('xcrun', ['stapler', 'validate', file], { env }).status === 0;
524
+ }
525
+
526
+ /**
527
+ * What Gatekeeper says about `file`: an app ('exec') or a disk image ('open',
528
+ * judged by its own signature). { ok, accepted, source, origin, disabled, text },
529
+ * ok when it is accepted as "Notarized Developer ID". `disabled` when this
530
+ * machine has Gatekeeper's assessments turned off and so can't say: the caller
531
+ * then has only the stapled ticket to go on, and says so.
532
+ */
533
+ export function assess(file, type, { env = process.env } = {}) {
534
+ const args = type === 'exec'
535
+ ? ['-a', '-vvv', '-t', 'exec', file]
536
+ : ['-a', '-vvv', '-t', 'open', '--context', 'context:primary-signature', file];
537
+ const r = capture('spctl', args, { env });
538
+ const text = `${r.stdout}\n${r.stderr}`.trim();
539
+ const v = parseSpctl(text);
540
+ return { ...v, ok: r.status === 0 && v.accepted && v.source === 'Notarized Developer ID', text };
541
+ }
542
+
543
+ // ── release.yml's question: sign this release, or not? ─────────────────────
544
+ //
545
+ // node scripts/mac-sign.mjs release-plan <version> <dry-run: true|false>
546
+ //
547
+ // Reads only whether each of SECRET_NAMES is set (never a value). Prints
548
+ // "developer-id" or "ad-hoc" and exits 0, or says why not and exits 1.
549
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
550
+ const [command, version, dry] = process.argv.slice(2);
551
+ if (command !== 'release-plan' || !version) {
552
+ console.error('usage: node scripts/mac-sign.mjs release-plan <version> <dry-run: true|false>');
553
+ process.exit(2);
554
+ }
555
+ const present = SECRET_NAMES.filter((name) => !blank(process.env[name]));
556
+ const plan = releaseSigningPlan({ version, dryRun: dry === 'true', present });
557
+ if (plan.error) {
558
+ console.error(plan.error);
559
+ process.exit(1);
560
+ }
561
+ if (plan.note) console.error(plan.note);
562
+ console.log(plan.sign ? 'developer-id' : 'ad-hoc');
563
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Render scripts/dmg/background.html into scripts/dmg/background.tiff — the
3
- // picture behind the .dmg window: drag across, then the one-time Open Anyway step.
3
+ // picture behind the .dmg window: drag across, and that the app is signed and
4
+ // notarized, so it opens like any Mac app (it showed the Open Anyway step until D4).
4
5
  //
5
6
  // Run by hand when the picture changes, and commit the .tiff. The build only
6
7
  // copies it, so packaging never depends on a browser being installed (the
@@ -0,0 +1,96 @@
1
+ #!/bin/bash
2
+ # Put the Mac signing secrets into GitHub, for the release workflow (DESKTOP.md D4).
3
+ # Blake runs this himself, on his own Mac:
4
+ #
5
+ # scripts/set-signing-secrets.sh <cert.p12> <AuthKey_XXXXXXXXXX.p8> <KEY_ID> <ISSUER_ID>
6
+ #
7
+ # cert.p12 the Developer ID Application certificate with its private key
8
+ # (Keychain Access → My Certificates → Export), Apple's intermediate
9
+ # in it too
10
+ # .p8 the App Store Connect API key notarytool uses (Users and Access →
11
+ # Integrations → Team Keys); Apple lets you download it once
12
+ # KEY_ID that key's ID, 10 letters and digits (it's in the .p8's name)
13
+ # ISSUER_ID the issuer ID shown above the keys list (a UUID)
14
+ #
15
+ # It asks for the .p12's password (typed, not shown), checks that it opens the
16
+ # certificate, and sets five secrets on blakeb056/six-degrees with the GitHub CLI:
17
+ # MACOS_SIGN_P12_BASE64, MACOS_SIGN_P12_PASSWORD, APPLE_API_KEY_P8_BASE64,
18
+ # APPLE_API_KEY_ID and APPLE_API_ISSUER_ID. Each value goes to `gh secret set`
19
+ # on its standard input, never as an argument (where `ps` could see it), and is
20
+ # never printed or written to a file: it prints only which secrets it set.
21
+ # Running it again replaces them (a renewed certificate, a new key).
22
+
23
+ set -euo pipefail
24
+
25
+ REPO=blakeb056/six-degrees
26
+ OPENSSL=/usr/bin/openssl # macOS's own (LibreSSL): it reads Keychain Access's .p12 files
27
+
28
+ say() { printf '%s\n' "$*"; }
29
+ fail() { printf '\n ✗ %s\n\n' "$*" >&2; exit 1; }
30
+ usage() {
31
+ printf 'usage: %s <cert.p12> <AuthKey_XXXXXXXXXX.p8> <KEY_ID> <ISSUER_ID>\n' "$(basename "$0")" >&2
32
+ exit 2
33
+ }
34
+
35
+ [ $# -eq 4 ] || usage
36
+ p12="$1" p8="$2" key_id="$3" issuer="$4"
37
+
38
+ # The files first: nothing is asked or sent unless both are there.
39
+ for f in "$p12" "$p8"; do
40
+ [ -f "$f" ] || fail "No file at $f. Nothing was set."
41
+ [ -r "$f" ] || fail "Can't read $f. Nothing was set."
42
+ [ -s "$f" ] || fail "$f is empty. Nothing was set."
43
+ done
44
+ case "$p12" in *.p12|*.pfx) ;; *) fail "The first file should be the certificate's .p12 (got $(basename "$p12")). Nothing was set." ;; esac
45
+ case "$p8" in *.p8) ;; *) fail "The second file should be the API key's .p8 (got $(basename "$p8")). Nothing was set." ;; esac
46
+ grep -q -- '-----BEGIN PRIVATE KEY-----' "$p8" || fail "$(basename "$p8") isn't an App Store Connect API key. Nothing was set."
47
+
48
+ # The two IDs, checked by their shape, never echoed.
49
+ [[ "$key_id" =~ ^[A-Z0-9]{10}$ ]] || fail "KEY_ID should be 10 capital letters and digits (it's in the .p8's name: AuthKey_<KEY_ID>.p8). Nothing was set."
50
+ if [[ "$(basename "$p8")" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]] && [ "${BASH_REMATCH[1]}" != "$key_id" ]; then
51
+ fail "KEY_ID doesn't match the .p8's name ($(basename "$p8")): use that key's ID. Nothing was set."
52
+ fi
53
+ [[ "$issuer" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] \
54
+ || fail "ISSUER_ID should be a UUID (8-4-4-4-12 letters and digits, above the keys list in App Store Connect). Nothing was set."
55
+
56
+ # GitHub, before the password is asked for.
57
+ command -v gh > /dev/null 2>&1 || fail "The GitHub CLI isn't installed (brew install gh). Nothing was set."
58
+ gh auth status > /dev/null 2>&1 || fail "The GitHub CLI isn't signed in: run gh auth login, then this again. Nothing was set."
59
+
60
+ # The password: typed, not shown, kept only in this shell's memory.
61
+ password=""
62
+ if [ -t 0 ]; then
63
+ read -r -s -p "Password for $(basename "$p12"): " password
64
+ printf '\n'
65
+ else
66
+ IFS= read -r password || true
67
+ fi
68
+ [ -n "$password" ] || fail "No password given. Nothing was set."
69
+
70
+ # Does it open the certificate? printf is part of bash, so the password is never
71
+ # a program's argument; openssl reads it from its standard input.
72
+ if ! printf '%s\n' "$password" | "$OPENSSL" pkcs12 -in "$p12" -nokeys -passin stdin -noout > /dev/null 2>&1; then
73
+ answer=""
74
+ if [ -t 0 ]; then
75
+ read -r -p "That password didn't open $(basename "$p12") (or this Mac's openssl can't read it). Set the secrets anyway? [y/N] " answer
76
+ else
77
+ IFS= read -r answer || true
78
+ fi
79
+ case "$answer" in y|Y|yes) ;; *) password=""; fail "Nothing was set." ;; esac
80
+ fi
81
+
82
+ # Each value on gh's standard input. base64 without line breaks, as the release
83
+ # workflow decodes it. (Each `gh secret set` is the last command of its pipe and
84
+ # reads it to the end.)
85
+ done_names=""
86
+ ok() { done_names="${done_names:+$done_names, }$1"; say " set $1"; }
87
+ stop() { password=""; fail "gh couldn't set $1 (does your account have admin rights on $REPO?). Set before it: ${done_names:-none}."; }
88
+ say "Setting the Mac signing secrets on $REPO:"
89
+ if base64 < "$p12" | tr -d '\n' | gh secret set MACOS_SIGN_P12_BASE64 --repo "$REPO" > /dev/null; then ok MACOS_SIGN_P12_BASE64; else stop MACOS_SIGN_P12_BASE64; fi
90
+ if printf '%s' "$password" | gh secret set MACOS_SIGN_P12_PASSWORD --repo "$REPO" > /dev/null; then ok MACOS_SIGN_P12_PASSWORD; else stop MACOS_SIGN_P12_PASSWORD; fi
91
+ password=""
92
+ if base64 < "$p8" | tr -d '\n' | gh secret set APPLE_API_KEY_P8_BASE64 --repo "$REPO" > /dev/null; then ok APPLE_API_KEY_P8_BASE64; else stop APPLE_API_KEY_P8_BASE64; fi
93
+ if printf '%s' "$key_id" | gh secret set APPLE_API_KEY_ID --repo "$REPO" > /dev/null; then ok APPLE_API_KEY_ID; else stop APPLE_API_KEY_ID; fi
94
+ if printf '%s' "$issuer" | gh secret set APPLE_API_ISSUER_ID --repo "$REPO" > /dev/null; then ok APPLE_API_ISSUER_ID; else stop APPLE_API_ISSUER_ID; fi
95
+ say "Done: all five are set. Check them with a dry run (it builds and notarizes, and publishes nothing):"
96
+ say " gh workflow run release.yml --repo $REPO --ref <branch> -f dry_run=true"