@hasna/recordings 0.3.13 → 0.5.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 (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Describe reviewed npm bytes; registry verification is an explicit read-only step."""
3
+ import argparse
4
+ import base64
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import pathlib
9
+ import re
10
+ import subprocess
11
+ import tarfile
12
+ import urllib.request
13
+ from datetime import datetime, timezone
14
+
15
+ PACKAGE = '@hasna/recordings'
16
+ CORE = 'src/native/Recordings/'
17
+ REGISTRY = 'https://registry.npmjs.org/'
18
+ LIMIT = 128 * 1024 * 1024
19
+
20
+
21
+ def digest(data):
22
+ return hashlib.sha256(data).hexdigest()
23
+
24
+
25
+ def read_archive(blob):
26
+ if not blob or len(blob) > LIMIT:
27
+ raise ValueError('Archive size is outside the allowed range')
28
+ files = {}
29
+ total = 0
30
+ seen = set()
31
+ with tarfile.open(fileobj=io.BytesIO(blob), mode='r:*') as archive:
32
+ for member in archive:
33
+ parts = member.name.rstrip('/').split('/')
34
+ if (len(seen) >= 20000 or member.name in seen or len(member.name) > 1024
35
+ or parts[0] != 'package' or any(p in ('', '.', '..') for p in parts)
36
+ or '\\' in member.name or any(ord(c) < 32 for c in member.name)
37
+ or not (member.isfile() or member.isdir())):
38
+ raise ValueError('Archive contains an unsafe or duplicate entry')
39
+ seen.add(member.name)
40
+ if member.isdir():
41
+ continue
42
+ total += member.size
43
+ if member.size > LIMIT or total > LIMIT * 2:
44
+ raise ValueError('Expanded archive is too large')
45
+ handle = archive.extractfile(member)
46
+ if handle is None:
47
+ raise ValueError('Archive member cannot be read')
48
+ data = handle.read(member.size + 1)
49
+ if len(data) != member.size:
50
+ raise ValueError('Archive member is truncated')
51
+ files['/'.join(parts[1:])] = (data, member.mode & 0o777)
52
+ return files
53
+
54
+
55
+ def create_receipt(blob, revision):
56
+ if not re.fullmatch('[a-f0-9]{40}', revision):
57
+ raise ValueError('A full public source revision is required')
58
+ files = read_archive(blob)
59
+ try:
60
+ package = json.loads(files['package.json'][0])
61
+ version = package['version']
62
+ if package['name'] != PACKAGE or not re.fullmatch(r'\d+\.\d+\.\d+', version):
63
+ raise ValueError('Unexpected package identity')
64
+ manifest = files[CORE + 'Package.swift'][0].decode()
65
+ for required in ('Package.resolved', 'RecordingsLib/RecordingEngine.swift', 'RecordingsLib/RecordingProvider.swift'):
66
+ if CORE + required not in files:
67
+ raise ValueError('Required native source is missing')
68
+ if (not re.search(r'\.library\(\s*name:\s*"RecordingsLib"\s*,\s*targets:\s*\["RecordingsLib"\]', manifest)
69
+ or not re.search(r'swift-tools-version:\s*6\.2\b', manifest)
70
+ or not re.search(r'\.macOS\(\s*\.v26\s*\)', manifest)):
71
+ raise ValueError('Required Swift library or platform declaration is missing')
72
+ except (KeyError, TypeError, UnicodeError, json.JSONDecodeError) as error:
73
+ raise ValueError('Invalid native package metadata') from error
74
+ native = [{'path': path[len(CORE):], 'sha256': digest(data), 'bytes': len(data), 'mode': mode}
75
+ for path, (data, mode) in sorted(files.items()) if path.startswith(CORE)]
76
+ tree = ''.join(f"{f['path']}\0{f['mode']:o}\0{f['bytes']}\0{f['sha256']}\n" for f in native).encode()
77
+ return {
78
+ 'schemaVersion': 1, 'kind': 'hasna.recordings.native-core',
79
+ 'package': {'name': PACKAGE, 'version': version},
80
+ 'source': {'repository': 'https://github.com/hasna/apps', 'revision': revision},
81
+ 'distribution': {'status': 'prepared'},
82
+ 'archive': {'url': REGISTRY + '@hasna/recordings/-/recordings-' + version + '.tgz',
83
+ 'bytes': len(blob), 'sha256': digest(blob),
84
+ 'integrity': 'sha512-' + base64.b64encode(hashlib.sha512(blob).digest()).decode()},
85
+ 'native': {'packagePath': CORE.rstrip('/'), 'product': 'RecordingsLib',
86
+ 'swiftToolsVersion': '6.2', 'minimumMacOS': '26.0',
87
+ 'treeSHA256': digest(tree), 'files': native},
88
+ }
89
+
90
+
91
+ class NoRedirects(urllib.request.HTTPRedirectHandler):
92
+ def redirect_request(self, *args, **kwargs):
93
+ raise ValueError('Registry redirects are not accepted')
94
+
95
+
96
+ def registry_bytes(url):
97
+ if not url.startswith(REGISTRY):
98
+ raise ValueError('Unexpected registry authority')
99
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects())
100
+ with opener.open(url, timeout=30) as response:
101
+ data = response.read(LIMIT + 1)
102
+ if len(data) > LIMIT:
103
+ raise ValueError('Registry response is too large')
104
+ return data
105
+
106
+
107
+ def verify_registry(receipt, fetch=registry_bytes):
108
+ version = receipt['package']['version']
109
+ metadata = json.loads(fetch(REGISTRY + '@hasna%2Frecordings/' + version))
110
+ if (metadata.get('name') != PACKAGE or metadata.get('version') != version
111
+ or metadata.get('dist', {}).get('tarball') != receipt['archive']['url']
112
+ or metadata.get('dist', {}).get('integrity') != receipt['archive']['integrity']):
113
+ raise ValueError('Registry metadata differs from the reviewed archive')
114
+ remote = fetch(receipt['archive']['url'])
115
+ if len(remote) != receipt['archive']['bytes'] or digest(remote) != receipt['archive']['sha256']:
116
+ raise ValueError('Registry archive differs from the reviewed bytes')
117
+ receipt['distribution'] = {'status': 'published', 'registry': REGISTRY,
118
+ 'verifiedAt': datetime.now(timezone.utc).isoformat()}
119
+
120
+
121
+ def verify_source(receipt, repository):
122
+ revision = receipt['source']['revision']
123
+ prefix = 'apps/recordings/' + CORE
124
+ result = subprocess.run(['git', '-c', 'tar.umask=0022', 'archive', '--format=tar', revision, '--', prefix, 'apps/recordings/package.json'],
125
+ cwd=repository, check=True, capture_output=True, timeout=30)
126
+ with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive:
127
+ package = archive.extractfile('apps/recordings/package.json')
128
+ metadata = json.load(package) if package else {}
129
+ if metadata.get('name') != PACKAGE or metadata.get('version') != receipt['package']['version']:
130
+ raise ValueError('Package version differs from the public source revision')
131
+ for expected in receipt['native']['files']:
132
+ try:
133
+ member = archive.getmember(prefix + expected['path'])
134
+ handle = archive.extractfile(member)
135
+ data = handle.read() if handle else b''
136
+ except KeyError as error:
137
+ raise ValueError('Native archive member is absent from the source revision') from error
138
+ if (not member.isfile() or len(data) != expected['bytes'] or digest(data) != expected['sha256']
139
+ or member.mode & 0o777 != expected['mode']):
140
+ raise ValueError('Native archive bytes differ from the public source revision')
141
+
142
+
143
+ def main():
144
+ parser = argparse.ArgumentParser(description=__doc__)
145
+ parser.add_argument('archive', type=pathlib.Path)
146
+ parser.add_argument('--source-revision', required=True)
147
+ parser.add_argument('--repository', type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[3])
148
+ parser.add_argument('--output', required=True, type=pathlib.Path)
149
+ parser.add_argument('--verify-registry', action='store_true', help='Compare public registry bytes; does not publish')
150
+ args = parser.parse_args()
151
+ if args.archive.is_symlink() or not args.archive.is_file() or args.archive.stat().st_size > LIMIT:
152
+ raise ValueError('Expected a regular bounded npm archive')
153
+ blob = args.archive.read_bytes()
154
+ receipt = create_receipt(blob, args.source_revision)
155
+ verify_source(receipt, args.repository)
156
+ if args.verify_registry:
157
+ verify_registry(receipt)
158
+ # Refuse replacement: a new verification writes a separate reviewable receipt.
159
+ with args.output.open('x') as output:
160
+ json.dump(receipt, output, indent=2)
161
+ output.write('\n')
162
+ print(json.dumps({'status': receipt['distribution']['status'], 'package': receipt['package'],
163
+ 'archiveSHA256': receipt['archive']['sha256'], 'nativeTreeSHA256': receipt['native']['treeSHA256']}))
164
+
165
+
166
+ if __name__ == '__main__':
167
+ try:
168
+ main()
169
+ except Exception:
170
+ # Exception messages can contain local paths or registry/credential diagnostics.
171
+ raise SystemExit('Native core receipt verification failed; no published receipt was produced.')
@@ -16,6 +16,7 @@ export type NativeMetadata = {
16
16
  export type NativeHandle = object;
17
17
 
18
18
  export type NativeFsGuard = {
19
+ duplicateDirectoryDescriptor(descriptor: number): NativeHandle;
19
20
  openTrustedHome(path: string, uid: number): NativeHandle;
20
21
  openDirAt(parent: NativeHandle, leaf: string): NativeHandle;
21
22
  openRegularAt(
@@ -122,6 +123,7 @@ export function nativeFsGuard(): NativeFsGuard {
122
123
  }
123
124
  const loaded = createRequire(import.meta.url)(path) as Partial<NativeFsGuard>;
124
125
  for (const name of [
126
+ "duplicateDirectoryDescriptor",
125
127
  "openTrustedHome",
126
128
  "openDirAt",
127
129
  "openRegularAt",
@@ -1,174 +1,251 @@
1
1
  #!/usr/bin/env bun
2
- /**
3
- * Release-suite gate (wired into `prepublishOnly`).
4
- *
5
- * Runs the app's own Linux-CI-partitioned suite — `test:gated`, which covers
6
- * every discovered `*.test.ts` (the quarantine file is empty by design) — and
7
- * fails on ANY failure.
8
- *
9
- * The allowlist is SELF-INVALIDATING, the same property as
10
- * `.github/linux-quarantine.txt`: if an allowlisted failure stops failing
11
- * (the owning lane lands its fix, or a quarantined package ages out of the
12
- * 7-day window), the gate exits 1 and demands the entry be removed. Any
13
- * failure outside the allowlist fails the gate.
14
- *
15
- * Allowlist history (both entries removed 2026-08-23 by the 0.3.9 release
16
- * lane, publish-all-recordings, per this gate's own self-invalidation
17
- * contract — each failure stopped failing at head 15b6181c6):
18
- * - service-contract-manifest.test.ts (credential_seam_compliance): the
19
- * contracts-surface migration landed in hasna/apps PR #957
20
- * (resolveStorageClient now imported from @hasna/contracts/client), so
21
- * the conformance check passes; the entry's own reason said "Remove this
22
- * entry when the lane lands."
23
- * - native-app-companion-contract.test.ts (release supply-chain policy):
24
- * the companion build's --minimum-release-age=604800 quarantine no longer
25
- * bites because @hasna/contracts is on the fleet minimumReleaseAgeExcludes
26
- * list (the sanctioned mechanism for fresh publishes); the test passes.
27
- * Both removals are the gate's designed behaviour, not a weakening: any
28
- * future failure now fails the gate with zero allowlisted exceptions.
29
- *
30
- * The gate FAILS CLOSED on abnormal termination: a non-zero suite exit is
31
- * acceptable only when it is fully explained by allowlisted failures — the
32
- * runner completed (its summary line is present), every parsed failure is
33
- * allowlisted, at least one allowlisted failure fired, and the runner's
34
- * reported failure count equals the parsed count. A crash, signal kill,
35
- * timeout, or runner error that produces a non-zero exit without a complete
36
- * summary fails the gate (remediation cycle 2 of PR hasna/apps#826).
2
+ /** Run every selected manifest test once, using each Darwin fixture's required boundary.
3
+ * There are no allowed failures. Missing/empty reports, crashes (including exit-zero
4
+ * early exits), unexpected files and overlapping or incomplete selections fail closed.
37
5
  */
38
6
  import { spawnSync } from "node:child_process";
39
- import { mkdtempSync, writeFileSync } from "node:fs";
7
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
40
8
  import { tmpdir } from "node:os";
41
9
  import { join } from "node:path";
42
10
 
43
- const SEP = "::";
44
-
45
- const GATED_KEYS: Array<{ file: string; test: string; owner: string; reason: string }> = [];
46
-
47
- function run(argv: string[], env: Record<string, string>): { status: number; stdout: string; stderr: string } {
48
- const res = spawnSync(process.execPath, argv, { encoding: "utf8", env: { ...process.env, ...env } });
49
- return { status: res.status ?? -1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
11
+ export interface SuiteGroup {
12
+ id: string;
13
+ files: string[];
14
+ runner: "bun" | "publication" | "recorder";
15
+ pattern?: string;
50
16
  }
51
-
52
- // 1. Validate the partition (quarantine file must be consistent).
53
- const check = run(["scripts/ci-linux-suite.ts", "--check"], {});
54
- if (check.status !== 0) {
55
- console.error("release-suite-gate: partition check failed:\n" + (check.stderr || check.stdout));
56
- process.exit(1);
17
+ export interface GroupReport {
18
+ id: string;
19
+ status: number | null;
20
+ signal?: string | null;
21
+ error?: string;
22
+ output: string;
23
+ junit: string;
57
24
  }
25
+ const LIFECYCLE = "src/__tests__/macos-app-lifecycle.test.ts";
26
+ const PUBLICATION = "src/__tests__/release-output-publication-contract.test.ts";
27
+ const RECORDER = "src/__tests__/recorder.test.ts";
28
+ const PUBLICATION_NAMES = "(?:macOS finalized artifact installer|release output publication contract)(?:\\s|$)";
58
29
 
59
- // 2. Collect the gated file list.
60
- const gated = run(["scripts/ci-linux-suite.ts", "--gated"], {});
61
- if (gated.status !== 0) {
62
- console.error("release-suite-gate: could not enumerate gated files:\n" + (gated.stderr || gated.stdout));
63
- process.exit(1);
30
+ export function planReleaseSuite(files: string[], platform: string): SuiteGroup[] {
31
+ if (!files.length || new Set(files).size !== files.length || files.some(f => !/^src\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_.-]+\.test\.ts$/.test(f))) {
32
+ throw new Error("empty, duplicate or unsafe manifest-gated files");
33
+ }
34
+ const recorder: SuiteGroup[] = files.includes(RECORDER) ? [{ id: "recorder", files: [RECORDER], runner: "recorder" }] : [];
35
+ const remaining = files.filter(f => f !== RECORDER);
36
+ if (platform !== "darwin") return [...recorder, ...(remaining.length ? [{ id: "ordinary", files: remaining, runner: "bun" as const }] : [])];
37
+ for (const file of [LIFECYCLE, PUBLICATION, RECORDER]) if (!files.includes(file)) throw new Error(`required Darwin fixture file missing: ${file}`);
38
+ const ordinary = remaining.filter(f => f !== LIFECYCLE && f !== PUBLICATION);
39
+ if (!ordinary.length) throw new Error("empty ordinary group");
40
+ return [
41
+ ...recorder,
42
+ { id: "publication", files: [LIFECYCLE, PUBLICATION], runner: "publication", pattern: `^${PUBLICATION_NAMES}` },
43
+ { id: "lifecycle", files: [LIFECYCLE], runner: "bun", pattern: `^(?!${PUBLICATION_NAMES})` },
44
+ { id: "ordinary", files: ordinary, runner: "bun" },
45
+ ];
64
46
  }
65
- const gatedFiles = gated.stdout.split("\n").filter(Boolean);
66
- if (gatedFiles.length === 0) {
67
- console.error("release-suite-gate: zero gated files - refusing to pass on an empty suite.");
68
- process.exit(1);
47
+ interface TestCase { key: string; file: string; name: string; skipped: boolean }
48
+ function fail(message: string): never { throw new Error(message); }
49
+ function entity(text: string): string {
50
+ return text.replace(/&([^;]+);/g, (_, value: string) => {
51
+ const entities: Record<string, string> = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" };
52
+ if (value in entities) return entities[value]!;
53
+ const number = /^#x[0-9a-f]+$/i.test(value) ? parseInt(value.slice(2), 16) : /^#[0-9]+$/.test(value) ? Number(value.slice(1)) : NaN;
54
+ if (!Number.isInteger(number) || number <= 0 || number > 0x10ffff) fail("invalid JUnit entity");
55
+ return String.fromCodePoint(number);
56
+ });
69
57
  }
70
-
71
- // 3. Run the gated suite exactly like `test:gated`.
72
- const logDir = mkdtempSync(join(tmpdir(), "recordings-release-gate-"));
73
- const logPath = join(logDir, "suite.log");
74
- const suite = spawnSync(
75
- process.execPath,
76
- ["test", "--timeout", "120000", ...gatedFiles],
77
- {
78
- encoding: "utf8",
79
- env: { ...process.env, RECORDINGS_TEST_TIMEOUT_MS: "120000" },
80
- maxBuffer: 64 * 1024 * 1024,
81
- },
82
- );
83
- const output = (suite.stdout ?? "") + (suite.stderr ?? "");
84
- try {
85
- writeFileSync(logPath, output);
86
- } catch {
87
- /* log capture is best-effort */
58
+ function count(value: string | undefined): number {
59
+ if (!value || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) fail("missing or invalid JUnit count");
60
+ return Number(value);
88
61
  }
89
- const suiteStatus = suite.status ?? -1;
90
-
91
- // 4. Parse failing tests as (file, description) pairs, in output order.
92
- const failPairs: Array<{ file: string; test: string }> = [];
93
- let currentFile = "";
94
- for (const line of output.split("\n")) {
95
- const fileMatch = /^(?:\.\/)?(src\/[^\s:]+\.test\.ts):$/.exec(line);
96
- if (fileMatch) {
97
- currentFile = fileMatch[1];
98
- continue;
99
- }
100
- const failMatch = /^\(fail\) (.+) \[\d+.*\]$/.exec(line);
101
- if (failMatch) {
102
- failPairs.push({ file: currentFile, test: failMatch[1].trim() });
62
+ /** Strictly parse the small XML vocabulary emitted by Bun's JUnit reporter. Unlike
63
+ * console output, it includes filtered tests as skipped, providing a registration
64
+ * inventory without executing the same tests again in a discovery pass.
65
+ */
66
+ function casesFromJUnit(xml: string): TestCase[] {
67
+ const stack: Array<{ tag: string; attrs: Record<string, string> }> = [];
68
+ const cases: TestCase[] = [];
69
+ const occurrences = new Map<string, number>();
70
+ let root: Record<string, string> | undefined;
71
+ let current: TestCase | undefined;
72
+ let closed = false;
73
+ const tokens = /<\?xml[^?]*\?>|<\/?[a-zA-Z]+(?:\s+[a-zA-Z]+="[^"]*")*\s*\/?>|[^<]+/gy;
74
+ let offset = 0;
75
+ while (offset < xml.length) {
76
+ tokens.lastIndex = offset;
77
+ const match = tokens.exec(xml);
78
+ if (!match || match.index !== offset) fail("malformed or truncated JUnit XML");
79
+ const token = match[0]; offset = tokens.lastIndex;
80
+ if (token.startsWith("<?xml")) { if (root) fail("misplaced JUnit declaration"); continue; }
81
+ if (!token.startsWith("<")) { if (token.trim()) fail("unexpected JUnit text"); continue; }
82
+ const tag = /^<\/?([a-zA-Z]+)/.exec(token)![1]!;
83
+ if (token.startsWith("</")) {
84
+ if (!/^<\/[a-zA-Z]+\s*>$/.test(token) || stack.pop()?.tag !== tag) fail("unbalanced JUnit XML");
85
+ if (tag === "testcase") current = undefined;
86
+ if (tag === "testsuites") closed = true;
87
+ continue;
88
+ }
89
+ if (closed) fail("data after JUnit completion");
90
+ const attrs: Record<string, string> = {};
91
+ for (const attr of token.matchAll(/\s+([a-zA-Z]+)="([^"]*)"/g)) {
92
+ if (attr[1]! in attrs) fail("duplicate JUnit attribute");
93
+ attrs[attr[1]!] = entity(attr[2]!);
94
+ }
95
+ const parent = stack.at(-1)?.tag;
96
+ if (tag === "testsuites") {
97
+ if (root || parent) fail("duplicate JUnit root");
98
+ root = attrs;
99
+ } else if (tag === "testsuite") {
100
+ if (parent !== "testsuites" && parent !== "testsuite") fail("misplaced JUnit suite");
101
+ } else if (tag === "testcase") {
102
+ if (parent !== "testsuite" || !attrs.file || attrs.name === undefined) fail("invalid JUnit testcase");
103
+ const names = stack.filter(s => s.tag === "testsuite").slice(1).map(s => s.attrs.name ?? fail("unnamed JUnit suite"));
104
+ const file = attrs.file.replace(/^\.\//, "");
105
+ const identity = JSON.stringify([file, names, attrs.name, attrs.line ?? ""]);
106
+ const instance = occurrences.get(identity) ?? 0; occurrences.set(identity, instance + 1);
107
+ current = { file, name: [...names, attrs.name].join(" "), key: `${identity}:${instance}`, skipped: false };
108
+ cases.push(current);
109
+ } else if (tag === "skipped") {
110
+ if (parent !== "testcase" || !current || current.skipped) fail("misplaced or duplicate JUnit skip");
111
+ current.skipped = true;
112
+ } else fail(`unexpected JUnit element: ${tag}`);
113
+ if (!token.endsWith("/>")) stack.push({ tag, attrs });
114
+ else if (tag === "testcase") current = undefined;
115
+ else if (tag === "testsuites") closed = true;
103
116
  }
117
+ if (!root || !closed || stack.length || !cases.length) fail("missing, empty or incomplete JUnit report");
118
+ if (count(root.tests) !== cases.length || count(root.failures) !== 0 || count(root.skipped) !== cases.filter(c => c.skipped).length || (root.errors !== undefined && count(root.errors) !== 0)) fail("JUnit totals do not explain the complete run");
119
+ return cases;
104
120
  }
121
+ const sorted = (values: Iterable<string>) => [...values].sort();
122
+ const equal = (a: string[], b: string[]) => JSON.stringify(a) === JSON.stringify(b);
105
123
 
106
- // 5. Classify.
107
- const failuresByKey = new Map<string, number>();
108
- for (const key of GATED_KEYS) {
109
- failuresByKey.set(key.file + SEP + key.test, 0);
110
- }
111
- const unexpected: Array<{ file: string; test: string }> = [];
112
- for (const pair of failPairs) {
113
- const key = pair.file + SEP + pair.test;
114
- if (failuresByKey.has(key)) {
115
- failuresByKey.set(key, (failuresByKey.get(key) ?? 0) + 1);
116
- } else {
117
- unexpected.push(pair);
124
+ export function validateReleaseRun(groups: SuiteGroup[], reports: GroupReport[]): { passed: number; skipped: number; tests: number; files: number } {
125
+ if (!groups.length || groups.some(g => !g.files.length) || new Set(groups.map(g => g.id)).size !== groups.length || reports.length !== groups.length || new Set(reports.map(r => r.id)).size !== reports.length) fail("missing, repeated or empty release group");
126
+ const inventories = new Map<string, string[]>();
127
+ const owned = new Map<string, number>();
128
+ let passed = 0, skipped = 0;
129
+ for (const group of groups) {
130
+ const report = reports.find(r => r.id === group.id) ?? fail(`missing report: ${group.id}`);
131
+ if (report.status !== 0 || report.signal || report.error) fail(`${group.id}: runner exited abnormally (${report.error ?? report.signal ?? report.status})`);
132
+ const cases = casesFromJUnit(report.junit);
133
+ if (!equal(sorted(new Set(cases.map(c => c.file))), sorted(group.files))) fail(`${group.id}: executed files differ from the planned group`);
134
+ const selector = group.pattern ? new RegExp(group.pattern) : undefined;
135
+ const selected = cases.filter(c => !selector || selector.test(c.name));
136
+ if (!selected.length || group.files.some(f => !selected.some(c => c.file === f))) fail(`${group.id}: empty selected file or group`);
137
+ if (cases.some(c => selector && !selector.test(c.name) && !c.skipped)) fail(`${group.id}: executed a test owned by another group`);
138
+ for (const file of group.files) {
139
+ const inventory = sorted(cases.filter(c => c.file === file).map(c => c.key));
140
+ const previous = inventories.get(file);
141
+ if (previous && !equal(previous, inventory)) fail(`${group.id}: shared file registration inventory changed: ${file}`);
142
+ inventories.set(file, inventory);
143
+ }
144
+ const selectedSkips = selected.filter(c => c.skipped).length;
145
+ const tally = (kind: string) => {
146
+ const values = [...report.output.matchAll(new RegExp(`^\\s*(\\d+) ${kind}\\s*$`, "gm"))];
147
+ return values.length === 1 ? Number(values[0]![1]) : undefined;
148
+ };
149
+ const summaries = [...report.output.matchAll(/^Ran (\d+) tests? across (\d+) files?\./gm)];
150
+ if (summaries.length !== 1 || Number(summaries[0]![1]) !== selected.length || Number(summaries[0]![2]) !== group.files.length || tally("pass") !== selected.length - selectedSkips || (tally("skip") ?? 0) !== selectedSkips || tally("fail") !== 0 || /^\s*[1-9]\d* errors?\s*$/m.test(report.output)) fail(`${group.id}: console completion does not match selected JUnit tests`);
151
+ for (const c of selected) owned.set(c.key, (owned.get(c.key) ?? 0) + 1);
152
+ passed += selected.length - selectedSkips; skipped += selectedSkips;
118
153
  }
154
+ for (const inventory of inventories.values()) for (const key of inventory) if (owned.get(key) !== 1) fail("release test must belong to exactly one completed group");
155
+ return { passed, skipped, tests: passed + skipped, files: inventories.size };
119
156
  }
120
157
 
121
- if (unexpected.length > 0) {
122
- console.error(
123
- "release-suite-gate: FAIL - unexpected test failures:\n" +
124
- unexpected.map((u) => " " + u.file + " :: " + u.test).join("\n"),
125
- );
126
- console.error("Full suite log: " + logPath);
127
- process.exit(1);
158
+ /** Credentials and account/storage selectors never enter a release test process.
159
+ * This is supplemental isolation; Darwin host tools still require the audited OS
160
+ * fixture boundaries (changing HOME cannot isolate defaults, Keychain or TCC).
161
+ */
162
+ export function groupEnvironment(root: string, inherited: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
163
+ for (const directory of ["home", "tmp", "bin"]) mkdirSync(join(root, directory), { mode: 0o700 });
164
+ // Shell children must use this exact runner even when the caller's PATH has a
165
+ // different Bun version. No global bin or existing symlink is modified.
166
+ symlinkSync(process.execPath, join(root, "bin", "bun"));
167
+ return {
168
+ HOME: join(root, "home"), TMPDIR: join(root, "tmp") + "/",
169
+ PATH: `${join(root, "bin")}:${inherited.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"}`,
170
+ XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"),
171
+ XDG_DATA_HOME: join(root, "data"), NPM_CONFIG_CACHE: join(root, "npm-cache"),
172
+ BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(root, "bun-cache"),
173
+ RECORDINGS_TEST_TIMEOUT_MS: "120000", NO_COLOR: "1", TERM: "dumb",
174
+ ...(inherited.CI ? { CI: inherited.CI } : {}),
175
+ ...(inherited.LANG ? { LANG: inherited.LANG } : {}),
176
+ };
128
177
  }
129
178
 
130
- // 6. Fail closed on abnormal termination. A non-zero suite exit is only
131
- // acceptable when it is fully explained by the allowlisted failures: the
132
- // runner completed (summary line present), every parsed failure is
133
- // allowlisted, at least one allowlisted failure fired, and the runner's
134
- // reported failure count equals the parsed count (no unparsed failures).
135
- // A crash, signal kill, timeout, or runner error produces a non-zero exit
136
- // without a complete summary and must fail the gate.
137
- if (suiteStatus !== 0) {
138
- const summaryMatch = /Ran (\d+) tests across (\d+) files/.exec(output);
139
- // The tally is "N pass[,/ ] M fail" in the runner's closing summary. The
140
- // LAST match is taken (bun prints the tally once, but a naive first match
141
- // can hit "0.13.3 failed to resolve" -> "3 fail" inside a version string).
142
- const tallyMatches = [...output.matchAll(/(\d+) pass[\s,/]+(\d+) fail/g)];
143
- const failCountMatch = tallyMatches.length > 0 ? tallyMatches[tallyMatches.length - 1] : null;
144
- const reportedFails = failCountMatch ? Number(failCountMatch[2]) : null;
145
- const anyAllowlistedFired = failPairs.length > 0;
146
- const complete = summaryMatch !== null && reportedFails !== null && reportedFails === failPairs.length;
147
- if (!anyAllowlistedFired || !complete) {
148
- console.error(
149
- "release-suite-gate: FAIL - suite exited " + suiteStatus +
150
- " with " + failPairs.length + " parsed failure(s) but the run is incomplete or unexplained:" +
151
- "\n summary line present: " + (summaryMatch !== null) +
152
- "\n reported failure count: " + String(reportedFails) +
153
- "\n parsed failure count: " + failPairs.length,
154
- );
155
- console.error("Full suite log: " + logPath);
156
- process.exit(1);
157
- }
179
+ export function groupCommand(group: SuiteGroup, junitPath: string): string[] {
180
+ return group.runner === "publication"
181
+ ? ["/usr/bin/python3", "-I", "-B", "src/__tests__/helpers/run-publication-fixtures.py", "--bun", process.execPath, "--report-outfile", junitPath]
182
+ : group.runner === "recorder"
183
+ ? [process.execPath, "scripts/test-recorder-fixtures.ts", "--junit-output", junitPath]
184
+ : [process.execPath, "test", "--no-orphans", "--timeout", "120000", ...group.files.map(f => `./${f}`), ...(group.pattern ? ["--test-name-pattern", group.pattern] : []), "--reporter=junit", "--reporter-outfile", junitPath];
158
185
  }
159
186
 
160
- // 7. Self-invalidation: an allowlisted failure that no longer fails must be removed.
161
- const stale = GATED_KEYS.filter((key) => (failuresByKey.get(key.file + SEP + key.test) ?? 0) === 0);
162
- if (stale.length > 0) {
163
- console.error(
164
- "release-suite-gate: FAIL - allowlisted failure no longer fails; remove the entry from scripts/release-suite-gate.ts:\n" +
165
- stale.map((s) => " " + s.file + " :: " + s.test + " - " + s.owner).join("\n"),
166
- );
167
- process.exit(1);
187
+ /** Keep the first failing assertions and the runner summary visible in CI even
188
+ * when a disposable runner's complete logs cannot be recovered. Prefix every
189
+ * line so fixture output cannot be interpreted as a workflow command.
190
+ */
191
+ export function failureDiagnostics(report: GroupReport): string {
192
+ const lines = report.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").split("\n");
193
+ const selected = new Set<number>();
194
+ let failures = 0;
195
+ for (let index = 0; index < lines.length && failures < 8; index++) {
196
+ if (!/^\s*(?:\(fail\)|error:|Error:|AssertionError:|TimeoutError:)/.test(lines[index]!)) continue;
197
+ failures++;
198
+ for (let context = Math.max(0, index - 12); context <= Math.min(lines.length - 1, index + 6); context++) selected.add(context);
199
+ }
200
+ const tailStart = Math.max(0, lines.length - 20);
201
+ const output = [...selected].filter(index => index < tailStart).sort((a, b) => a - b)
202
+ .map(index => "recordings gate | " + lines[index]!.slice(0, 1000)).join("\n").slice(0, 14_000);
203
+ const summary = lines.slice(tailStart).map(line => "recordings gate | " + line.slice(0, 350)).join("\n");
204
+ return `release-suite-gate: ${report.id} status=${report.status} signal=${report.signal ?? "none"}\n`
205
+ + output + "\n" + summary;
168
206
  }
169
207
 
170
- console.log(
171
- "release-suite-gate: PASS - " + failPairs.length + " classified pre-existing failure(s) allowlisted:" +
172
- failPairs.map((p) => "\n " + p.file + " :: " + p.test).join(""),
173
- );
174
- process.exit(0);
208
+ export function releaseMain(): number {
209
+ const args = process.argv.slice(2);
210
+ if (new Set(args).size !== args.length || args.some(arg => arg !== "--all" && arg !== "--plan")) fail("usage: release-suite-gate.ts [--all] [--plan]; arbitrary test filters would invalidate coverage");
211
+ const enumeration = (mode: string) => {
212
+ const child = spawnSync(process.execPath, ["scripts/ci-linux-suite.ts", mode], { encoding: "utf8", timeout: 60000 });
213
+ if (child.status !== 0 || child.signal || child.error) fail(`partition ${mode} failed: ${child.stderr || child.stdout}`);
214
+ return child.stdout;
215
+ };
216
+ // --all validates discovery and quarantine metadata itself, while still running
217
+ // when the gated subset is empty. The default gate must retain its empty-set refusal.
218
+ if (!args.includes("--all")) enumeration("--check");
219
+ const groups = planReleaseSuite(enumeration(args.includes("--all") ? "--all" : "--gated").trim().split("\n").filter(Boolean), process.platform);
220
+ if (args.includes("--plan")) { console.log(JSON.stringify(groups, null, 2)); return 0; }
221
+ const logDir = realpathSync(mkdtempSync(join(tmpdir(), "recordings-release-gate-")));
222
+ console.log(`release-suite-gate: reports and logs: ${logDir}`);
223
+ const reports: GroupReport[] = [];
224
+ for (const group of groups) {
225
+ console.log(`release-suite-gate: running ${group.id} (${group.files.length} files)`);
226
+ const junitPath = join(logDir, `${group.id}.xml`);
227
+ const command = groupCommand(group, junitPath);
228
+ const environmentRoot = mkdtempSync(join(logDir, `${group.id}-environment-`));
229
+ const child = spawnSync(command[0]!, command.slice(1), { encoding: "utf8", env: groupEnvironment(environmentRoot), maxBuffer: 128 * 1024 * 1024, timeout: group.runner === "publication" ? 360000 : group.runner === "recorder" ? 60000 : 30 * 60 * 1000 });
230
+ const output = (child.stdout ?? "") + (child.stderr ?? "");
231
+ writeFileSync(join(logDir, `${group.id}.log`), output);
232
+ let junit = ""; try { junit = readFileSync(junitPath, "utf8"); } catch { /* Missing reports fail validation. */ }
233
+ reports.push({ id: group.id, status: child.status, signal: child.signal, error: child.error?.message, output, junit });
234
+ }
235
+ writeFileSync(join(logDir, "suite.log"), reports.map(r => `=== ${r.id} ===\n${r.output}`).join("\n"));
236
+ let result: ReturnType<typeof validateReleaseRun>;
237
+ try { result = validateReleaseRun(groups, reports); }
238
+ catch (error) {
239
+ const failed = reports.filter(report => report.status !== 0 || report.signal || report.error);
240
+ // Exit-zero truncated receipts also need diagnostics. Coverage validation
241
+ // remains authoritative; printing evidence never changes its result.
242
+ for (const report of failed.length ? failed : reports) console.error(failureDiagnostics(report));
243
+ throw error;
244
+ }
245
+ console.log(`release-suite-gate: PASS - ${result.passed} pass, ${result.skipped} skip, 0 fail; ${result.tests} tests across ${result.files} files, each selected once`);
246
+ return 0;
247
+ }
248
+ if (import.meta.main) {
249
+ try { process.exitCode = releaseMain(); }
250
+ catch (error) { console.error(`release-suite-gate: FAIL - ${(error as Error).message}`); process.exitCode = 1; }
251
+ }