@hasna/recordings 0.3.2 → 0.3.8

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 (58) hide show
  1. package/README.md +38 -34
  2. package/bun.lock +96 -36
  3. package/dist/__tests__/helpers/source-assertions.d.ts +6 -4
  4. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -1
  5. package/dist/cli/index.js +182 -93
  6. package/dist/cli/macos-shortcut.d.ts +2 -2
  7. package/dist/db/pg-migrations.d.ts +1 -1
  8. package/dist/http/client.d.ts +0 -10
  9. package/dist/http/client.d.ts.map +1 -1
  10. package/dist/index.js +162 -73
  11. package/dist/lib/capture-probe.d.ts +3 -3
  12. package/dist/lib/capture-probe.d.ts.map +1 -1
  13. package/dist/lib/macos-bundle.d.ts +1 -1
  14. package/dist/lib/release-install-policy.d.ts +21 -0
  15. package/dist/lib/release-install-policy.d.ts.map +1 -1
  16. package/dist/mcp/index.js +144 -59
  17. package/dist/sdk/index.d.ts +2 -2
  18. package/dist/sdk/index.js +7 -3
  19. package/dist/sdk/v1.generated.d.ts.map +1 -1
  20. package/dist/server/cloud-config.d.ts +4 -19
  21. package/dist/server/cloud-config.d.ts.map +1 -1
  22. package/dist/server/cloud.d.ts +1 -1
  23. package/dist/server/cloud.d.ts.map +1 -1
  24. package/dist/server/index.js +168 -110
  25. package/dist/server/serve.d.ts.map +1 -1
  26. package/dist/storage.d.ts +1 -1
  27. package/dist/storage.d.ts.map +1 -1
  28. package/dist/storage.js +144 -60
  29. package/dist/store.d.ts.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.d.ts.map +1 -1
  32. package/package.json +4 -3
  33. package/packaging/macos/build_release_pkg.sh +10 -4
  34. package/packaging/macos/managed_bootstrap.sh +4 -4
  35. package/packaging/macos/scripts/postinstall +2 -2
  36. package/packaging/macos/scripts/preinstall +2 -0
  37. package/scripts/generate-sdk.ts +17 -16
  38. package/scripts/install_macos_app.sh +22 -22
  39. package/scripts/macos_artifact.ts +48 -39
  40. package/scripts/migrate.ts +2 -2
  41. package/scripts/release-suite-gate.ts +181 -0
  42. package/scripts/set-version.ts +5 -5
  43. package/scripts/smoke_macos_app.sh +21 -21
  44. package/scripts/vacuity-manifests/version-sites.tsv +8 -4
  45. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  46. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +2 -2
  47. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  48. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  49. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  50. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  51. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  52. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  53. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  54. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  55. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  56. package/src/native/Recordings/build.sh +14 -20
  57. package/dist/lib/retired-deployment-modes.d.ts +0 -24
  58. package/dist/lib/retired-deployment-modes.d.ts.map +0 -1
