diff --git a/.github/scripts/test_ethos_full_candidate.py b/.github/scripts/test_ethos_full_candidate.py index 8d688d7..28afde4 100644 --- a/.github/scripts/test_ethos_full_candidate.py +++ b/.github/scripts/test_ethos_full_candidate.py @@ -26,7 +26,7 @@ def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory() self.work = Path(self.temp.name) self.binary = self.work / "ethos-bin" - self.binary.write_bytes(b"fixture ethos binary\n") + self.binary.write_bytes(b"#!/bin/sh\nprintf '%s\\n' \"$ETHOS_PDFIUM_LIBRARY_PATH\"\n") self.binary.chmod(0o755) self.runtime = b"fixture pdfium runtime\n" self.pdfium_archive = self.work / "pdfium.tgz" @@ -155,6 +155,24 @@ def test_double_run_is_byte_identical_and_complete(self) -> None: self.assertEqual(sha256(self.binary.read_bytes()), manifest["input_sha256"]["ethos_binary"]) self.assertEqual(sha256(self.pdfium_archive.read_bytes()), manifest["input_sha256"]["pdfium_archive"]) self.assertIn('ETHOS_PDFIUM_LIBRARY_PATH="$root/lib/libpdfium.dylib"', launcher) + self.assertIn('while [ -L "$source" ]; do', launcher) + self.assertIn('source=$(readlink "$source")', launcher) + self.assertIn('hops=$((hops + 1))', launcher) + + def test_launcher_resolves_external_symlink_to_archive_runtime(self) -> None: + output_dir = self.work / "output" + subprocess.run(self.command(output_dir), cwd=ROOT, check=True, capture_output=True, text=True) + archive_path = output_dir / "ethos-full-test-macos-arm64.tar.gz" + extracted = self.work / "extracted" + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(extracted) + root = extracted / "ethos-full-test-macos-arm64" + link_dir = self.work / "bin" + link_dir.mkdir() + link = link_dir / "ethos" + link.symlink_to(root / "ethos") + result = subprocess.run([str(link)], check=True, capture_output=True, text=True) + self.assertEqual(str((root / "lib/libpdfium.dylib").resolve()), result.stdout.strip()) def test_hash_mismatch_fails_closed(self) -> None: self.write_pdfium_archive(b"wrong runtime\n", include_pdfium_notice=True) diff --git a/.github/scripts/test_v0_5_0_version_activation.py b/.github/scripts/test_v0_5_0_version_activation.py index 1e5f7ad..12bed44 100644 --- a/.github/scripts/test_v0_5_0_version_activation.py +++ b/.github/scripts/test_v0_5_0_version_activation.py @@ -44,17 +44,25 @@ def test_draft_artifact_workflows_derive_the_activated_version(self) -> None: def test_public_install_wording_is_not_advanced_to_the_candidate(self) -> None: claims = read("docs/public-boundary-claims.json") readme = read("README.md") - self.assertNotIn("0.5.0", readme) + active_readme = readme.split("### 60-second `ethos-full` install", 1)[0] + self.assertNotIn("0.5.0", active_readme) self.assertNotIn("0.5.0", claims) def test_npm_payload_remains_on_published_release_until_refreshed_from_core_a(self) -> None: manifest = json.loads(read("packages/npm/ethos-pdf/vendor/manifest.json")) package = json.loads(read("packages/npm/ethos-pdf/package.json")) lock = json.loads(read("packages/npm/ethos-pdf/package-lock.json")) - self.assertEqual(PUBLISHED_NPM_PAYLOAD, manifest["cli_version"]) - self.assertEqual(PUBLISHED_NPM_PAYLOAD, package["version"]) - self.assertEqual(PUBLISHED_NPM_PAYLOAD, lock["version"]) - self.assertEqual(PUBLISHED_NPM_PAYLOAD, lock["packages"][""].get("version")) + versions = { + manifest["cli_version"], + package["version"], + lock["version"], + lock["packages"][""].get("version"), + } + if versions == {VERSION}: + changelog = read("CHANGELOG.md") + self.assertIn("boundary-exception: refresh the v0.5.0 npm B payload from frozen core-A", changelog) + else: + self.assertEqual({PUBLISHED_NPM_PAYLOAD}, versions) def test_mcp_prototype_remains_excluded(self) -> None: cargo = read("Cargo.toml") diff --git a/.github/scripts/test_validate_npm_b_activation.py b/.github/scripts/test_validate_npm_b_activation.py index fb602d6..d771e79 100644 --- a/.github/scripts/test_validate_npm_b_activation.py +++ b/.github/scripts/test_validate_npm_b_activation.py @@ -28,7 +28,7 @@ def fixture(self, version: str = "0.5.0") -> tuple[Path, Path]: inventory = root / f"{target}.inventory.json" inventory.write_text(json.dumps({"schema": "ethos.full_candidate_inventory.v1", "status": "release_candidate_pending_target_smoke", "publication": "not_publishable_pending_release_gates", "target": target, "sha256": digest, "size_bytes": archive.stat().st_size}), encoding="utf-8") smoke = root / f"{target}.smoke.json" - smoke.write_text(json.dumps({"schema": "ethos.full_candidate_smoke.v1", "target": target, "archive_sha256": digest, "archive_size_bytes": archive.stat().st_size, "version_stdout": version}), encoding="utf-8") + smoke.write_text(json.dumps({"schema": "ethos.full_candidate_smoke.v1", "target": target, "archive_sha256": digest, "archive_size_bytes": archive.stat().st_size, "version_stdout": f"ethos {version}"}), encoding="utf-8") targets[target] = {"archive": archive.name, "checksum": checksum.name, "inventory": inventory.name, "smoke": smoke.name} evidence = root / "evidence.json" evidence.write_text(json.dumps({"schema": "ethos.npm_b_activation_evidence.v1", "core_version": version, "core_commit": "a" * 40, "targets": targets}), encoding="utf-8") diff --git a/.github/scripts/validate_npm_b_activation.py b/.github/scripts/validate_npm_b_activation.py index 5a7d79d..3e18d92 100644 --- a/.github/scripts/validate_npm_b_activation.py +++ b/.github/scripts/validate_npm_b_activation.py @@ -83,7 +83,7 @@ def validate(evidence: Path, package_root: Path, expected_version: str = "0.5.0" or smoke.get("target") != target or smoke.get("archive_sha256") != archive_hash or smoke.get("archive_size_bytes") != paths["archive"].stat().st_size - or smoke.get("version_stdout") != expected_version + or smoke.get("version_stdout") != f"ethos {expected_version}" ): fail(f"{target} smoke evidence does not bind the frozen candidate") package = load(package_root / "package.json") diff --git a/CHANGELOG.md b/CHANGELOG.md index bf20807..f61bed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ while continuing to reject unsafe and unrelated members. - ci: allow the pinned PDFium archive's nested C++ header directory. - ci: include deterministic base CLI archive evidence in the v0.5 candidate workflow. +- ci: bind npm B smoke validation to the canonical `ethos ` output. +- perf: emit compact one-request-per-line NDJSON for the v0.5 batch benchmark runner. +- boundary-exception: refresh the v0.5.0 npm B payload from frozen core-A target-smoke evidence; + no publication or public install wording change. +- docs: add a future-gated ethos-full quick install, symlink-safe launcher guidance, and macOS + Gatekeeper quarantine handling. +- test: execute the ethos-full launcher through an external symlink and bound symlink hops. - Fix Evidence Handle Bridge projection for real verification reports, stale evidence, structured state fields, strict contexts, and inert model prose. - Render complete HTML proof diagnostics with canonical schema labels and expanded variant coverage. - Cover the full verify-batch request boundary, foreign-grounding, config, and crop-rejection matrix. diff --git a/README.md b/README.md index c7bc01a..e337d38 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,38 @@ Prerequisites: - caller-provided local PDFium through `ETHOS_PDFIUM_LIBRARY_PATH` only for PDFium-backed paths (`scripts/fetch-pdfium.sh` can fetch the exact pinned evaluation archive; see the quickstart) +### 60-second `ethos-full` install (when v0.5.0 is published) + +The optional `ethos-full` archive is the shortest path to a local PDF-capable CLI. Download the +macOS arm64 or Linux x64 archive and its published SHA-256 file, verify the checksum, extract it, +and put its launcher on your `PATH`: + +```bash +target=macos-arm64 # use linux-x64 on Linux +archive="ethos-full-0.5.0-${target}.tar.gz" +curl -LO "https://github.com/docushell/ethos/releases/download/v0.5.0/${archive}" +curl -LO "https://github.com/docushell/ethos/releases/download/v0.5.0/${archive}.sha256" +shasum -a 256 -c "${archive}.sha256" +tar -xzf "${archive}" +sudo ln -sf "$PWD/ethos-full-0.5.0-${target}/ethos" /usr/local/bin/ethos +ethos --version +``` + +The launcher resolves symlinks before locating its bundled runtime, so a `/usr/local/bin/ethos` +link works without adding the extracted archive directory to `PATH`. The archive includes the +caller-provided-PDFium runtime for this optional surface; the base CLI, Python, and npm surfaces +remain caller-PDFium as described below. + +On macOS, Gatekeeper may add a quarantine attribute to browser-downloaded archives. If a trusted +archive is refused to launch, remove that attribute from the extracted directory before retrying: + +```bash +xattr -dr com.apple.quarantine "ethos-full-0.5.0-${target}" +``` + +This is an unsigned-artifact workaround; signing and notarization are separate future release +work and are not implied by this instruction. + From a source checkout: ```bash diff --git a/packages/npm/ethos-pdf/package-lock.json b/packages/npm/ethos-pdf/package-lock.json index b969d48..1c20b9e 100644 --- a/packages/npm/ethos-pdf/package-lock.json +++ b/packages/npm/ethos-pdf/package-lock.json @@ -1,12 +1,12 @@ { "name": "@docushell/ethos-pdf", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@docushell/ethos-pdf", - "version": "0.4.0", + "version": "0.5.0", "cpu": [ "arm64", "x64" diff --git a/packages/npm/ethos-pdf/package.json b/packages/npm/ethos-pdf/package.json index 7caa37e..22b51d8 100644 --- a/packages/npm/ethos-pdf/package.json +++ b/packages/npm/ethos-pdf/package.json @@ -1,6 +1,6 @@ { "name": "@docushell/ethos-pdf", - "version": "0.4.0", + "version": "0.5.0", "description": "Ethos PDF CLI binary package for document evidence workflows.", "license": "Apache-2.0", "repository": { diff --git a/packages/npm/ethos-pdf/test/platform-selection.test.js b/packages/npm/ethos-pdf/test/platform-selection.test.js index 7d2bcd5..bbf9823 100644 --- a/packages/npm/ethos-pdf/test/platform-selection.test.js +++ b/packages/npm/ethos-pdf/test/platform-selection.test.js @@ -49,7 +49,7 @@ assert.throws( const manifest = readVendorManifest(); assert.strictEqual(manifest.version, 1); assert.strictEqual(manifest.package, "@docushell/ethos-pdf"); -assert.strictEqual(manifest.cli_version, "0.4.0"); +assert.strictEqual(manifest.cli_version, "0.5.0"); validateVendorManifest(manifest); for (const [key, binaryName] of SUPPORTED_TARGETS.entries()) { assert.strictEqual(manifest.targets[key].binary, binaryName); diff --git a/packages/npm/ethos-pdf/vendor/ethos-darwin-arm64 b/packages/npm/ethos-pdf/vendor/ethos-darwin-arm64 index 6892248..0513267 100755 Binary files a/packages/npm/ethos-pdf/vendor/ethos-darwin-arm64 and b/packages/npm/ethos-pdf/vendor/ethos-darwin-arm64 differ diff --git a/packages/npm/ethos-pdf/vendor/ethos-linux-x64 b/packages/npm/ethos-pdf/vendor/ethos-linux-x64 index a09ee6c..cf0ee30 100755 Binary files a/packages/npm/ethos-pdf/vendor/ethos-linux-x64 and b/packages/npm/ethos-pdf/vendor/ethos-linux-x64 differ diff --git a/packages/npm/ethos-pdf/vendor/manifest.json b/packages/npm/ethos-pdf/vendor/manifest.json index fff9e45..b4355cc 100644 --- a/packages/npm/ethos-pdf/vendor/manifest.json +++ b/packages/npm/ethos-pdf/vendor/manifest.json @@ -1,19 +1,19 @@ { "version": 1, "package": "@docushell/ethos-pdf", - "cli_version": "0.4.0", + "cli_version": "0.5.0", "targets": { "darwin:arm64": { "binary": "ethos-darwin-arm64", - "binary_sha256": "7d0fb5407d35acc29387c1573a362c54090410e11a8072a3cd2d541195b2a84b", + "binary_sha256": "df2d46efb96501b8071cd8665ca525ee5af4787804cd04d07262354199ead913", "release_asset": "ethos-macos-arm64.tar.gz", - "release_asset_sha256": "719f092101bb1e4cfeebc9507155b15739642f0b891664bd0d9f8a05d9688ba5" + "release_asset_sha256": "30fa34afda745d168e1af39a134e2281f4a409d425765f3dc85c2e312fcbbcc2" }, "linux:x64": { "binary": "ethos-linux-x64", - "binary_sha256": "2136dcd349a7b3f73f8df83a1b1e35819f9832043eb264b3eaea341697b739ed", + "binary_sha256": "7b6b7cb03c1d16183b6cdd56f6d2ebe593a25ef257baa5b6553a0055c53e8f44", "release_asset": "ethos-linux-x64.tar.gz", - "release_asset_sha256": "616be562306d64a293554ca4695f19deb6e135dd328e88598a80e76f6f8fb3cd" + "release_asset_sha256": "592b175c00d147625f2f2ccc8bc5c74fb8a00ee37f178c363757f2c72404876e" } } } diff --git a/scripts/build-ethos-full-candidate.py b/scripts/build-ethos-full-candidate.py index 7113620..bd00ec4 100644 --- a/scripts/build-ethos-full-candidate.py +++ b/scripts/build-ethos-full-candidate.py @@ -140,7 +140,19 @@ def wrapper(runtime_path: str) -> bytes: return ( "#!/bin/sh\n" "set -eu\n" - 'root=$(CDPATH= cd -P -- "$(dirname "$0")" && pwd)\n' + 'source=$0\n' + 'hops=0\n' + 'while [ -L "$source" ]; do\n' + ' hops=$((hops + 1))\n' + ' [ "$hops" -le 40 ] || { echo "ethos launcher: symlink chain exceeds 40 hops" >&2; exit 1; }\n' + ' source_dir=$(CDPATH= cd -P -- "$(dirname "$source")" && pwd)\n' + ' source=$(readlink "$source")\n' + ' case "$source" in\n' + ' /*) ;;\n' + ' *) source="$source_dir/$source" ;;\n' + ' esac\n' + 'done\n' + 'root=$(CDPATH= cd -P -- "$(dirname "$source")" && pwd)\n' f'export ETHOS_PDFIUM_LIBRARY_PATH="$root/{runtime_path}"\n' 'exec "$root/bin/ethos" "$@"\n' ).encode("utf-8") diff --git a/scripts/measure-v0-5-performance.py b/scripts/measure-v0-5-performance.py index 7aa107d..96ae521 100644 --- a/scripts/measure-v0-5-performance.py +++ b/scripts/measure-v0-5-performance.py @@ -83,7 +83,12 @@ def main() -> int: with tempfile.TemporaryDirectory() as temporary: requests = Path(temporary) / "requests.ndjson" - requests.write_bytes((args.citations.read_bytes().rstrip(b"\n") + b"\n") * 32) + citation_request = json.dumps( + json.loads(args.citations.read_text(encoding="utf-8")), + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + requests.write_bytes((citation_request + b"\n") * 32) batch_command = [str(args.candidate_bin), "verify-batch", str(args.source), "--citations-ndjson", str(requests)] expected_batch = (expected.rstrip(b"\n") + b"\n") * 32 run_individual_32(candidate_command, expected) # non-recorded setup diff --git a/scripts/test_measure_v0_5_performance.py b/scripts/test_measure_v0_5_performance.py index b486748..9051563 100644 --- a/scripts/test_measure_v0_5_performance.py +++ b/scripts/test_measure_v0_5_performance.py @@ -22,6 +22,7 @@ def test_writes_bound_passing_record(self): b=root/v; b.write_text(FAKE); b.chmod(0o755); bins.append(b) out=root/"record.json"; log=root/"calls.log"; env=dict(os.environ,ETHOS_PERF_TEST_LOG=str(log)); r=subprocess.run(["python3",str(RUNNER),"--baseline-bin",str(bins[0]),"--candidate-bin",str(bins[1]),"--source",str(source),"--citations",str(citations),"--out",str(out)],capture_output=True,text=True,env=env) self.assertEqual(0,r.returncode,r.stderr); record=json.loads(out.read_text()); self.assertTrue(record["derived"]["passed"]); self.assertEqual(30,len(record["single_request_cold_ns"]["baseline"])); self.assertEqual(10,len(record["batch_32_ns"]["individual_processes"])); self.assertEqual(10,len(record["batch_32_ns"]["batch_process"])); self.assertEqual({"os","os_release","architecture","cpu"},set(record["environment"])) + self.assertIn("verify-batch", calls[-1]) calls=log.read_text().splitlines(); cold=calls[1:61]; self.assertEqual(["0.4.0:verify","0.5.0:verify","0.5.0:verify","0.4.0:verify"],cold[:4]) def test_rejects_wrong_version(self): with tempfile.TemporaryDirectory() as d: