diff --git a/.github/workflows/cli-distribution-check.yml b/.github/workflows/cli-distribution-check.yml index 15c446ab..7bd1eb6d 100644 --- a/.github/workflows/cli-distribution-check.yml +++ b/.github/workflows/cli-distribution-check.yml @@ -16,7 +16,7 @@ jobs: matrix: os: [ubuntu-22.04, ubuntu-22.04-arm, macos-15, macos-15-intel] runs-on: ${{ matrix.os }} - timeout-minutes: 35 + timeout-minutes: 45 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: @@ -103,11 +103,21 @@ jobs: python3 -m unittest discover -s cli/ci -p test_rehearse_npm_identity.py - name: Build, package and exercise fresh standalone and npm installations run: python3 cli/ci/rehearse_release.py --output "$RUNNER_TEMP/cli-rehearsal" --trials 1 + - name: Test Homebrew candidate archive admission + run: python3 -m unittest discover -s cli/ci -p test_homebrew_rehearsal.py + - uses: Homebrew/actions/setup-homebrew@dc7099b3e807f1e2ecc61f3ecabc840eedd5586a + - name: Rehearse Homebrew against this build's verified native archives + run: >- + "$RUNNER_TEMP/distribution-python/bin/python3" cli/ci/homebrew_rehearsal.py + --rehearsal "$RUNNER_TEMP/cli-rehearsal" + --output "$RUNNER_TEMP/cli-homebrew-rehearsal" - name: Retain rehearsal evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: cli-rehearsal-${{ matrix.os }} - path: ${{ runner.temp }}/cli-rehearsal + path: | + ${{ runner.temp }}/cli-rehearsal + ${{ runner.temp }}/cli-homebrew-rehearsal retention-days: 14 if-no-files-found: warn diff --git a/.github/workflows/cli-kernel-rc.yml b/.github/workflows/cli-kernel-rc.yml index 52bad653..df121453 100644 --- a/.github/workflows/cli-kernel-rc.yml +++ b/.github/workflows/cli-kernel-rc.yml @@ -75,6 +75,16 @@ jobs: env: RC_VERSION: ${{ inputs.version || '0.15.0-rc.1' }} run: python3 cli/ci/build_kernel_rc.py --version "$RC_VERSION" --out "$RUNNER_TEMP/kernel-rc" + - uses: Homebrew/actions/setup-homebrew@dc7099b3e807f1e2ecc61f3ecabc840eedd5586a + - name: Rehearse Homebrew against these exact release archives + env: + RC_VERSION: ${{ inputs.version || '0.15.0-rc.1' }} + EXPECTED_SOURCE: ${{ github.sha }} + run: | + "$RUNNER_TEMP/distribution-python/bin/python3" cli/ci/homebrew_rehearsal.py \ + --kernel-rc "$RUNNER_TEMP/kernel-rc" \ + --expected-source "$EXPECTED_SOURCE" --expected-version "$RC_VERSION" \ + --output "$RUNNER_TEMP/kernel-rc/homebrew" - name: Retain packages and offline evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 @@ -84,5 +94,6 @@ jobs: ${{ runner.temp }}/kernel-rc/package ${{ runner.temp }}/kernel-rc/logs ${{ runner.temp }}/kernel-rc/build-report.json + ${{ runner.temp }}/kernel-rc/homebrew retention-days: 14 if-no-files-found: warn diff --git a/cli/ci/check_workflows.py b/cli/ci/check_workflows.py index b3416c0a..dfc91211 100644 --- a/cli/ci/check_workflows.py +++ b/cli/ci/check_workflows.py @@ -17,6 +17,7 @@ "astral-sh/setup-uv": "d0d8abe699bfb85fec6de9f7adb5ae17292296ff", "oven-sh/setup-bun": "3d267786b128fe76c2f16a390aa2448b815359f3", "actions/upload-artifact": "ea165f8d65b6e75b540449e92b4886f43607fa02", + "Homebrew/actions/setup-homebrew": "dc7099b3e807f1e2ecc61f3ecabc840eedd5586a", "sigstore/cosign-installer": "828df1e55de306ba29db814d6057ddae71883cda", } JOBS = { @@ -304,6 +305,32 @@ def require(condition: bool, message: str) -> None: by_name[required_step]["run"] == command, "required qualification command drift", ) + if name in ("cli-distribution-check.yml", "cli-kernel-rc.yml"): + kernel_rc = name == "cli-kernel-rc.yml" + label = ("Rehearse Homebrew against these exact release archives" if kernel_rc + else "Rehearse Homebrew against this build's verified native archives") + homebrew = by_name[label] + setup = next(step for step in steps if step.get("uses", "").startswith("Homebrew/actions/setup-homebrew@")) + retention = next(step for step in steps if step.get("uses", "").startswith("actions/upload-artifact@")) + require(homebrew.get("if") is None and setup.get("if") is None, + "Homebrew admission must not be conditional") + require(steps.index(by_name[required_step]) < steps.index(setup) < steps.index(homebrew) < steps.index(retention), + "test already-built Homebrew archives before retaining results") + fragments = ['"$RUNNER_TEMP/distribution-python/bin/python3" cli/ci/homebrew_rehearsal.py'] + if kernel_rc: + fragments += ['--kernel-rc "$RUNNER_TEMP/kernel-rc"', '--expected-source "$EXPECTED_SOURCE"', + '--expected-version "$RC_VERSION"', '--output "$RUNNER_TEMP/kernel-rc/homebrew"'] + require(homebrew.get("env") == {"RC_VERSION": "${{ inputs.version || '0.15.0-rc.1' }}", + "EXPECTED_SOURCE": "${{ github.sha }}"}, + "exact release Homebrew admission must bind workflow source/version") + evidence_path = "${{ runner.temp }}/kernel-rc/homebrew" + else: + fragments += ['--rehearsal "$RUNNER_TEMP/cli-rehearsal"', '--output "$RUNNER_TEMP/cli-homebrew-rehearsal"'] + evidence_path = "${{ runner.temp }}/cli-homebrew-rehearsal" + require(all(fragment in homebrew.get("run", "") for fragment in fragments), + "Homebrew admission must use pinned Python and verified archive custody") + require(evidence_path in retention.get("with", {}).get("path", "").splitlines(), + "retain Homebrew custody and cleanup results") require( any( step.get("uses", "").startswith("actions/upload-artifact@") diff --git a/cli/ci/homebrew_rehearsal.py b/cli/ci/homebrew_rehearsal.py new file mode 100644 index 00000000..1d581733 --- /dev/null +++ b/cli/ci/homebrew_rehearsal.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Test Homebrew against verified development or native RC bytes; never publish.""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tarfile +from typing import Any + +import rehearse_release +import kernel_rc_evidence as custody +import publication as pub + +SAFE_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]{0,180}') +SAFE_VERSION = re.compile(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?') +PLATFORMS = {'darwin-arm64', 'darwin-x64', 'linux-arm64-gnu', 'linux-x64-gnu'} +TAP = 'openprose/cli-rehearsal' + + +def select_archives(package: Path) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + if package.is_symlink() or not package.is_dir(): + raise ValueError('A regular verified package directory is required') + path = package / 'release-manifest.json' + if path.is_symlink() or not path.is_file(): + raise ValueError('A regular package manifest is required') + manifest = json.loads(path.read_text()) + if manifest.get('schema') != 'openprose.local-release-manifest/1': + raise ValueError('Unsupported package manifest') + if not isinstance(manifest.get('version'), str) or not SAFE_VERSION.fullmatch(manifest['version']): + raise ValueError('Unsafe formula version') + if manifest.get('platform') not in PLATFORMS: + raise ValueError('Unsupported native platform') + selected = {} + for item in manifest.get('artifacts', []): + if item.get('kind') != 'standalone-archive': + continue + implementation = item.get('implementation') + name = item.get('path') + if implementation not in {'bun', 'rust'} or implementation in selected: + raise ValueError('Exactly one native archive per implementation is required') + if item.get('platform') != manifest['platform']: + raise ValueError('Mixed native archive platforms') + if not isinstance(name, str) or not SAFE_NAME.fullmatch(name) or '..' in name: + raise ValueError('Unsafe archive name') + archive = package / name + if archive.is_symlink() or not archive.is_file(): + raise ValueError('A regular native archive is required') + if type(item.get('byteLength')) is not int or not 0 < item['byteLength'] <= 512 * 1024 * 1024: + raise ValueError('Invalid archive byte limit') + if archive.stat().st_size != item['byteLength'] or hashlib.sha256(archive.read_bytes()).hexdigest() != item.get('sha256'): + raise ValueError('Native archive identity mismatch') + # Homebrew strips the package root and installs only the verified executable. + with tarfile.open(archive, 'r:gz') as contents: + members = contents.getmembers() + for member in members: + parts = Path(member.name).parts + if member.name.startswith('/') or '..' in parts or not (member.isfile() or member.isdir()): + raise ValueError('Unsafe archive member') + executables = [m for m in members if Path(m.name).name == 'prose' and m.isfile()] + if len(executables) != 1 or len(Path(executables[0].name).parts) != 2: + raise ValueError('Archive requires one prose executable under one package root') + selected[implementation] = item + if set(selected) != {'bun', 'rust'}: + raise ValueError('Both native implementations are required') + return manifest, selected + + +def formula(version: str, implementation: str, archive: Path, sha256: str) -> str: + return '\n'.join([ + f'class Prose{implementation.capitalize()} < Formula', + ' desc "Development-only Prose packaging rehearsal"', + ' homepage "https://prose.md"', f' version "{version}"', ' license "MIT"', + f' url "{archive.as_uri()}"', f' sha256 "{sha256}"', '', + ' def install', ' bin.install "prose"', ' end', '', + ' test do', f' assert_equal "prose {version} ({implementation})", shell_output("#{{bin}}/prose --version").strip', + ' end', 'end', '', + ]) + + +def verify_kernel_rc(root: Path, expected_source: str, expected_version: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """Check offline build custody, without executing or qualifying the candidate.""" + if not isinstance(expected_source, str) or not re.fullmatch(r'[0-9a-f]{40}', expected_source): + raise ValueError('An exact 40-hex expected source is required') + if not isinstance(expected_version, str) or not re.fullmatch(r'0\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*)', expected_version): + raise ValueError('An explicit expected 0.x RC version is required') + if root.is_symlink() or not root.is_dir(): + raise ValueError('A regular native RC root is required') + root = root.resolve() + for name in ('package', 'logs'): + if (root / name).is_symlink() or not (root / name).is_dir(): + raise ValueError('Regular native RC evidence directories are required') + report = pub.read_json(root / 'build-report.json') + custody.require(report.get('sourceRevision') == expected_source and report.get('version') == expected_version, + 'Native report differs from expected source/version') + pub.read_json(root / 'package/release-manifest.json') # Reject duplicate JSON keys before selection. + manifest, archives = select_archives(root / 'package') + artifacts = manifest['artifacts'] + names = {item['path'] for item in artifacts} + custody.require(len(names) == len(artifacts), 'Duplicate native artifact paths') + evidence = report.get('evidence', {}) + custody.require(isinstance(evidence, dict) and set(custody.CHECK_PATHS).union({'package/release-manifest.json'}).issubset(evidence), + 'Required native evidence is missing') + for relative, record in evidence.items(): + custody.asset_name(manifest['platform'], relative, names) + path = root / relative + custody.require(isinstance(record, dict) and set(record) == {'sha256', 'byteLength'} + and type(record['byteLength']) is int and 0 <= record['byteLength'] <= pub.MAX_BYTES + and isinstance(record['sha256'], str) and re.fullmatch(r'[0-9a-f]{64}', record['sha256']) + and path.is_file() and not path.is_symlink() + and path.stat().st_size == record['byteLength'] and pub.digest(path) == record['sha256'], + 'Native evidence identity mismatch: ' + relative) + for item in artifacts: + name = item['path'] + custody.require(isinstance(name, str) and SAFE_NAME.fullmatch(name) and '..' not in name, + 'Unsafe native artifact path') + record = evidence.get('package/' + name, {}) + custody.require(record.get('sha256') == item.get('sha256') and record.get('byteLength') == item.get('byteLength') + and type(item.get('byteLength')) is int and item['byteLength'] > 0, + 'Native artifact is not bound to report evidence') + hashes = {} + for implementation, item in archives.items(): + members = pub.archive_members(root / 'package' / item['path']) + binaries = [data for name, data in members.items() if Path(name).name == 'prose'] + custody.require(len(binaries) == 1, 'A unique native binary is required') + hashes[(implementation, manifest['platform'])] = hashlib.sha256(binaries[0]).hexdigest() + platform_artifact = next((a for a in artifacts if a.get('kind') == 'npm-platform'), None) + custody.require(platform_artifact is not None, 'Missing npm native artifact') + npm_members = pub.archive_members(root / 'package' / platform_artifact['path']) + custody.require('package/bin/prose' in npm_members + and hashlib.sha256(npm_members['package/bin/prose']).hexdigest() == hashes[('bun', manifest['platform'])], + 'npm native binary differs from standalone archive') + meta = next((a for a in artifacts if a.get('kind') == 'npm-meta'), None) + custody.require(meta is not None, 'Missing npm launcher artifact') + members = pub.archive_members(root / 'package' / meta['path']) + custody.require('package/bin/prose.js' in members, 'Missing npm launcher') + launcher_hash = hashlib.sha256(members['package/bin/prose.js']).hexdigest() + checks = {name: pub.read_json(root / 'logs' / (name + '.json')) for name in custody.CHECKS} + custody.validate_native(report, manifest, checks, hashes, launcher_hash) + # Original build trees retain these files; uploaded artifact trees retain the + # built probes instead. Both bind to the exact packaged executable hashes. + binaries = root / 'binaries' + if binaries.exists() or binaries.is_symlink(): + custody.require(binaries.is_dir() and not binaries.is_symlink(), 'Invalid compiled binary directory') + for implementation in ('bun', 'rust'): + path = binaries / ('prose-' + implementation) + custody.require(path.is_file() and not path.is_symlink() + and pub.digest(path) == hashes[(implementation, manifest['platform'])], + 'Compiled binary differs from native archive') + verified = {'packageIdentity': {implementation + 'BinarySha256': hashes[(implementation, manifest['platform'])] + for implementation in ('bun', 'rust')}, + 'manifestSha256': pub.digest(root / 'package/release-manifest.json'), + 'source': expected_source, 'custodyKind': 'kernel-rc-native', + 'nativeReportSha256': pub.digest(root / 'build-report.json'), + 'archiveIdentities': [{key: item[key] for key in ('kind', 'implementation', 'platform', 'path', 'sha256', 'byteLength')} + for item in artifacts]} + return verified, manifest, archives + + +def run(rehearsal: Path, output: Path, brew: str) -> dict[str, Any]: + verified = rehearse_release.verify_rehearsal(rehearsal) + verified = dict(verified, custodyKind='development-rehearsal') + manifest, archives = select_archives(rehearsal.resolve() / 'package') + return exercise(rehearsal.resolve() / 'package', manifest, archives, verified, output, brew) + + +def run_kernel_rc(root: Path, output: Path, brew: str, expected_source: str, expected_version: str) -> dict[str, Any]: + verified, manifest, archives = verify_kernel_rc(root, expected_source, expected_version) + return exercise(root.resolve() / 'package', manifest, archives, verified, output, brew) + + +def exercise(package: Path, manifest: dict[str, Any], archives: dict[str, Any], verified: dict[str, Any], + output: Path, brew: str) -> dict[str, Any]: + output.mkdir(exist_ok=False) + output = output.resolve() + env = {key: os.environ[key] for key in ('PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'DEVELOPER_DIR', 'SDKROOT') if key in os.environ} + env.update(HOMEBREW_NO_AUTO_UPDATE='1', HOMEBREW_NO_ANALYTICS='1', HOMEBREW_NO_INSTALL_CLEANUP='1', + HOMEBREW_CACHE=str(output / 'cache'), HOMEBREW_LOGS=str(output / 'logs'), + HOMEBREW_TEMP=str(output / 'tmp'), XDG_CONFIG_HOME=str(output / 'trust')) + for name in ('cache', 'logs', 'tmp', 'trust'): + (output / name).mkdir() + checks = [] + + def command(label: str, argv: list[str], *, expected_failure: bool = False) -> subprocess.CompletedProcess[str]: + result = subprocess.run(argv, env=env, text=True, capture_output=True, timeout=180) + (output / f'{label}.log').write_text(result.stdout + result.stderr) + checks.append({'name': label, 'exitCode': result.returncode, 'expectedFailure': expected_failure}) + if (result.returncode != 0) != expected_failure: + raise ValueError(f'Homebrew check failed: {label}; inspect its retained log') + return result + + prefix = Path(command('prefix', [brew, '--prefix']).stdout.strip()) + if TAP in command('existing-taps', [brew, 'tap']).stdout.splitlines(): + raise ValueError('Rehearsal refuses to replace an existing tap') + installed = command('existing-installations', [brew, 'list', '--formula', '--versions']).stdout + if any(line.split()[0] in {'prose-bun', 'prose-rust'} for line in installed.splitlines() if line.split()): + raise ValueError('Rehearsal requires a fresh Homebrew installation without Prose packages') + # Keep an existing command unchanged; CI runners should have no prefix/bin/prose. + active = prefix / 'bin/prose' + if active.exists() or active.is_symlink(): + raise ValueError('Rehearsal refuses to overwrite an existing prose command') + tap = output / 'tap' + (tap / 'Formula').mkdir(parents=True) + for implementation, item in archives.items(): + (tap / 'Formula' / f'prose-{implementation}.rb').write_text( + formula(manifest['version'], implementation, package / item['path'], item['sha256'])) + command('tap-init', ['git', '-C', str(tap), 'init', '-q']) + command('tap-add', ['git', '-C', str(tap), 'add', 'Formula']) + command('tap-commit', ['git', '-C', str(tap), '-c', 'user.name=Rehearsal', '-c', 'user.email=rehearsal@localhost', 'commit', '-qm', 'Local non-publishing formula rehearsal']) + command('tap', [brew, 'tap', TAP, str(tap)]) + completed = False + try: + for implementation in ('bun', 'rust'): + name = f'{TAP}/prose-{implementation}' + command(f'install-{implementation}', [brew, 'install', '--build-from-source', name]) + command(f'test-{implementation}', [brew, 'test', name]) + result = command(f'version-{implementation}', [str(active), '--version']) + if result.stdout.strip() != f'prose {manifest["version"]} ({implementation})': + raise ValueError('Homebrew selected a different implementation or version') + digest = hashlib.sha256(active.read_bytes()).hexdigest() + if digest != verified['packageIdentity'][f'{implementation}BinarySha256']: + raise ValueError('Homebrew executable differs from the verified candidate') + command(f'unlink-{implementation}', [brew, 'unlink', name]) + command('link-bun', [brew, 'link', f'{TAP}/prose-bun']) + original = hashlib.sha256(active.read_bytes()).hexdigest() + conflict = command('reject-overwrite', [brew, 'link', f'{TAP}/prose-rust'], expected_failure=True) + if 'Could not symlink' not in conflict.stdout + conflict.stderr: + raise ValueError('The rejected link did not report a symlink collision') + if hashlib.sha256(active.read_bytes()).hexdigest() != original: + raise ValueError('A failed link changed the active executable') + command('switch-unlink-bun', [brew, 'unlink', f'{TAP}/prose-bun']) + command('switch-link-rust', [brew, 'link', f'{TAP}/prose-rust']) + if hashlib.sha256(active.read_bytes()).hexdigest() != verified['packageIdentity']['rustBinarySha256']: + raise ValueError('Rust link switch did not select the verified executable') + completed = True + finally: + # Remove only packages under the newly owned rehearsal tap. + for implementation in ('bun', 'rust'): + cleaned = subprocess.run([brew, 'uninstall', f'{TAP}/prose-{implementation}'], env=env, text=True, capture_output=True, timeout=180) + (output / f'cleanup-{implementation}.log').write_text(cleaned.stdout + cleaned.stderr) + untapped = subprocess.run([brew, 'untap', TAP], env=env, text=True, capture_output=True, timeout=180) + (output / 'cleanup-tap.log').write_text(untapped.stdout + untapped.stderr) + if TAP in command('remaining-taps', [brew, 'tap']).stdout.splitlines(): + raise ValueError('The rehearsal tap did not uninstall cleanly') + remaining = command('remaining-installations', [brew, 'list', '--formula', '--versions']).stdout + if any(line.split()[0] in {'prose-bun', 'prose-rust'} for line in remaining.splitlines() if line.split()): + raise ValueError('The rehearsal packages did not uninstall cleanly') + if active.exists() or active.is_symlink(): + raise ValueError('The rehearsal command did not uninstall cleanly') + receipt = {'schema': 'openprose.homebrew-rehearsal/1', 'status': ('passed-native-rc-packaging-check' if verified['custodyKind'] == 'kernel-rc-native' else 'passed-development-packaging-check') if completed else 'failed', + 'version': manifest['version'], 'platform': manifest['platform'], 'packageIdentity': verified['packageIdentity'], + 'source': verified.get('source', verified['packageIdentity'].get('sourceRevision', manifest.get('source', {}).get('revision'))), 'custodyKind': verified['custodyKind'], + 'rehearsalManifestSha256': verified['manifestSha256'], 'checks': checks, 'modelCalls': 0, + 'publicationAuthorized': False, 'releaseQualification': False, 'uninstallPassed': True} + if 'nativeReportSha256' in verified: + receipt['nativeReportSha256'] = verified['nativeReportSha256'] + receipt['archiveIdentities'] = verified['archiveIdentities'] + (output / 'homebrew-rehearsal.json').write_text(json.dumps(receipt, indent=2) + '\n') + return receipt + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + inputs = parser.add_mutually_exclusive_group(required=True) + inputs.add_argument('--rehearsal', type=Path) + inputs.add_argument('--kernel-rc', type=Path) + parser.add_argument('--expected-source') + parser.add_argument('--expected-version') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--brew', default='brew') + args = parser.parse_args() + if args.kernel_rc: + if not args.expected_source or not args.expected_version: + parser.error('--kernel-rc requires --expected-source and --expected-version') + result = run_kernel_rc(args.kernel_rc, args.output, args.brew, args.expected_source, args.expected_version) + else: + if args.expected_source or args.expected_version: + parser.error('Expected source/version are only supported with --kernel-rc') + result = run(args.rehearsal, args.output, args.brew) + print(json.dumps(result, indent=2)) diff --git a/cli/ci/test_check_workflows.py b/cli/ci/test_check_workflows.py index b82b102a..93c488cf 100644 --- a/cli/ci/test_check_workflows.py +++ b/cli/ci/test_check_workflows.py @@ -182,6 +182,28 @@ def test_source_preloads_the_windows_helper_target(self): for step in j["steps"] if step.get("name") == "Prepare locked toolchains and dependencies" ]) + def test_homebrew_gates_cannot_be_removed_skipped_or_reordered(self): + for name, label in (("cli-distribution-check.yml", "Rehearse Homebrew against this build's verified native archives"), + ("cli-kernel-rc.yml", "Rehearse Homebrew against these exact release archives")): + index = next(i for i, step in enumerate(self.workflows[name]["jobs"][JOBS[name]]["steps"]) + if step.get("name") == label) + self.changed(name, lambda w, j, i=index: j["steps"].pop(i)) + self.changed(name, lambda w, j, i=index: j["steps"][i].update({"if": "false"})) + self.changed(name, lambda w, j, i=index: j["steps"][i].update(run="echo skipped")) + self.changed(name, lambda w, j: j["steps"].reverse()) + self.changed(name, lambda w, j: [step["with"].update(path="unrelated") + for step in j["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")]) + + def test_release_homebrew_custody_cannot_select_another_source_or_version(self): + name = "cli-kernel-rc.yml" + index = next(i for i, step in enumerate(self.workflows[name]["jobs"][JOBS[name]]["steps"]) + if step.get("name") == "Rehearse Homebrew against these exact release archives") + for key in ("EXPECTED_SOURCE", "RC_VERSION"): + self.changed(name, lambda w, j, i=index, k=key: j["steps"][i]["env"].update({k: "unreviewed"})) + for argument in ("--expected-source", "--expected-version", "--kernel-rc"): + self.changed(name, lambda w, j, i=index, a=argument: j["steps"][i].update( + run=j["steps"][i]["run"].replace(a, "--wrong-argument"))) + def test_locked_dependency_guards_and_required_commands(self): for name in JOBS: if name == "cli-publish.yml": @@ -297,3 +319,13 @@ def test_publication_verification_and_bootstrap_are_mandatory(self): if __name__ == "__main__": unittest.main() + + +class HomebrewActionAdmissionTest(unittest.TestCase): + def test_only_the_reviewed_homebrew_setup_pin_is_admitted(self): + text = (ROOT / ".github/workflows/cli-distribution-check.yml").read_text() + reviewed = "Homebrew/actions/setup-homebrew@dc7099b3e807f1e2ecc61f3ecabc840eedd5586a" + self.assertIn(reviewed, text) + self.assertEqual([], audit_workflow("cli-distribution-check.yml", text)) + for replacement in ("Homebrew/actions/setup-homebrew@main", "Homebrew/actions/setup-homebrew@" + "0" * 40): + self.assertTrue(audit_workflow("cli-distribution-check.yml", text.replace(reviewed, replacement))) diff --git a/cli/ci/test_homebrew_rehearsal.py b/cli/ci/test_homebrew_rehearsal.py new file mode 100644 index 00000000..f4c4e0bd --- /dev/null +++ b/cli/ci/test_homebrew_rehearsal.py @@ -0,0 +1,251 @@ +"""Archive admission for the non-publishing native Homebrew rehearsal.""" +import copy +import hashlib +import io +import json +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import homebrew_rehearsal as rehearsal + + +class ArchiveAdmissionTests(unittest.TestCase): + def setUp(self): + self.root = tempfile.TemporaryDirectory() + self.addCleanup(self.root.cleanup) + self.package = Path(self.root.name) + self.manifest = {'schema': 'openprose.local-release-manifest/1', 'version': '0.15.0-dev.0', + 'platform': 'darwin-arm64', 'artifacts': []} + for implementation in ('bun', 'rust'): + path = self.package / f'{implementation}.tar.gz' + with tarfile.open(path, 'w:gz') as archive: + member = tarfile.TarInfo('package/prose') + data = implementation.encode() + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + self.manifest['artifacts'].append({'kind': 'standalone-archive', 'implementation': implementation, + 'platform': 'darwin-arm64', 'path': path.name, 'byteLength': path.stat().st_size, + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) + + def select(self, manifest=None): + (self.package / 'release-manifest.json').write_text(json.dumps(manifest or self.manifest)) + return rehearsal.select_archives(self.package) + + def test_binds_both_native_archives_and_version(self): + manifest, selected = self.select() + self.assertEqual(set(selected), {'bun', 'rust'}) + self.assertEqual(manifest['version'], '0.15.0-dev.0') + rendered = rehearsal.formula(manifest['version'], 'bun', self.package / 'bun.tar.gz', selected['bun']['sha256']) + self.assertIn('bin.install "prose"', rendered) + self.assertIn((self.package / 'bun.tar.gz').as_uri(), rendered) + self.assertNotIn('pkg.prose.md', rendered) + + def test_changed_bytes_and_size_are_rejected(self): + with (self.package / 'bun.tar.gz').open('ab') as stream: + stream.write(b'changed') + with self.assertRaisesRegex(ValueError, 'identity mismatch'): + self.select() + + def test_wrong_digest_is_rejected(self): + self.manifest['artifacts'][0]['sha256'] = '0' * 64 + with self.assertRaisesRegex(ValueError, 'identity mismatch'): + self.select() + + def test_missing_duplicate_or_mixed_implementation_is_rejected(self): + for mutation in ('missing', 'duplicate', 'mixed'): + manifest = copy.deepcopy(self.manifest) + if mutation == 'missing': manifest['artifacts'].pop() + elif mutation == 'duplicate': manifest['artifacts'].append(manifest['artifacts'][0]) + else: manifest['artifacts'][0]['platform'] = 'linux-x64-gnu' + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + self.select(manifest) + + def test_unsafe_version_and_archive_path_are_rejected(self): + for version in ('0.15.0"; system("unsafe")', 'not-a-version'): + manifest = copy.deepcopy(self.manifest); manifest['version'] = version + with self.subTest(version=version), self.assertRaisesRegex(ValueError, 'version'): + self.select(manifest) + self.manifest['artifacts'][0]['path'] = '../outside.tar.gz' + with self.assertRaisesRegex(ValueError, 'archive name'): + self.select() + + def test_symlinked_archive_is_rejected(self): + path = self.package / 'bun.tar.gz'; path.rename(self.package / 'original.tar.gz') + path.symlink_to('original.tar.gz') + with self.assertRaisesRegex(ValueError, 'regular native archive'): + self.select() + + def test_unsafe_archive_member_is_rejected(self): + path = self.package / 'bun.tar.gz' + with tarfile.open(path, 'w:gz') as archive: + member = tarfile.TarInfo('../prose'); member.size = 1 + archive.addfile(member, io.BytesIO(b'x')) + item = self.manifest['artifacts'][0] + item.update(byteLength=path.stat().st_size, sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + with self.assertRaisesRegex(ValueError, 'Unsafe archive member'): + self.select() + + def test_non_regular_manifest_is_rejected(self): + target = self.package / 'original.json'; target.write_text(json.dumps(self.manifest)) + (self.package / 'release-manifest.json').symlink_to(target) + with self.assertRaisesRegex(ValueError, 'regular package manifest'): + rehearsal.select_archives(self.package) + + +class KernelRcAdmissionTests(unittest.TestCase): + SOURCE = 'a' * 40 + VERSION = '0.15.0-rc.3' + + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + (self.root / 'package').mkdir() + (self.root / 'logs').mkdir() + self.manifest = {'schema': 'openprose.local-release-manifest/1', 'mode': 'kernel-rc', + 'platform': 'darwin-arm64', 'version': self.VERSION, + 'source': {'revision': self.SOURCE, 'verification': 'matched-product-doctor'}, + 'imageSource': 'published-on-run', 'releaseEligible': False, 'publicationAuthorized': False, + 'buildProfiles': {i: {'profile': 'release', 'testSeamsEnabled': False} for i in ('bun', 'rust')}, + 'artifacts': []} + for implementation in ('bun', 'rust'): + self.archive(implementation + '.tar.gz', 'standalone-archive', implementation, + 'darwin-arm64', 'package/prose', implementation.encode()) + self.archive('npm-platform.tgz', 'npm-platform', 'bun', 'darwin-arm64', 'package/bin/prose', b'bun') + self.archive('npm-meta.tgz', 'npm-meta', 'bun', None, 'package/bin/prose.js', b'launcher') + self.checks = {} + for name in rehearsal.custody.CHECKS: + runner = 'rust' if name.endswith('-rust') else 'bun' + payload = b'launcher' if name == 'installed-npm' else runner.encode() + self.checks[name] = {'schema': 'openprose.published-release-check/1', + 'status': 'passed-offline-release-check', 'runner': runner, 'commit': self.SOURCE, + 'version': self.VERSION, 'binarySha256': hashlib.sha256(payload).hexdigest(), + 'imageSource': 'published-on-run', 'testSeamsEnabled': False, 'modelCalls': 0, 'networkCalls': 0} + if name == 'installed-npm': + self.checks[name]['nodeInterpreterSha256'] = 'b' * 64 + self.report = {'schema': 'openprose.kernel-rc-build/1', 'sourceRevision': self.SOURCE, + 'version': self.VERSION, 'platform': 'darwin-arm64', 'imageSource': 'published-on-run', + 'testSeamsEnabled': False, 'publicationAuthorized': False, 'qualification': 'offline-install-only', + 'modelCalls': 0, 'kernelFetches': 0, + 'checks': [{'name': name, 'status': 'passed'} for name in rehearsal.custody.CHECKS]} + self.write() + + def archive(self, filename, kind, implementation, platform, member_name, payload): + path = self.root / 'package' / filename + with tarfile.open(path, 'w:gz') as archive: + member = tarfile.TarInfo(member_name) + member.mode = 0o755 + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + item = {'kind': kind, 'implementation': implementation, 'platform': platform, 'path': filename, + 'byteLength': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} + self.manifest['artifacts'] = [a for a in self.manifest['artifacts'] if a['path'] != filename] + [item] + + def write(self): + (self.root / 'package/release-manifest.json').write_text(json.dumps(self.manifest)) + for name, check in self.checks.items(): + (self.root / 'logs' / (name + '.json')).write_text(json.dumps(check)) + self.report['evidence'] = {str(path.relative_to(self.root)): { + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'byteLength': path.stat().st_size} + for directory in ('package', 'logs') for path in (self.root / directory).iterdir()} + (self.root / 'build-report.json').write_text(json.dumps(self.report)) + + def verify(self, source=None, version=None): + return rehearsal.verify_kernel_rc(self.root, source or self.SOURCE, version or self.VERSION) + + def test_accepts_exact_offline_native_custody_without_execution(self): + verified, manifest, selected = self.verify() + self.assertEqual(verified['source'], self.SOURCE) + self.assertEqual(verified['custodyKind'], 'kernel-rc-native') + self.assertEqual(len(verified['archiveIdentities']), 4) + self.assertEqual(verified['packageIdentity']['bunBinarySha256'], hashlib.sha256(b'bun').hexdigest()) + self.assertEqual(manifest['version'], self.VERSION) + self.assertEqual(set(selected), {'bun', 'rust'}) + + def test_expected_source_and_version_are_mandatory_exact_anchors(self): + for source, version in [('bad', self.VERSION), ('c' * 40, self.VERSION), + (self.SOURCE, '0.15.0'), (self.SOURCE, '0.15.0-rc.4')]: + with self.subTest(source=source, version=version), self.assertRaises(ValueError): + self.verify(source, version) + + def test_invalid_expected_version_lexemes_rejected_before_report_comparison(self): + for version in ('0.015.0-rc.3', '0.15.00-rc.3', '0.15.0-rc.03', '00.15.0-rc.3', + '0.15.0-rc.-1', '0.15.0-rc.3+build'): + with self.subTest(version=version), self.assertRaisesRegex(ValueError, 'explicit expected 0.x RC version'): + self.verify(version=version) + # rc.0 is valid SemVer: it reaches the separate exact-candidate check. + with self.assertRaisesRegex(ValueError, 'report differs from expected source/version'): + self.verify(version='0.15.0-rc.0') + + def test_mixed_manifest_source_version_and_platform_rejected(self): + original = copy.deepcopy(self.manifest) + for field, value in [('source', {'revision': 'c' * 40, 'verification': 'matched-product-doctor'}), + ('version', '0.15.0-rc.4'), ('platform', 'linux-x64-gnu')]: + self.manifest = copy.deepcopy(original) + self.manifest[field] = value + self.write() + with self.subTest(field=field), self.assertRaises(ValueError): self.verify() + + def test_probe_must_bind_final_binary_and_source_even_when_rehashed(self): + original = copy.deepcopy(self.checks) + for field, value in [('binarySha256', '0' * 64), ('commit', 'c' * 40), ('version', '0.15.0-rc.4'), + ('networkCalls', 1), ('testSeamsEnabled', True)]: + self.checks = copy.deepcopy(original) + self.checks['built-bun'][field] = value + self.write() + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'Structured native check'): self.verify() + + def test_report_claims_do_not_authorize_publication_or_model_calls(self): + for field, value in [('publicationAuthorized', True), ('modelCalls', 1), ('kernelFetches', 1), + ('qualification', 'kernel-smoke-qualified')]: + original = self.report[field] + self.report[field] = value + self.write() + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'native build claims'): self.verify() + self.report[field] = original + + def test_modified_retained_probe_or_manifest_rejected(self): + for relative in ('logs/installed-rust.json', 'package/release-manifest.json'): + path = self.root / relative + original = path.read_bytes() + path.write_bytes(original + b' ') + with self.subTest(relative=relative), self.assertRaisesRegex(ValueError, 'evidence identity'): self.verify() + path.write_bytes(original) + + def test_modified_archive_rejected(self): + for name in ('bun.tar.gz', 'npm-meta.tgz', 'npm-platform.tgz'): + path = self.root / 'package' / name + original = path.read_bytes() + path.write_bytes(original + b'changed') + with self.subTest(name=name), self.assertRaises(ValueError): self.verify() + path.write_bytes(original) + + def test_rehashed_different_npm_binary_rejected(self): + self.archive('npm-platform.tgz', 'npm-platform', 'bun', 'darwin-arm64', 'package/bin/prose', b'other') + self.write() + with self.assertRaisesRegex(ValueError, 'npm native binary differs'): self.verify() + + def test_optional_original_compiled_binaries_are_bound(self): + binaries = self.root / 'binaries'; binaries.mkdir() + for implementation in ('bun', 'rust'): + (binaries / ('prose-' + implementation)).write_bytes(implementation.encode()) + self.verify() + (binaries / 'prose-bun').write_bytes(b'other') + with self.assertRaisesRegex(ValueError, 'Compiled binary'): self.verify() + + def test_missing_structured_evidence_and_symlink_directories_rejected(self): + del self.report['evidence']['logs/built-bun.json'] + (self.root / 'build-report.json').write_text(json.dumps(self.report)) + with self.assertRaisesRegex(ValueError, 'Required native evidence'): self.verify() + self.write() + (self.root / 'logs').rename(self.root / 'real-logs') + (self.root / 'logs').symlink_to('real-logs') + with self.assertRaisesRegex(ValueError, 'directories'): self.verify() + + def test_unsafe_report_path_rejected_before_read(self): + self.report['evidence']['../outside'] = {'sha256': '0' * 64, 'byteLength': 1} + (self.root / 'build-report.json').write_text(json.dumps(self.report)) + with self.assertRaisesRegex(ValueError, 'Unsafe evidence path'): self.verify() diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index d655d251..cdaba0bf 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -778,6 +778,48 @@ IMP-086 lease extension: `cli/conformance/fixtures/adapter-host-expectations.jso IMP-086 lease extension: `cli/ci/test_run_local.py` solely to make the interrupt-tree fixture reap its controlled descendant and publish readiness after signal-safe setup; supervisor behavior, 130/143 exits and PID-absence assertions remain unchanged. +## IMP-014: user-selected Homebrew implementation packages (October 5, 2026) + +Codex `/root` owns sole edits and Git in isolated +`codex/imp-014-homebrew-docs`, from current main. Exact paths: +`cli/protocol/OWNERSHIP.md`, `docs/cli-distribution.md`, +`docs/cli-release-next.md`. Scope: record user-selected public tap formulae +`prose-bun` and `prose-rust`, both providing `prose`, native link switching, +explicit RC versions and actual installation evidence. No implementation +default, runtime, release artifact, Apple identity or model spending is claimed. +The Homebrew subagent owns only the new tap formula/readme/CI/record paths +under root Git/integration ownership; other owners' leases remain unchanged. + +IMP-014 Homebrew automation lease extension: root owns exact paths +`.github/workflows/cli-distribution-check.yml`, `cli/ci/homebrew_rehearsal.py`, +`cli/ci/test_homebrew_rehearsal.py`. Scope: provider-free Homebrew install/link +regression checks against the existing verified local rehearsal's actual +archives on its four native runner platforms, before release. Development +rehearsal bytes remain private/non-publishing and cannot qualify a release. + +IMP-014 workflow admission extension: root owns `cli/ci/check_workflows.py` +and `cli/ci/test_check_workflows.py` to admit only the reviewed immutable +Homebrew setup action and retain mutable/unreviewed action rejection. + +IMP-014 admission fixture extension: root owns only +`failure_settlement_drains_diagnostics_under_channel_backpressure` in +`cli/rust/crates/prose-process-supervisor/src/supervisor.rs` and +`run_deadline_does_not_hang_on_an_escaped_descendant_retaining_stream_pipes` in +`cli/rust/crates/prose-process-supervisor/tests/fake_harness.rs`, for test +fixture timing budgets and explanatory comments. Preserve all assertions and +production constants; no runtime implementation is leased. This follows two +retained macOS admission failures and a matching existing-main fixture failure. +Root retains Git; subagent review is read-only. + +IMP-014 exact release-archive Homebrew extension: root owns +`.github/workflows/cli-kernel-rc.yml` and retains the helper/test/registry +leases above. The Homebrew subagent edits only `cli/ci/homebrew_rehearsal.py` +and `cli/ci/test_homebrew_rehearsal.py`; root owns workflow edits and all Git. +Scope: verify expected native RC source/version and report-bound archive/binary +identities, then exercise the same install/link/switch/uninstall checks against +those already-built release bytes. No rebuild, relabel, live qualification, +publication authority or production runtime change. + ## IMP-089 Bun natural-exit diagnosis — October 5, 2026 Root authorizes `/root/bun_cleanup` on branch `codex/imp-089-bun-cleanup` diff --git a/cli/rust/crates/prose-process-supervisor/src/supervisor.rs b/cli/rust/crates/prose-process-supervisor/src/supervisor.rs index d01a399b..b7f4a746 100644 --- a/cli/rust/crates/prose-process-supervisor/src/supervisor.rs +++ b/cli/rust/crates/prose-process-supervisor/src/supervisor.rs @@ -2257,7 +2257,10 @@ mod tests { &receiver, &mut diagnostic, 8 * 1024 * 1024, - PROBE_READER_STOP_TIMEOUT, + // This fixture tests lossless draining under channel backpressure, + // not a 250 ms throughput guarantee for 4 MiB on a loaded CI host. + // Production settlement keeps PROBE_READER_STOP_TIMEOUT unchanged. + Duration::from_secs(2), ); assert_eq!(settlements, (Some(false), Some(false))); diff --git a/cli/rust/crates/prose-process-supervisor/tests/fake_harness.rs b/cli/rust/crates/prose-process-supervisor/tests/fake_harness.rs index 374222ab..e5574c21 100644 --- a/cli/rust/crates/prose-process-supervisor/tests/fake_harness.rs +++ b/cli/rust/crates/prose-process-supervisor/tests/fake_harness.rs @@ -529,7 +529,10 @@ os._exit(0) identity_path.clone().into_os_string(), ]; spec.version_probe = None; - spec.run_timeout = Duration::from_millis(150); + // The child may spend up to two seconds establishing its escaped identity. + // Allow that fixture setup and terminal emission before testing the retained + // pipe's deadline; a 150 ms startup assumption races on loaded CI hosts. + spec.run_timeout = Duration::from_secs(3); spec.termination_grace = Duration::from_millis(50); let started = Instant::now(); let error = supervise(spec, &JsonlProtocol::fake_harness()).unwrap_err(); @@ -540,7 +543,7 @@ os._exit(0) let _cleanup = EscapedProcess { pid, process_group }; assert_eq!(error.kind, FailureKind::CleanupFailed); assert!(error.terminal_observed); - assert!(started.elapsed() < Duration::from_secs(2)); + assert!(started.elapsed() < Duration::from_secs(5)); } #[test] diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 48a2d40c..1264f13e 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -1,9 +1,68 @@ # CLI distribution (IMP-014) -Current status (October 2, 2026): unsigned `0.15.0-rc.1` is public through -GitHub and pkg.prose.md. npm remains pending bootstrap; Homebrew remains a -proposal. [Publication setup](cli-publication.md) owns current policy and -[next-candidate preparation](cli-release-next.md) owns the next release gates. +Current status (October 5, 2026): unsigned `0.15.0-rc.2` is published through +npm, GitHub and pkg.prose.md. Homebrew tap preparation is in progress with +user-selected formula names `prose-bun` and `prose-rust`, both providing the +command `prose`. Neither is designated the default. +[Publication setup](cli-publication.md) owns signing/publication policy; +[next-candidate preparation](cli-release-next.md) owns current runtime gaps and +release gates. + +## Homebrew installation and implementation selection + +The selected tap is `openprose/tap`. Each formula selects a verified immutable +release; the current candidate version is `0.15.0-rc.2`. RC status appears in +its version and installation caveats, rather than a package-name suffix. +Both implementations are prebuilt; no Bun or Rust compiler is required. + +After the tap installation checks pass, select one implementation: + +```sh +brew install openprose/tap/prose-bun +# Or select Rust instead: +brew install openprose/tap/prose-rust +prose --version +``` + +Both packages provide `prose`. To switch from Bun to Rust without overwriting +the active command: + +```sh +brew unlink prose-bun +brew install openprose/tap/prose-rust +# If Rust is already installed, use: brew link prose-rust +prose --version +``` + +Switch back with `brew unlink prose-rust` and `brew link prose-bun`. Homebrew +retains both installed kegs, with one linked command. An attempted simultaneous +link refuses to overwrite the existing `prose`; do not use `--overwrite`. +An existing npm installation may precede Homebrew in PATH. Check the actual +executable and version rather than assuming the selected package wins PATH. + +These are experimental prereleases. macOS binaries are not Developer ID signed +or notarized; Linux requires glibc 2.34 or newer. Install and authenticate a +supported harness separately. A CLI executable does not pin the selected +kernel or harness. Homebrew packaging does not qualify program fulfillment. +The existing public RC2 remains behind main's merged Prime fix and does not +claim the separately observed cleanup failure repaired. + +No plain `prose` package alias is selected until the implementation default is +decided. Homebrew needs no separate publisher account; the tap uses the existing +GitHub organization. Future formula updates select a newly qualified public +release and pass installation/switching tests before merge. CLI distribution CI +first tests Homebrew against this checkout's verified development-rehearsal +archives on all four native runners. These local-only bytes never become tap +releases. The unsigned candidate build also runs the same checks against its +already-built kernel-RC archives, binding the expected source/version, native +custody reports and executable hashes. A failed platform withholds the candidate +cohort; neither packaging check grants live qualification. The tap update workflow follows the guarded public RC pointer, +verifies the immutable manifest digest and prepares a formula update branch. +Its own-repository CI is explicitly dispatched for bot-created branches. The +release owner opens the checked PR using existing GitHub authentication, +respecting the organization policy against Actions-created PRs. Merging that +checked PR selects the new Homebrew release. This avoids a separate +cross-repository publishing token and preserves a reviewable update boundary. ## Historical implementation record — September 16–17, 2026 @@ -29,15 +88,11 @@ historical test tooling and is not a public migration plan. See [Publication and signing](cli-publication.md) for the exact OIDC identity and release gates. -Standalone Bun and Rust downloads remain available independently. Proposed -Homebrew setup: a public `openprose/homebrew-tap` GitHub repository, with Rust -installed by `brew install openprose/tap/prose`, and an explicit `prose-bun` -alternative that avoids executable collisions. These formula names and the Rust -default are proposals, not deployed decisions. No separate Homebrew publisher -account is required. The distribution repository can render formulae from -qualified exact artifact plans. Stable formulae require all four declared -platforms; RC/development consumers use explicit versioned artifacts until a -separate prerelease formula policy is agreed. +Standalone Bun and Rust downloads remain available independently. The historical +Rust-default and `prose-bun` alternate-command proposal below is superseded by +the October 5 user-selected implementation package names and common `prose` +command above. Existing stable-only tooling remains the default, with explicit +RC version/qualification admission added for the new tap. ## Local build and installation rehearsal diff --git a/docs/cli-release-next.md b/docs/cli-release-next.md index 478094f3..587e56f4 100644 --- a/docs/cli-release-next.md +++ b/docs/cli-release-next.md @@ -1,6 +1,6 @@ # CLI release status and next candidate — October 5, 2026 -Published RC2 is behind current main runtime after [PR33](https://github.com/openprose/prose-cli/pull/33) merged on October 5. See the [current source/release difference](#current-source-and-release-difference) below. +Published RC2 is behind current main runtime after [PR33](https://github.com/openprose/prose-cli/pull/33) and [PR42](https://github.com/openprose/prose-cli/pull/42) merged on October 5. See the [current source/release difference](#current-source-and-release-difference) below. Unsigned `0.15.0-rc.2` is published on [npm](https://www.npmjs.com/package/@openprose/prose-cli/v/0.15.0-rc.2) and as a @@ -43,13 +43,10 @@ Earlier exact macOS payloads retain their original main-workflow provenance; recovery reused their matching bytes. Keep runtime and publisher identities separate in receipts. -The finance/context research owner is -investigating Prime event-history compatibility under IMP-083. Its longer probe -found missing tool-start events, and the compatibility candidate remains -unqualified. Do not merge it, weaken parsing or count its paid research runs as -release qualification. Refresh its workspace record and current remote main -before selecting source. Either integrate an independently qualified repair or -explicitly disclose the affected Prime route and review release scope. +Prime PR33 is integrated after independent review and all fifteen checks. +Its repair is bounded to missing results for completed tools with corroborating +same-producer history. Broader native event loss and the longer finance workflow +remain unqualified; no research budget or run qualifies this release. IMP-082's reproduced OMP cleanup defect is repaired and qualified on integrated main. Its distinct historical Bun timing observation remains unexplained; the release does not claim that separate observation repaired. @@ -59,6 +56,22 @@ of kernel `0.2.0-rc.1` is separately owned and cannot be bypassed by this releas A CLI version pins executable bytes; normal startup can still select a moving kernel. Record the observed kernel identity in the new live evidence. +## Assigned successor preparation + +The user assigned a small follow-up candidate after compatibility PR42 lands. +It is merged at `625106e80c202e6e3916eb6cca8fd0dba2fb62a4`; refresh final main +and its checks before freezing release source. The expected next label is +`0.15.0-rc.3`, subject to authoritative unused-version preflight. Homebrew's +candidate and tap integration must be checked before publication. No stable +promotion or default implementation selection is implied. + +The explicit `--codex-compatibility probe` option permits mechanically admitted +unqualified Codex versions on supported POSIX hosts; default qualification remains +unchanged. Native `0.159.0-alpha.12.1` has provider-free capability/doctor evidence, +not model-execution qualification. The closed RC2 live allocation cannot be +reused. Prepare exact-byte artifacts and a concrete fresh bounded live scope +before requesting any remaining allocation; make no paid calls before approval. + ## Gates for the next unused candidate 1. Review the final changes and exact remote-main source, including the CLI @@ -74,6 +87,10 @@ kernel. Record the observed kernel identity in the new live evidence. Read the run's actual source SHA immediately. If main moved, inspect the new source and gates; do not treat the previously inspected head as its identity. + Each native job must also pass Homebrew installation, protected linking, + implementation switching and clean uninstall against those exact RC archives. + Retained results bind the expected source/version and verified native custody; + they do not replace the separate live qualification. Download all four platform artifacts from that one run and verify native reports, standalone and npm inventories. Never rebuild between qualification and publication or reuse automatic runs labeled with the old public version. @@ -166,11 +183,12 @@ Use the existing unsigned-RC policy only with explicit disclosure and concrete candidate approval. Sigstore supplies workflow/byte provenance, not Developer ID or notarization. A signed release must use a new immutable version. -Homebrew tap/formula work remains deferred; no separate Homebrew account is -required. No accessible `openprose/homebrew-tap` was found, formula -names and default implementation remain proposals, and current formula tooling -requires stable artifacts. Adding an RC tap now would add an unqualified channel -without resolving Apple prerequisites. +Homebrew preparation is now user-assigned. The selected formula names are +`prose-bun` and `prose-rust`, both providing `prose`; no implementation default +or plain `prose` alias is chosen. Prerelease status stays in the version and +caveats. [Installation and switching](cli-distribution.md#homebrew-installation-and-implementation-selection) +uses native Homebrew unlink/link and preserves immutable RC2 bytes. Apple +signing remains separate; Homebrew does not remove unsigned-RC limitations. ## October 5 runtime parity audit