@@ -183,8 +183,8 @@ export type BuildProvenance = {
183
183
 
184
184
  export type MacOSArtifactManifest = BuildProvenance & {
185
185
  artifact_type: "recordings-macos-app";
186
- /// The top-level bundle directory name inside the artifact (Recordings.app for the
187
- /// full app, HasnaRecordings.app for the bar variant per the fleet naming rule).
186
+ /// The top-level bundle directory name inside the artifact (HasnaRecordings.app for
187
+ /// both the full app and the bar variant per the fleet naming rule).
188
188
  bundle_name: string;
189
189
  app_sha256: string;
190
190
  binding: {
@@ -843,7 +843,7 @@ function manifestVariant(manifest: MacOSArtifactManifest): ArtifactVariant {
843
843
  }
844
844
 
845
845
  function manifestBundleName(manifest: MacOSArtifactManifest): string {
846
- return manifest.bundle_name ?? "Recordings.app";
846
+ return manifest.bundle_name ?? "HasnaRecordings.app";
847
847
  }
848
848
 
849
849
  function manifestTargetIdentityKind(manifest: MacOSArtifactManifest): OperatorTargetIdentityKind {
@@ -1094,23 +1094,23 @@ function assertCanonicalZipEntries(entries: Array<{ name: string; isDirectory: b
1094
1094
  const components = logicalPath.split("/");
1095
1095
  if (
1096
1096
  !logicalPath ||
1097
- components[0] !== "Recordings.app" ||
1097
+ components[0] !== "HasnaRecordings.app" ||
1098
1098
  components.some((component) => !component || component === "." || component === "..")
1099
1099
  ) {
1100
- throw new Error("release ZIP contains an entry outside the canonical Recordings.app tree");
1100
+ throw new Error("release ZIP contains an entry outside the canonical HasnaRecordings.app tree");
1101
1101
  }
1102
1102
  const collisionKey = canonicalZipCollisionKey(logicalPath);
1103
1103
  if (logicalPaths.has(collisionKey)) {
1104
1104
  throw new Error("release ZIP contains duplicate, file/directory, case-fold, or Unicode-colliding entries");
1105
1105
  }
1106
1106
  logicalPaths.set(collisionKey, { name: logicalPath, isDirectory: entry.isDirectory });
1107
- if (logicalPath === "Recordings.app") {
1108
- if (!entry.isDirectory) throw new Error("release ZIP Recordings.app root is not a directory entry");
1107
+ if (logicalPath === "HasnaRecordings.app") {
1108
+ if (!entry.isDirectory) throw new Error("release ZIP HasnaRecordings.app root is not a directory entry");
1109
1109
  rootDirectoryCount += 1;
1110
1110
  }
1111
1111
  }
1112
1112
  if (rootDirectoryCount !== 1) {
1113
- throw new Error("release ZIP must contain exactly one canonical Recordings.app root entry");
1113
+ throw new Error("release ZIP must contain exactly one canonical HasnaRecordings.app root entry");
1114
1114
  }
1115
1115
  for (const { name } of logicalPaths.values()) {
1116
1116
  const components = name.split("/");
@@ -1426,7 +1426,7 @@ export function withPrivatelyExtractedArchiveApp<T>(
1426
1426
  operation: (appPath: string) => T,
1427
1427
  platformArchiveTool = "/usr/bin/ditto",
1428
1428
  expectedArchiveSha256?: string,
1429
- expectedBundleName = "Recordings.app",
1429
+ expectedBundleName = "HasnaRecordings.app",
1430
1430
  ): T {
1431
1431
  const privateRoot = mkdtempSync(join(tmpdir(), "recordings-artifact-extract-"));
1432
1432
  chmodSync(privateRoot, 0o700);
@@ -1751,8 +1751,8 @@ export function assertManifestShape(manifest: MacOSArtifactManifest): void {
1751
1751
  manifest.container?.type !== "zip" ||
1752
1752
  JSON.stringify(manifest.container.install_locations) !== JSON.stringify(
1753
1753
  artifactPolicy === "release"
1754
- ? ["/Applications/Recordings.app"]
1755
- : ["~/Applications/Recordings.app"],
1754
+ ? ["/Applications/HasnaRecordings.app"]
1755
+ : ["~/Applications/HasnaRecordings.app"],
1756
1756
  )
1757
1757
  ) {
1758
1758
  throw new Error("manifest has an unexpected container install policy");
@@ -2516,7 +2516,7 @@ export function assertVersionTransition(
2516
2516
  assertManifestShape(manifest);
2517
2517
  if (compareVersions(manifest.bundle_version, installedVersion) < 0) {
2518
2518
  throw new Error(
2519
- `refusing to downgrade Recordings.app from ${installedVersion} to ${manifest.bundle_version}`,
2519
+ `refusing to downgrade HasnaRecordings.app from ${installedVersion} to ${manifest.bundle_version}`,
2520
2520
  );
2521
2521
  }
2522
2522
  if (compareVersions(manifest.bundle_version, installedVersion) === 0) {
@@ -2626,8 +2626,6 @@ function verifyActiveApp(
2626
2626
  const environment: NodeJS.ProcessEnv = {
2627
2627
  HOME: contractHome,
2628
2628
  PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
2629
- HASNA_RECORDINGS_CLIENT_STORE: "sqlite",
2630
- RECORDINGS_CLIENT_STORE: "sqlite",
2631
2629
  HASNA_RECORDINGS_DB_PATH: join(contractHome, "recordings.db"),
2632
2630
  RECORDINGS_AUDIO_DIR: join(contractHome, "audio"),
2633
2631
  };
@@ -2983,7 +2981,7 @@ function readJournal(path: string): InstallJournal {
2983
2981
  if (resolve(path) !== resolve(join(expectedParent, ".Recordings-install-transaction.json"))) {
2984
2982
  throw new Error("install transaction journal is outside the expected app parent");
2985
2983
  }
2986
- if (resolve(journal.app_destination) !== resolve(join(expectedParent, "Recordings.app"))) {
2984
+ if (resolve(journal.app_destination) !== resolve(join(expectedParent, "HasnaRecordings.app"))) {
2987
2985
  throw new Error("install transaction journal has an unexpected app destination");
2988
2986
  }
2989
2987
  const expectedDataDir = resolve(join(dirname(expectedParent), ".hasna", "recordings"));
@@ -3001,7 +2999,9 @@ function readJournal(path: string): InstallJournal {
3001
2999
  return (
3002
3000
  resolved === resolve(journal.app_destination) ||
3003
3001
  resolved === resolve(join(journal.data_dir, "Recordings.app")) ||
3004
- resolved.startsWith(`${resolve(journal.app_parent)}/Recordings.app.`)
3002
+ resolved === resolve(join(journal.data_dir, "HasnaRecordings.app")) ||
3003
+ resolved.startsWith(`${resolve(journal.app_parent)}/Recordings.app.`) ||
3004
+ resolved.startsWith(`${resolve(journal.app_parent)}/HasnaRecordings.app.`)
3005
3005
  );
3006
3006
  };
3007
3007
  if (!journal.originals.every((entry) => allowedOriginal(entry.path))) {
@@ -3939,7 +3939,7 @@ function openRecoveryCapabilities(
3939
3939
  const homePath = resolve(dirname(appParent));
3940
3940
  if (
3941
3941
  basename(appParent) !== "Applications" ||
3942
- resolve(journal.app_destination) !== resolve(join(appParent, "Recordings.app")) ||
3942
+ resolve(journal.app_destination) !== resolve(join(appParent, "HasnaRecordings.app")) ||
3943
3943
  resolve(journal.data_dir) !== resolve(join(homePath, ".hasna", "recordings")) ||
3944
3944
  dirname(resolve(journal.transaction_dir)) !== appParent
3945
3945
  ) {
@@ -4342,9 +4342,10 @@ export function publishInstallCandidate(
4342
4342
  const destination = resolve(destinationPath);
4343
4343
  const stagingParentPath = dirname(staging);
4344
4344
  const stagingParentLeaf = basename(stagingParentPath);
4345
+ const bundleLeaf = basename(journal.app_destination);
4345
4346
  if (
4346
4347
  destination !== resolve(journal.app_destination) ||
4347
- basename(staging) !== "Recordings.app" ||
4348
+ basename(staging) !== bundleLeaf ||
4348
4349
  dirname(stagingParentPath) !== resolve(journal.app_parent) ||
4349
4350
  !/^\.Recordings-install\.[A-Za-z0-9]+$/.test(stagingParentLeaf)
4350
4351
  ) {
@@ -4367,12 +4368,12 @@ export function publishInstallCandidate(
4367
4368
  stagingParent,
4368
4369
  "candidate staging parent",
4369
4370
  );
4370
- retained = guard.openDirAt(stagingParent, "Recordings.app");
4371
- assertNativeBinding(guard, stagingParent, "Recordings.app", retained, "staged candidate");
4371
+ retained = guard.openDirAt(stagingParent, bundleLeaf);
4372
+ assertNativeBinding(guard, stagingParent, bundleLeaf, retained, "staged candidate");
4372
4373
  if (nativeTreeDigest(guard, retained) !== expectedDigest) {
4373
4374
  throw new Error("staged candidate does not match durable journal evidence");
4374
4375
  }
4375
- if (guard.statAt(capabilities.applications, "Recordings.app") !== null) {
4376
+ if (guard.statAt(capabilities.applications, bundleLeaf) !== null) {
4376
4377
  throw new Error("candidate destination already exists");
4377
4378
  }
4378
4379
  fsyncRetainedTree(guard, retained, uid, "staged candidate");
@@ -4384,8 +4385,8 @@ export function publishInstallCandidate(
4384
4385
  stagingParent,
4385
4386
  "candidate staging parent",
4386
4387
  );
4387
- assertNativeBinding(guard, stagingParent, "Recordings.app", retained, "staged candidate");
4388
- if (guard.statAt(capabilities.applications, "Recordings.app") !== null) {
4388
+ assertNativeBinding(guard, stagingParent, bundleLeaf, retained, "staged candidate");
4389
+ if (guard.statAt(capabilities.applications, bundleLeaf) !== null) {
4389
4390
  throw new Error("candidate destination appeared before publication");
4390
4391
  }
4391
4392
  installTransitionTestPoint(
@@ -4395,10 +4396,10 @@ export function publishInstallCandidate(
4395
4396
  );
4396
4397
  guard.renameHandleNoReplaceAt(
4397
4398
  stagingParent,
4398
- "Recordings.app",
4399
+ bundleLeaf,
4399
4400
  retained,
4400
4401
  capabilities.applications,
4401
- "Recordings.app",
4402
+ bundleLeaf,
4402
4403
  );
4403
4404
  installTransitionTestPoint(
4404
4405
  "publish-candidate",
@@ -4409,11 +4410,11 @@ export function publishInstallCandidate(
4409
4410
  assertNativeBinding(
4410
4411
  guard,
4411
4412
  capabilities.applications,
4412
- "Recordings.app",
4413
+ bundleLeaf,
4413
4414
  retained,
4414
4415
  "published candidate",
4415
4416
  );
4416
- if (guard.statAt(stagingParent, "Recordings.app") !== null) {
4417
+ if (guard.statAt(stagingParent, bundleLeaf) !== null) {
4417
4418
  throw new Error("candidate staging leaf was recreated during publication");
4418
4419
  }
4419
4420
  guard.fsyncHandle(capabilities.applications);
@@ -4459,7 +4460,8 @@ function cleanupInstallCandidateStaging(
4459
4460
  staging,
4460
4461
  "candidate staging root",
4461
4462
  );
4462
- const candidateMetadata = guard.statAt(staging, "Recordings.app");
4463
+ const bundleLeaf = basename(journal.app_destination);
4464
+ const candidateMetadata = guard.statAt(staging, bundleLeaf);
4463
4465
  if (candidateMetadata !== null) {
4464
4466
  if (journal.schema_version !== 9 || !journal.candidate_tree_sha256) {
4465
4467
  throw new Error("legacy journal cannot authenticate candidate staging cleanup");
@@ -4467,7 +4469,7 @@ function cleanupInstallCandidateStaging(
4467
4469
  const candidate = openProvenDirectoryAt(
4468
4470
  guard,
4469
4471
  staging,
4470
- "Recordings.app",
4472
+ bundleLeaf,
4471
4473
  journal.candidate_tree_sha256,
4472
4474
  "staged candidate",
4473
4475
  );
@@ -4475,7 +4477,7 @@ function cleanupInstallCandidateStaging(
4475
4477
  quarantineRemoveRetainedAt(
4476
4478
  guard,
4477
4479
  staging,
4478
- "Recordings.app",
4480
+ bundleLeaf,
4479
4481
  candidate,
4480
4482
  "staged candidate",
4481
4483
  );
@@ -4567,14 +4569,20 @@ function originalDestinationCapability(
4567
4569
  ): { parent: NativeHandle; leaf: string } {
4568
4570
  const resolved = resolve(path);
4569
4571
  if (resolved === resolve(journal.app_destination)) {
4570
- return { parent: capabilities.applications, leaf: "Recordings.app" };
4572
+ return { parent: capabilities.applications, leaf: basename(journal.app_destination) };
4571
4573
  }
4572
- if (resolved === resolve(join(journal.data_dir, "Recordings.app"))) {
4573
- return { parent: capabilities.data, leaf: "Recordings.app" };
4574
+ if (
4575
+ dirname(resolved) === resolve(journal.data_dir) &&
4576
+ (basename(resolved) === "Recordings.app" || basename(resolved) === "HasnaRecordings.app")
4577
+ ) {
4578
+ // Install site inside the state data directory: the legacy pre-rename
4579
+ // Recordings.app and the renamed HasnaRecordings.app are both recognized and
4580
+ // preserved, never treated as the canonical Applications destination.
4581
+ return { parent: capabilities.data, leaf: basename(resolved) };
4574
4582
  }
4575
4583
  if (
4576
4584
  dirname(resolved) === resolve(journal.app_parent) &&
4577
- /^Recordings\.app\.[A-Za-z0-9._-]+$/.test(basename(resolved))
4585
+ /^(?:Hasna)?Recordings\.app\.[A-Za-z0-9._-]+$/.test(basename(resolved))
4578
4586
  ) {
4579
4587
  return { parent: capabilities.applications, leaf: basename(resolved) };
4580
4588
  }
@@ -5005,24 +5013,25 @@ function recoverJournal(path: string): void {
5005
5013
  const canonicalOriginal = journal.originals.find(
5006
5014
  (entry) => resolve(entry.path) === resolve(journal.app_destination),
5007
5015
  );
5016
+ const destinationLeaf = basename(journal.app_destination);
5008
5017
  const canonicalDestinationDigest = canonicalOriginal
5009
- ? nativeTreeDigestAt(guard, applications, "Recordings.app")
5018
+ ? nativeTreeDigestAt(guard, applications, destinationLeaf)
5010
5019
  : null;
5011
5020
  const canonicalAlreadyRestored = canonicalOriginal !== undefined &&
5012
5021
  canonicalDestinationDigest === canonicalOriginal.sha256;
5013
5022
  if (
5014
5023
  ["candidate-moving", "candidate-installed", "activated", "launching"].includes(journal.phase) &&
5015
- !canonicalAlreadyRestored && guard.statAt(applications, "Recordings.app") !== null
5024
+ !canonicalAlreadyRestored && guard.statAt(applications, destinationLeaf) !== null
5016
5025
  ) {
5017
5026
  if (journal.schema_version !== 9 || !journal.candidate_tree_sha256) {
5018
5027
  throw new Error("legacy recovery journal cannot prove the uncommitted candidate tree");
5019
5028
  }
5020
- recoveryTestBarrier("before-candidate-remove", "Recordings.app");
5029
+ recoveryTestBarrier("before-candidate-remove", destinationLeaf);
5021
5030
  assertNativeBinding(guard, capabilities.home, "Applications", applications, "Applications");
5022
5031
  const candidate = openProvenDirectoryAt(
5023
5032
  guard,
5024
5033
  applications,
5025
- "Recordings.app",
5034
+ destinationLeaf,
5026
5035
  journal.candidate_tree_sha256,
5027
5036
  "uncommitted candidate",
5028
5037
  );
@@ -5030,7 +5039,7 @@ function recoverJournal(path: string): void {
5030
5039
  quarantineRemoveRetainedAt(
5031
5040
  guard,
5032
5041
  applications,
5033
- "Recordings.app",
5042
+ destinationLeaf,
5034
5043
  candidate,
5035
5044
  "uncommitted candidate",
5036
5045
  );
@@ -34,11 +34,11 @@ async function main() {
34
34
  getCloudPg,
35
35
  migrateCloudSchema,
36
36
  pingCloudConnectivity,
37
- resolveCloudDatabaseUrl,
37
+ resolveDatabaseUrl,
38
38
  } = await import("../src/server/cloud.js");
39
39
  const { assertCloudSchemaContract } = await import("../src/server/cloud-readiness.js");
40
40
  const { runCloudMigration } = await import("../src/server/migrate-command.js");
41
- const url = resolveCloudDatabaseUrl();
41
+ const url = resolveDatabaseUrl();
42
42
  if (!url) {
43
43
  console.error(
44
44
  "migrate: no database URL (HASNA_RECORDINGS_DATABASE_URL / RECORDINGS_DATABASE_URL / DATABASE_URL)",
@@ -0,0 +1,181 @@
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 except the allowlisted pre-existing classes below.
8
+ *
9
+ * Why the allowlist exists: the suite is red at this release's base sha with
10
+ * exactly two measured pre-existing classes (both reproduced identically at
11
+ * the pristine base commit 4a9b31e56, classified non-blocking by the release
12
+ * lineage). The old `prepublishOnly` (`bun run test`, bare) could not pass on
13
+ * any box for independent reasons: no timeout flag for the long macOS
14
+ * lifecycle suites, and the ambient hosted-store environment on fleet boxes.
15
+ *
16
+ * The allowlist is SELF-INVALIDATING, the same property as
17
+ * `.github/linux-quarantine.txt`: if an allowlisted failure stops failing
18
+ * (the owning lane lands its fix, or a quarantined package ages out of the
19
+ * 7-day window), the gate exits 1 and demands the entry be removed. Any
20
+ * failure outside the allowlist fails the gate.
21
+ *
22
+ * The gate FAILS CLOSED on abnormal termination: a non-zero suite exit is
23
+ * acceptable only when it is fully explained by allowlisted failures — the
24
+ * runner completed (its summary line is present), every parsed failure is
25
+ * allowlisted, at least one allowlisted failure fired, and the runner's
26
+ * reported failure count equals the parsed count. A crash, signal kill,
27
+ * timeout, or runner error that produces a non-zero exit without a complete
28
+ * summary fails the gate (remediation cycle 2 of PR hasna/apps#826).
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { mkdtempSync, writeFileSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+
35
+ const SEP = "::";
36
+
37
+ const GATED_KEYS: Array<{ file: string; test: string; owner: string; reason: string }> = [
38
+ {
39
+ file: "src/__tests__/service-contract-manifest.test.ts",
40
+ test: "the repository satisfies the tracked contract kit's conformance checks",
41
+ owner: "contracts-surface lanes (hasna/apps PRs 743/749/754 lineage)",
42
+ reason:
43
+ "credential_seam_compliance flags the vendored client seam (resolveStorageClient in src/http/client.ts) and the RECORDINGS_API_KEY env read (src/lib/config.ts) - an in-flight migration owned by the contracts-surface lanes; classified pre-existing at the release base. Remove this entry when the lane lands.",
44
+ },
45
+ {
46
+ file: "src/__tests__/native-app-companion-contract.test.ts",
47
+ test: "(unnamed)",
48
+ owner: "release supply-chain policy",
49
+ reason:
50
+ "build_companion_cli.sh enforces --minimum-release-age=604800, so the companion build refuses @hasna/contracts@0.13.3 (published 2026-08-21T13:13Z) inside its 7-day quarantine by design until 2026-08-28. The npm publish path does not build the companion. Remove this entry when the package ages out.",
51
+ },
52
+ ];
53
+
54
+ function run(argv: string[], env: Record<string, string>): { status: number; stdout: string; stderr: string } {
55
+ const res = spawnSync(process.execPath, argv, { encoding: "utf8", env: { ...process.env, ...env } });
56
+ return { status: res.status ?? -1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
57
+ }
58
+
59
+ // 1. Validate the partition (quarantine file must be consistent).
60
+ const check = run(["scripts/ci-linux-suite.ts", "--check"], {});
61
+ if (check.status !== 0) {
62
+ console.error("release-suite-gate: partition check failed:\n" + (check.stderr || check.stdout));
63
+ process.exit(1);
64
+ }
65
+
66
+ // 2. Collect the gated file list.
67
+ const gated = run(["scripts/ci-linux-suite.ts", "--gated"], {});
68
+ if (gated.status !== 0) {
69
+ console.error("release-suite-gate: could not enumerate gated files:\n" + (gated.stderr || gated.stdout));
70
+ process.exit(1);
71
+ }
72
+ const gatedFiles = gated.stdout.split("\n").filter(Boolean);
73
+ if (gatedFiles.length === 0) {
74
+ console.error("release-suite-gate: zero gated files - refusing to pass on an empty suite.");
75
+ process.exit(1);
76
+ }
77
+
78
+ // 3. Run the gated suite exactly like `test:gated`.
79
+ const logDir = mkdtempSync(join(tmpdir(), "recordings-release-gate-"));
80
+ const logPath = join(logDir, "suite.log");
81
+ const suite = spawnSync(
82
+ process.execPath,
83
+ ["test", "--timeout", "120000", ...gatedFiles],
84
+ {
85
+ encoding: "utf8",
86
+ env: { ...process.env, RECORDINGS_TEST_TIMEOUT_MS: "120000" },
87
+ maxBuffer: 64 * 1024 * 1024,
88
+ },
89
+ );
90
+ const output = (suite.stdout ?? "") + (suite.stderr ?? "");
91
+ try {
92
+ writeFileSync(logPath, output);
93
+ } catch {
94
+ /* log capture is best-effort */
95
+ }
96
+ const suiteStatus = suite.status ?? -1;
97
+
98
+ // 4. Parse failing tests as (file, description) pairs, in output order.
99
+ const failPairs: Array<{ file: string; test: string }> = [];
100
+ let currentFile = "";
101
+ for (const line of output.split("\n")) {
102
+ const fileMatch = /^(?:\.\/)?(src\/[^\s:]+\.test\.ts):$/.exec(line);
103
+ if (fileMatch) {
104
+ currentFile = fileMatch[1];
105
+ continue;
106
+ }
107
+ const failMatch = /^\(fail\) (.+) \[\d+.*\]$/.exec(line);
108
+ if (failMatch) {
109
+ failPairs.push({ file: currentFile, test: failMatch[1].trim() });
110
+ }
111
+ }
112
+
113
+ // 5. Classify.
114
+ const failuresByKey = new Map<string, number>();
115
+ for (const key of GATED_KEYS) {
116
+ failuresByKey.set(key.file + SEP + key.test, 0);
117
+ }
118
+ const unexpected: Array<{ file: string; test: string }> = [];
119
+ for (const pair of failPairs) {
120
+ const key = pair.file + SEP + pair.test;
121
+ if (failuresByKey.has(key)) {
122
+ failuresByKey.set(key, (failuresByKey.get(key) ?? 0) + 1);
123
+ } else {
124
+ unexpected.push(pair);
125
+ }
126
+ }
127
+
128
+ if (unexpected.length > 0) {
129
+ console.error(
130
+ "release-suite-gate: FAIL - unexpected test failures:\n" +
131
+ unexpected.map((u) => " " + u.file + " :: " + u.test).join("\n"),
132
+ );
133
+ console.error("Full suite log: " + logPath);
134
+ process.exit(1);
135
+ }
136
+
137
+ // 6. Fail closed on abnormal termination. A non-zero suite exit is only
138
+ // acceptable when it is fully explained by the allowlisted failures: the
139
+ // runner completed (summary line present), every parsed failure is
140
+ // allowlisted, at least one allowlisted failure fired, and the runner's
141
+ // reported failure count equals the parsed count (no unparsed failures).
142
+ // A crash, signal kill, timeout, or runner error produces a non-zero exit
143
+ // without a complete summary and must fail the gate.
144
+ if (suiteStatus !== 0) {
145
+ const summaryMatch = /Ran (\d+) tests across (\d+) files/.exec(output);
146
+ // The tally is "N pass[,/ ] M fail" in the runner's closing summary. The
147
+ // LAST match is taken (bun prints the tally once, but a naive first match
148
+ // can hit "0.13.3 failed to resolve" -> "3 fail" inside a version string).
149
+ const tallyMatches = [...output.matchAll(/(\d+) pass[\s,/]+(\d+) fail/g)];
150
+ const failCountMatch = tallyMatches.length > 0 ? tallyMatches[tallyMatches.length - 1] : null;
151
+ const reportedFails = failCountMatch ? Number(failCountMatch[2]) : null;
152
+ const anyAllowlistedFired = failPairs.length > 0;
153
+ const complete = summaryMatch !== null && reportedFails !== null && reportedFails === failPairs.length;
154
+ if (!anyAllowlistedFired || !complete) {
155
+ console.error(
156
+ "release-suite-gate: FAIL - suite exited " + suiteStatus +
157
+ " with " + failPairs.length + " parsed failure(s) but the run is incomplete or unexplained:" +
158
+ "\n summary line present: " + (summaryMatch !== null) +
159
+ "\n reported failure count: " + String(reportedFails) +
160
+ "\n parsed failure count: " + failPairs.length,
161
+ );
162
+ console.error("Full suite log: " + logPath);
163
+ process.exit(1);
164
+ }
165
+ }
166
+
167
+ // 7. Self-invalidation: an allowlisted failure that no longer fails must be removed.
168
+ const stale = GATED_KEYS.filter((key) => (failuresByKey.get(key.file + SEP + key.test) ?? 0) === 0);
169
+ if (stale.length > 0) {
170
+ console.error(
171
+ "release-suite-gate: FAIL - allowlisted failure no longer fails; remove the entry from scripts/release-suite-gate.ts:\n" +
172
+ stale.map((s) => " " + s.file + " :: " + s.test + " - " + s.owner).join("\n"),
173
+ );
174
+ process.exit(1);
175
+ }
176
+
177
+ console.log(
178
+ "release-suite-gate: PASS - " + failPairs.length + " classified pre-existing failure(s) allowlisted:" +
179
+ failPairs.map((p) => "\n " + p.file + " :: " + p.test).join(""),
180
+ );
181
+ process.exit(0);
@@ -16,17 +16,17 @@ export type VersionSite = {
16
16
  pattern: RegExp;
17
17
  };
18
18
 
19
+ // src/version.ts is deliberately NOT a site: it derives VERSION from
20
+ // package.json at import time (see src/version.ts), so it holds no literal
21
+ // this writer could keep in sync, and a monorepo changesets bump can no
22
+ // longer leave it stale. The remaining sites are the native bundle surfaces,
23
+ // which cannot derive and must be written here.
19
24
  export const versionSites: VersionSite[] = [
20
25
  {
21
26
  file: "package.json",
22
27
  label: "package.json version",
23
28
  pattern: /("version"\s*:\s*")([^"]+)(")/,
24
29
  },
25
- {
26
- file: "src/version.ts",
27
- label: "src/version.ts VERSION",
28
- pattern: /(export const VERSION = ")([^"]+)(")/,
29
- },
30
30
  {
31
31
  file: "src/native/Recordings/RecordingsLib/Info.plist",
32
32
  label: "Info.plist CFBundleShortVersionString",
@@ -9,7 +9,7 @@ if [ "$#" -ge 3 ]; then
9
9
  SMOKE_VARIANT="${4:-full}"
10
10
  ;;
11
11
  *)
12
- echo "Usage: $0 <Recordings.app> <explicit absolute Bun executable path> [--variant full|bar]" >&2
12
+ echo "Usage: $0 <HasnaRecordings.app> <explicit absolute Bun executable path> [--variant full|bar]" >&2
13
13
  exit 2
14
14
  ;;
15
15
  esac
@@ -51,7 +51,7 @@ HOST_PLATFORM="$("$HOST_UNAME_EXECUTABLE" -s)"
51
51
  readonly HOST_PLATFORM
52
52
 
53
53
  if [ "$HOST_PLATFORM" != "Darwin" ] && [ "${RECORDINGS_TEST_SMOKE_ALLOW_NON_DARWIN:-0}" != "1" ]; then
54
- echo "Recordings.app runtime smoke is only supported on macOS." >&2
54
+ echo "HasnaRecordings.app runtime smoke is only supported on macOS." >&2
55
55
  exit 1
56
56
  fi
57
57
 
@@ -123,12 +123,12 @@ HOME_DIRECTORY="${HOME:-}"
123
123
  case "$HOME_DIRECTORY" in
124
124
  /*) ;;
125
125
  *)
126
- echo "Recordings.app runtime smoke requires an absolute HOME path." >&2
126
+ echo "HasnaRecordings.app runtime smoke requires an absolute HOME path." >&2
127
127
  exit 1
128
128
  ;;
129
129
  esac
130
130
  if [ ! -d "$HOME_DIRECTORY" ]; then
131
- echo "Recordings.app runtime smoke HOME is not a directory: $HOME_DIRECTORY" >&2
131
+ echo "HasnaRecordings.app runtime smoke HOME is not a directory: $HOME_DIRECTORY" >&2
132
132
  exit 1
133
133
  fi
134
134
 
@@ -136,7 +136,7 @@ APP_PARENT="$(cd -P "$("$DIRNAME_EXECUTABLE" "$1")" && "$PWD_EXECUTABLE" -P)"
136
136
  APP_PATH="$APP_PARENT/$("$BASENAME_EXECUTABLE" "$1")"
137
137
  EXECUTABLE="$APP_PATH/Contents/MacOS/Recordings"
138
138
  if [ ! -x "$EXECUTABLE" ]; then
139
- echo "Recordings.app runtime smoke executable is missing: $EXECUTABLE" >&2
139
+ echo "HasnaRecordings.app runtime smoke executable is missing: $EXECUTABLE" >&2
140
140
  exit 1
141
141
  fi
142
142
 
@@ -317,7 +317,7 @@ cleanup() {
317
317
  fi
318
318
  if [ -n "$SMOKE_APP_PID" ] && [ -n "$SMOKE_APP_PID_START_IDENTITY" ]; then
319
319
  terminate_verified_process \
320
- "Recordings.app" \
320
+ "HasnaRecordings.app" \
321
321
  "$SMOKE_APP_PID" \
322
322
  "$EXECUTABLE" \
323
323
  "$SMOKE_APP_PID_START_IDENTITY" || true
@@ -377,7 +377,7 @@ run_smoke() {
377
377
  if ! SMOKE_APP_IDENTITIES_BEFORE_LAUNCH="$(
378
378
  process_records_for_exact_executable "$EXECUTABLE"
379
379
  )"; then
380
- echo "Recordings.app runtime smoke ${mode} could not inventory exact app processes before launch." >&2
380
+ echo "HasnaRecordings.app runtime smoke ${mode} could not inventory exact app processes before launch." >&2
381
381
  return 1
382
382
  fi
383
383
 
@@ -399,24 +399,24 @@ run_smoke() {
399
399
  result_pid="$("$ENV_EXECUTABLE" -i HOME="$HOME_DIRECTORY" PATH="$SANITIZED_PATH" TMPDIR="$WORK_DIR" \
400
400
  "$BUN_EXECUTABLE" -e 'const value = await Bun.file(process.argv[1]).json(); console.log(value.processIdentifier)' "$output")"
401
401
  if ! [[ "$result_pid" =~ ^[1-9][0-9]*$ ]] || ! kill -0 "$result_pid" 2>/dev/null; then
402
- echo "Recordings.app runtime smoke ${mode} reported a process that is not running." >&2
402
+ echo "HasnaRecordings.app runtime smoke ${mode} reported a process that is not running." >&2
403
403
  return 1
404
404
  fi
405
405
  if ! result_pid_start_identity="$(
406
406
  capture_process_start_identity "$result_pid" "$EXECUTABLE"
407
407
  )"; then
408
- echo "Recordings.app runtime smoke ${mode} reported a process outside the exact app path." >&2
408
+ echo "HasnaRecordings.app runtime smoke ${mode} reported a process outside the exact app path." >&2
409
409
  return 1
410
410
  fi
411
411
  if process_identity_existed_before_launch "$result_pid" "$result_pid_start_identity"; then
412
- echo "Recordings.app runtime smoke ${mode} reported an app process that predated this launch." >&2
412
+ echo "HasnaRecordings.app runtime smoke ${mode} reported an app process that predated this launch." >&2
413
413
  return 1
414
414
  fi
415
415
  if [ -n "$SMOKE_APP_PID" ] && {
416
416
  [ "$result_pid" != "$SMOKE_APP_PID" ] ||
417
417
  [ "$result_pid_start_identity" != "$SMOKE_APP_PID_START_IDENTITY" ];
418
418
  }; then
419
- echo "Recordings.app runtime smoke ${mode} evidence did not match the exact launched app process." >&2
419
+ echo "HasnaRecordings.app runtime smoke ${mode} evidence did not match the exact launched app process." >&2
420
420
  return 1
421
421
  fi
422
422
  SMOKE_APP_PID="$result_pid"
@@ -429,19 +429,19 @@ run_smoke() {
429
429
  break
430
430
  fi
431
431
  if ! kill -0 "$SMOKE_PID" 2>/dev/null; then
432
- echo "Recordings.app runtime smoke ${mode} exited without evidence." >&2
432
+ echo "HasnaRecordings.app runtime smoke ${mode} exited without evidence." >&2
433
433
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
434
434
  return 1
435
435
  fi
436
436
  "$SLEEP_EXECUTABLE" 0.1
437
437
  done
438
438
  if [ ! -f "$output" ]; then
439
- echo "Recordings.app runtime smoke ${mode} timed out." >&2
439
+ echo "HasnaRecordings.app runtime smoke ${mode} timed out." >&2
440
440
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
441
441
  return 1
442
442
  fi
443
443
  if [ -z "$SMOKE_APP_PID" ]; then
444
- echo "Recordings.app runtime smoke ${mode} did not bind evidence to the exact app process." >&2
444
+ echo "HasnaRecordings.app runtime smoke ${mode} did not bind evidence to the exact app process." >&2
445
445
  return 1
446
446
  fi
447
447
 
@@ -546,7 +546,7 @@ run_smoke() {
546
546
  ' "$output"
547
547
 
548
548
  if [ -e "$completion" ]; then
549
- echo "Recordings.app runtime smoke ${mode} produced completion before the challenge." >&2
549
+ echo "HasnaRecordings.app runtime smoke ${mode} produced completion before the challenge." >&2
550
550
  return 1
551
551
  fi
552
552
  challenge="$("$ENV_EXECUTABLE" -i HOME="$HOME_DIRECTORY" PATH="$SANITIZED_PATH" TMPDIR="$WORK_DIR" \
@@ -554,7 +554,7 @@ run_smoke() {
554
554
  if ! rechecked_start_identity="$(
555
555
  capture_process_start_identity "$result_pid" "$EXECUTABLE"
556
556
  )" || [ "$rechecked_start_identity" != "$result_pid_start_identity" ]; then
557
- echo "Recordings.app runtime smoke ${mode} process identity changed before completion challenge." >&2
557
+ echo "HasnaRecordings.app runtime smoke ${mode} process identity changed before completion challenge." >&2
558
558
  return 1
559
559
  fi
560
560
  write_atomic_text_file "$acknowledgement" "$challenge"
@@ -564,7 +564,7 @@ run_smoke() {
564
564
  "$SLEEP_EXECUTABLE" 0.1
565
565
  done
566
566
  if kill -0 "$SMOKE_PID" 2>/dev/null; then
567
- echo "Recordings.app runtime smoke ${mode} completion handshake timed out." >&2
567
+ echo "HasnaRecordings.app runtime smoke ${mode} completion handshake timed out." >&2
568
568
  return 1
569
569
  fi
570
570
  if wait "$SMOKE_PID" 2>/dev/null; then
@@ -577,7 +577,7 @@ run_smoke() {
577
577
  SMOKE_APP_PID=""
578
578
  SMOKE_APP_PID_START_IDENTITY=""
579
579
  if [ "$wrapper_status" -ne 0 ]; then
580
- echo "Recordings.app runtime smoke ${mode} open -W wrapper exited unsuccessfully (${wrapper_status})." >&2
580
+ echo "HasnaRecordings.app runtime smoke ${mode} open -W wrapper exited unsuccessfully (${wrapper_status})." >&2
581
581
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
582
582
  return 1
583
583
  fi
@@ -602,7 +602,7 @@ run_smoke() {
602
602
  process.exit(1);
603
603
  }
604
604
  ' "$completion"; then
605
- echo "Recordings.app runtime smoke ${mode} did not provide a valid completion response." >&2
605
+ echo "HasnaRecordings.app runtime smoke ${mode} did not provide a valid completion response." >&2
606
606
  return 1
607
607
  fi
608
608
 
@@ -613,7 +613,7 @@ run_smoke normal
613
613
  run_smoke permission-helper
614
614
  run_smoke resolver
615
615
  if [ "$SMOKE_VARIANT" = "bar" ]; then
616
- echo "Recordings.app runtime smoke passed (bar variant): menu bar, windowless launch, helper isolation, and packaged resolver."
616
+ echo "HasnaRecordings.app runtime smoke passed (bar variant): menu bar, windowless launch, helper isolation, and packaged resolver."
617
617
  else
618
- echo "Recordings.app runtime smoke passed: menu bar, retained window, helper isolation, and packaged resolver."
618
+ echo "HasnaRecordings.app runtime smoke passed: menu bar, retained window, helper isolation, and packaged resolver."
619
619
  fi