@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.
- package/README.md +38 -34
- package/bun.lock +96 -36
- package/dist/__tests__/helpers/source-assertions.d.ts +6 -4
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -1
- package/dist/cli/index.js +182 -93
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/http/client.d.ts +0 -10
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.js +162 -73
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/release-install-policy.d.ts +21 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -1
- package/dist/mcp/index.js +144 -59
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +7 -3
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +4 -19
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +1 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +168 -110
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +144 -60
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +4 -3
- package/packaging/macos/build_release_pkg.sh +10 -4
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -0
- package/scripts/generate-sdk.ts +17 -16
- package/scripts/install_macos_app.sh +22 -22
- package/scripts/macos_artifact.ts +48 -39
- package/scripts/migrate.ts +2 -2
- package/scripts/release-suite-gate.ts +181 -0
- package/scripts/set-version.ts +5 -5
- package/scripts/smoke_macos_app.sh +21 -21
- package/scripts/vacuity-manifests/version-sites.tsv +8 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +2 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +14 -20
- package/dist/lib/retired-deployment-modes.d.ts +0 -24
- 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 (
|
|
187
|
-
/// full app
|
|
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 ?? "
|
|
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] !== "
|
|
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
|
|
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 === "
|
|
1108
|
-
if (!entry.isDirectory) throw new Error("release ZIP
|
|
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
|
|
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 = "
|
|
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/
|
|
1755
|
-
: ["~/Applications/
|
|
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
|
|
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, "
|
|
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
|
|
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, "
|
|
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) !==
|
|
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,
|
|
4371
|
-
assertNativeBinding(guard, stagingParent,
|
|
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,
|
|
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,
|
|
4388
|
-
if (guard.statAt(capabilities.applications,
|
|
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
|
-
|
|
4399
|
+
bundleLeaf,
|
|
4399
4400
|
retained,
|
|
4400
4401
|
capabilities.applications,
|
|
4401
|
-
|
|
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
|
-
|
|
4413
|
+
bundleLeaf,
|
|
4413
4414
|
retained,
|
|
4414
4415
|
"published candidate",
|
|
4415
4416
|
);
|
|
4416
|
-
if (guard.statAt(stagingParent,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
4572
|
+
return { parent: capabilities.applications, leaf: basename(journal.app_destination) };
|
|
4571
4573
|
}
|
|
4572
|
-
if (
|
|
4573
|
-
|
|
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,
|
|
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,
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
5042
|
+
destinationLeaf,
|
|
5034
5043
|
candidate,
|
|
5035
5044
|
"uncommitted candidate",
|
|
5036
5045
|
);
|
package/scripts/migrate.ts
CHANGED
|
@@ -34,11 +34,11 @@ async function main() {
|
|
|
34
34
|
getCloudPg,
|
|
35
35
|
migrateCloudSchema,
|
|
36
36
|
pingCloudConnectivity,
|
|
37
|
-
|
|
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 =
|
|
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);
|
package/scripts/set-version.ts
CHANGED
|
@@ -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 <
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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
|
-
"
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
616
|
+
echo "HasnaRecordings.app runtime smoke passed (bar variant): menu bar, windowless launch, helper isolation, and packaged resolver."
|
|
617
617
|
else
|
|
618
|
-
echo "
|
|
618
|
+
echo "HasnaRecordings.app runtime smoke passed: menu bar, retained window, helper isolation, and packaged resolver."
|
|
619
619
|
fi
|