diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58d8cdd8..091fbbe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,24 @@ jobs: - name: Run release-smoke tests run: python3 -m unittest discover -s tests/ReleaseSmokeTests -p 'test_*.py' + release-installation-retries: + name: Release installation retries (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.x" + - name: Verify installation failure handling + run: python -m unittest discover -s tests/ReleaseSmokeTests -p test_install_release_package.py + worker-process-identity: name: Worker process identity (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9cf7be4..5715bc78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,9 @@ on: - credential-preflight - prepare-release - verify-published + - verify-installation tag: - description: Existing empty draft release tag (required for prepare-release) + description: Draft tag for prepare-release, or published tag for verification required: false type: string publication: @@ -28,13 +29,13 @@ on: types: [published] concurrency: - group: release-${{ inputs.tag || github.event.release.tag_name || 'credential-preflight' }} + group: ${{ inputs.operation == 'verify-installation' && 'installation' || 'release' }}-${{ inputs.tag || github.event.release.tag_name || 'credential-preflight' }} cancel-in-progress: false jobs: preflight-release-app: name: Verify release App - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.operation != 'verify-installation' runs-on: ubuntu-latest timeout-minutes: 5 environment: release @@ -802,10 +803,59 @@ jobs: ) done + installation-target: + name: Resolve existing release for installation checks + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'verify-installation' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + source-sha: ${{ steps.target.outputs.source-sha }} + version: ${{ steps.target.outputs.version }} + steps: + - name: Resolve and verify published release + id: target + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + import subprocess + + tag = os.environ["TAG"] + numeric = r"(?:0|[1-9][0-9]*)" + identifier = rf"(?:{numeric}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + pattern = rf"v{numeric}\.{numeric}\.{numeric}(?:-{identifier}(?:\.{identifier})*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + if not re.fullmatch(pattern, tag): + raise SystemExit("Installation verification requires an existing v tag.") + release = json.loads(subprocess.check_output( + ["gh", "release", "view", tag, "--json", "isDraft,isImmutable,tagName"], text=True)) + if release["isDraft"] or not release["isImmutable"] or release["tagName"] != tag: + raise SystemExit("Installation verification requires a published immutable release.") + subprocess.run(["gh", "release", "verify", tag], check=True) + sha = subprocess.check_output( + ["gh", "api", f"repos/{os.environ['GH_REPO']}/commits/{tag}", "--jq", ".sha"], + text=True).strip() + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise SystemExit("Could not resolve the release source commit.") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"source-sha={sha}\nversion={tag[1:]}\n") + print(f"Verify installed {tag} from {sha}; installation code comes from the dispatched workflow ref.") + PY + smoke-homebrew-installation: name: Smoke Homebrew on ${{ matrix.os }} - if: ${{ !cancelled() && needs.update-package-managers.result == 'success' }} - needs: [published-release, update-package-managers] + if: >- + !cancelled() && + (needs.update-package-managers.result == 'success' || needs.installation-target.result == 'success') + needs: [published-release, update-package-managers, installation-target] runs-on: ${{ matrix.os }} timeout-minutes: 15 permissions: @@ -819,13 +869,16 @@ jobs: - name: Check out smoke test uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.published-release.outputs.source-sha }} + ref: ${{ inputs.operation == 'verify-installation' && github.sha || needs.published-release.outputs.source-sha }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" + - name: Verify installation failure handling + run: python -m unittest discover -s tests/ReleaseSmokeTests -p test_install_release_package.py + - name: Prepare Homebrew shell: bash run: | @@ -839,20 +892,37 @@ jobs: brew --version + - name: Tap Highbyte formulas + shell: bash + run: brew tap highbyte/tap + - name: Install Wrighty from Homebrew shell: bash + env: + SOURCE_SHA: ${{ needs.installation-target.outputs.source-sha || needs.published-release.outputs.source-sha }} + VERSION: ${{ needs.installation-target.outputs.version || needs.published-release.outputs.version }} run: | set -euo pipefail + python3 scripts/install-release-package.py \ + --manager homebrew \ + --version "$VERSION" \ + --source-sha "$SOURCE_SHA" \ + --log-directory "$RUNNER_TEMP/package-install-logs" - brew tap highbyte/tap - brew install highbyte/tap/wrighty - command -v wrighty + - name: Retain installation diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: homebrew-install-${{ matrix.os }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/package-install-logs + if-no-files-found: ignore + retention-days: 7 - name: Smoke installed Wrighty shell: bash env: - SOURCE_SHA: ${{ needs.published-release.outputs.source-sha }} - VERSION: ${{ needs.published-release.outputs.version }} + SOURCE_SHA: ${{ needs.installation-target.outputs.source-sha || needs.published-release.outputs.source-sha }} + VERSION: ${{ needs.installation-target.outputs.version || needs.published-release.outputs.version }} run: | python3 scripts/smoke-release-cli.py \ --cli "$(command -v wrighty)" \ @@ -874,8 +944,10 @@ jobs: smoke-scoop-installation: name: Smoke Scoop on Windows - if: ${{ !cancelled() && needs.update-package-managers.result == 'success' }} - needs: [published-release, update-package-managers] + if: >- + !cancelled() && + (needs.update-package-managers.result == 'success' || needs.installation-target.result == 'success') + needs: [published-release, update-package-managers, installation-target] runs-on: windows-latest timeout-minutes: 15 permissions: @@ -885,13 +957,16 @@ jobs: - name: Check out smoke test uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.published-release.outputs.source-sha }} + ref: ${{ inputs.operation == 'verify-installation' && github.sha || needs.published-release.outputs.source-sha }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" + - name: Verify installation failure handling + run: python -m unittest discover -s tests/ReleaseSmokeTests -p test_install_release_package.py + - name: Install Scoop id: scoop shell: pwsh @@ -932,15 +1007,22 @@ jobs: SCOOP: ${{ steps.scoop.outputs.root }} SCOOP_CMD: ${{ steps.scoop.outputs.scoop }} SCOOP_ROOT: ${{ steps.scoop.outputs.root }} + SOURCE_SHA: ${{ needs.installation-target.outputs.source-sha || needs.published-release.outputs.source-sha }} + VERSION: ${{ needs.installation-target.outputs.version || needs.published-release.outputs.version }} run: | & $env:SCOOP_CMD bucket add highbyte https://github.com/highbyte/scoop-bucket if ($LASTEXITCODE -ne 0) { throw "Could not add the Highbyte Scoop bucket." } - & $env:SCOOP_CMD install highbyte/wrighty + python scripts/install-release-package.py ` + --manager scoop ` + --scoop-root $env:SCOOP_ROOT ` + --version $env:VERSION ` + --source-sha $env:SOURCE_SHA ` + --log-directory (Join-Path $env:RUNNER_TEMP 'package-install-logs') if ($LASTEXITCODE -ne 0) { - throw "Could not install Wrighty from Scoop." + throw "Could not install and verify Wrighty from Scoop. See installation diagnostics." } $cli = Join-Path $env:SCOOP_ROOT 'shims\wrighty.exe' @@ -949,12 +1031,21 @@ jobs: } "cli=$cli" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + - name: Retain installation diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scoop-install-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/package-install-logs + if-no-files-found: ignore + retention-days: 7 + - name: Smoke installed Wrighty shell: pwsh env: CLI: ${{ steps.wrighty.outputs.cli }} - SOURCE_SHA: ${{ needs.published-release.outputs.source-sha }} - VERSION: ${{ needs.published-release.outputs.version }} + SOURCE_SHA: ${{ needs.installation-target.outputs.source-sha || needs.published-release.outputs.source-sha }} + VERSION: ${{ needs.installation-target.outputs.version || needs.published-release.outputs.version }} run: | python scripts/smoke-release-cli.py ` --cli $env:CLI ` diff --git a/docs/development/repository-maintenance.md b/docs/development/repository-maintenance.md index 400f825b..46d09f52 100644 --- a/docs/development/repository-maintenance.md +++ b/docs/development/repository-maintenance.md @@ -232,6 +232,44 @@ Immediate publication makes the completed draft immutable, verifies the release updates the Homebrew tap and Scoop bucket with the App token, installs the packages on their supported runners, repeats the Local Markdown smoke test, and uninstalls the package. +The Homebrew and Scoop Wrighty installation steps allow at most three attempts, waiting 10 and +30 seconds after recognized transient failures such as broken pipes, connection resets, DNS +failures, timeouts, or HTTP 502/503/504 responses. Unknown errors, authentication/permission +denials, and integrity failures stop immediately. These retries cover the Wrighty package install; +package-manager bootstrap, tap/bucket setup, release downloads, publication, and repository pushes +are not retried by this helper. + +Each installation check requires a clean disposable runner. Before retrying, it inspects the +package directory and executable. If installation completed despite a transient command failure, +the exact CLI version and source commit must match before proceeding to the separate functional +smoke test. Partial installations or version mismatches stop instead of being repaired or retried. +The job timeout still applies, and the existing cleanup step runs on failure as well as success. + +The Actions job summary records attempts and recovery, and seven-day `homebrew-install-*` or +`scoop-install-*` artifacts retain each attempt's exit code and output. Homebrew installation uses +verbose output; the helper does not dump the environment. Automatic retries never bypass checksum, +attestation, version, or functional checks. A terminal workflow failure still requires maintainer +approval before a manual rerun. + +To verify installation workflow changes without publishing another release, dispatch the existing +Release workflow with `operation=verify-installation`, selecting the branch that contains the +changes and the published tag currently served by the Homebrew tap and Scoop bucket: + +```shell +gh workflow run release.yml --repo highbyte/wrighty --ref YOUR_BRANCH \ + -f operation=verify-installation -f tag=v0.19.0-alpha -f publication=draft +``` + +This operation uses only read access to Wrighty's repository and does not access the release App +or modify releases, tags, the tap, or the bucket. It verifies the existing immutable release and +resolves its source commit, then runs the shared Homebrew and Scoop installation jobs using the +helper and tests from the dispatched workflow commit. The `publication` input is ignored for this +operation. Installation failure-handling tests, real package installs, exact version/commit checks, +Local Markdown smoke tests, and cleanup run on Linux, macOS, and Windows. Selecting a release +different from the version currently served by the package managers fails the version check; it +does not silently test a different version. This also works before the branch is merged, because +`release.yml` already exists on the default branch. + If the maintainer chooses a draft, the workflow stops after verified assets are attached. Later publishing that draft through GitHub triggers public-release verification, package-manager updates, installation smoke tests, and cleanup. diff --git a/scripts/install-release-package.py b/scripts/install-release-package.py new file mode 100644 index 00000000..96c1fb98 --- /dev/null +++ b/scripts/install-release-package.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Install Wrighty on a disposable release runner with bounded transient retries.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import time + + +DELAYS = (10, 30) +# Denials and integrity failures take precedence even when another line mentions a +# transient error. Unknown errors stop too; never retry arbitrary installer failures. +PERMANENT = re.compile( + r"(?:checksum|sha-?256|hash|signature|attestation)[^\n]*(?:mismatch|invalid|failed|does not match)" + r"|(?:permission|access) (?:is )?denied|unauthorized|forbidden" + r"|(?:HTTP[^\n]*|returned (?:an )?error:|status code(?: does not indicate success)?:)\s*\(?(?:401|403|404)\b" + r"|certificate[^\n]*(?:invalid|expired|verify failed)" + r"|no available formula|couldn.t find manifest|unknown command|invalid argument", + re.IGNORECASE, +) +TRANSIENT = re.compile( + r"broken pipe|connection (?:reset|aborted|timed out)|remote end hung up unexpectedly" + r"|could not resolve host|temporary failure in name resolution" + r"|(?:operation|request) timed out" + r"|(?:HTTP[^\n]*|returned (?:an )?error:|status code(?: does not indicate success)?:)\s*\(?50[234]\b" + r"|curl: \((?:5|6|7|18|28|52|55|56)\)", + re.IGNORECASE, +) + + +def retryable(exit_code: int, output: str) -> bool: + return ( + exit_code > 0 + and exit_code not in (130, 143) + and not PERMANENT.search(output) + and bool(TRANSIENT.search(output)) + ) + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", check=False, + ) + + +def brew_path(option: str) -> Path: + result = run(["brew", option]) + if result.returncode != 0 or not result.stdout.strip(): + raise RuntimeError(f"Could not inspect Homebrew {option}: {result.stdout}") + return Path(result.stdout.strip()) + + +def package_paths(manager: str, scoop_root: Path | None) -> tuple[Path, Path]: + if manager == "homebrew": + return brew_path("--cellar") / "wrighty", brew_path("--prefix") / "bin" / "wrighty" + if scoop_root is None: + raise RuntimeError("Scoop installation requires --scoop-root.") + return scoop_root / "apps" / "wrighty", scoop_root / "shims" / "wrighty.exe" + + +def present(path: Path) -> bool: + # Include dangling links left by interrupted installations. + return path.exists() or path.is_symlink() + + +def inspect_installation(package: Path, cli: Path, expected_version: str) -> bool: + """Return false only for an absent install; ambiguous/partial state must stop.""" + if not present(package) and not present(cli): + return False + if not package.is_dir() or not cli.is_file(): + raise RuntimeError("Partial Wrighty installation detected; refusing another install attempt.") + result = run([str(cli), "--version"]) + if result.returncode != 0 or result.stdout.strip() != expected_version: + raise RuntimeError( + f"Installed Wrighty failed version verification (expected {expected_version!r}, " + f"exit {result.returncode}): {result.stdout.strip()}" + ) + return True + + +def report(message: str) -> None: + print(message, flush=True) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as destination: + destination.write(message + "\n\n") + + +def install( + manager: str, command: list[str], package: Path, cli: Path, + expected_version: str, log_directory: Path, +) -> None: + if present(package) or present(cli): + raise RuntimeError("Wrighty is already present; this check requires a clean disposable runner.") + log_directory.mkdir(parents=True, exist_ok=True) + for attempt in range(1, len(DELAYS) + 2): + report(f"{manager}: Wrighty installation attempt {attempt}/3.") + result = run(command) + log = log_directory / f"{manager}-attempt-{attempt}.log" + # Record the original failure before inspecting state or retrying. No environment dump. + log.write_text(f"Exit code: {result.returncode}\n{result.stdout}", encoding="utf-8") + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n", flush=True) + if result.returncode == 0: + if not inspect_installation(package, cli, expected_version): + raise RuntimeError("Installer exited successfully but Wrighty is missing.") + report(f"{manager}: installed and version verified on attempt {attempt}/3; functional smoke test still required.") + return + if not retryable(result.returncode, result.stdout): + raise RuntimeError(f"{manager}: non-retryable installation failure (exit {result.returncode}); see {log}.") + # A transient error may happen after installation. Verify exact version/commit + # before accepting that state; never repair a partial or wrong-version install. + if inspect_installation(package, cli, expected_version): + report(f"{manager}: command failed on attempt {attempt}/3, but installation completed and version verified; functional smoke test still required.") + return + if attempt > len(DELAYS): + raise RuntimeError(f"{manager}: transient installation failure persisted after 3 attempts; see {log}.") + delay = DELAYS[attempt - 1] + report(f"{manager}: recognized transient failure (exit {result.returncode}); retrying in {delay}s. Log: {log.name}.") + time.sleep(delay) + # Recheck after the delay as well, so a late-completing installer is not run twice. + if inspect_installation(package, cli, expected_version): + report(f"{manager}: installation completed during retry delay and version verified; functional smoke test still required.") + return + + +def install_command(manager: str) -> list[str]: + if manager == "homebrew": + return ["brew", "install", "--verbose", "highbyte/tap/wrighty"] + # The workflow supplies SCOOP_CMD as a path; never interpolate it into shell code. + if not os.environ.get("SCOOP_CMD"): + raise RuntimeError("Scoop installation requires SCOOP_CMD.") + return [ + "pwsh", "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference = 'Stop'; & $env:SCOOP_CMD install highbyte/wrighty; exit $LASTEXITCODE", + ] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manager", choices=("homebrew", "scoop"), required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--log-directory", type=Path, required=True) + parser.add_argument("--scoop-root", type=Path) + args = parser.parse_args() + package, cli = package_paths(args.manager, args.scoop_root) + separator = "." if "+" in args.version else "+" + install(args.manager, install_command(args.manager), package, cli, + f"{args.version}{separator}{args.source_sha}", args.log_directory) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError) as error: + report(f"Package installation stopped: {error}") + sys.exit(1) diff --git a/tests/ReleaseSmokeTests/test_install_release_package.py b/tests/ReleaseSmokeTests/test_install_release_package.py new file mode 100644 index 00000000..2210132e --- /dev/null +++ b/tests/ReleaseSmokeTests/test_install_release_package.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "install-release-package.py" +SPEC = importlib.util.spec_from_file_location("install_release_package", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Could not load {SCRIPT}") +INSTALL = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(INSTALL) + + +class PackageInstallTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.package = self.root / "package" + self.cli = self.root / "wrighty" + self.logs = self.root / "logs" + self.summary = self.root / "summary.md" + self.version = "0.19.0-alpha+" + "a" * 40 + self.command = ["package-manager", "install", "wrighty"] + self.enterContext(patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(self.summary)})) + self.enterContext(redirect_stdout(io.StringIO())) + self.sleep = self.enterContext(patch.object(INSTALL.time, "sleep")) + + def result(self, code: int, output: str) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(self.command, code, output) + + def complete_files(self) -> None: + self.package.mkdir(exist_ok=True) + self.cli.touch() + + def install(self) -> None: + INSTALL.install("fixture", self.command, self.package, self.cli, self.version, self.logs) + + def test_broken_pipe_then_success_keeps_both_logs_and_reports_recovery(self) -> None: + def execute(command): + if command == [str(self.cli), "--version"]: + return self.result(0, self.version + "\n") + if not (self.logs / "fixture-attempt-1.log").exists(): + return self.result(1, "Broken pipe\n") + self.complete_files() + return self.result(0, "Installed\n") + with patch.object(INSTALL, "run", side_effect=execute) as run: + self.install() + self.assertEqual(3, run.call_count) + self.sleep.assert_called_once_with(10) + self.assertIn("Broken pipe", (self.logs / "fixture-attempt-1.log").read_text()) + self.assertIn("Installed", (self.logs / "fixture-attempt-2.log").read_text()) + self.assertIn("verified on attempt 2/3", self.summary.read_text()) + + def test_transient_failure_stops_after_three_attempts(self) -> None: + with patch.object(INSTALL, "run", return_value=self.result(1, "HTTP 503 Service Unavailable")) as run: + with self.assertRaisesRegex(RuntimeError, "persisted after 3 attempts"): + self.install() + self.assertEqual(3, run.call_count) + self.assertEqual([10, 30], [call.args[0] for call in self.sleep.call_args_list]) + self.assertEqual(3, len(list(self.logs.glob("*.log")))) + + def test_common_download_failures_are_distinguished_from_permanent_errors(self) -> None: + for message in ["curl: (28) Operation too slow", "Could not resolve host: github.com", + "The remote server returned an error: (503) Server Unavailable", + "Response status code does not indicate success: 502 (Bad Gateway)"]: + with self.subTest(message=message): + self.assertTrue(INSTALL.retryable(1, message)) + for message in ["The remote server returned an error: (403) Forbidden\nBroken pipe", + "SSL certificate verify failed\ncurl: (56) Failure", + "HTTP 429 Too Many Requests", "Hash check failed!\nconnection reset"]: + with self.subTest(message=message): + self.assertFalse(INSTALL.retryable(1, message)) + + def test_integrity_permission_unknown_and_cancellation_fail_without_retry(self) -> None: + for code, output in [(1, "SHA256 mismatch\nBroken pipe"), (1, "Permission denied\nconnection reset"), + (1, "HTTP 403 Forbidden"), (1, "Formula syntax error"), (130, "Broken pipe"), + (-15, "connection reset")]: + with self.subTest(code=code, output=output): + with patch.object(INSTALL, "run", return_value=self.result(code, output)) as run: + with self.assertRaisesRegex(RuntimeError, "non-retryable"): + self.install() + run.assert_called_once_with(self.command) + self.sleep.assert_not_called() + + def test_partial_install_is_not_retried_or_deleted(self) -> None: + def execute(command): + self.package.mkdir() + return self.result(1, "Connection reset by peer") + with patch.object(INSTALL, "run", side_effect=execute) as run: + with self.assertRaisesRegex(RuntimeError, "Partial Wrighty installation"): + self.install() + self.assertTrue(self.package.exists()) + run.assert_called_once() + self.sleep.assert_not_called() + + def test_completed_install_after_transient_failure_is_verified_without_reinstall(self) -> None: + def execute(command): + if command == self.command: + self.complete_files() + return self.result(1, "Broken pipe") + return self.result(0, self.version) + with patch.object(INSTALL, "run", side_effect=execute) as run: + self.install() + self.assertEqual(2, run.call_count) + self.sleep.assert_not_called() + self.assertIn("command failed", self.summary.read_text()) + self.assertIn("functional smoke test still required", self.summary.read_text()) + + def test_wrong_version_or_commit_is_fatal_even_after_transient_failure(self) -> None: + for version in ["0.18.0-alpha+" + "a" * 40, "0.19.0-alpha+" + "b" * 40]: + with self.subTest(version=version): + self.cli.unlink(missing_ok=True) + if self.package.exists(): + self.package.rmdir() + def execute(command, reported_version=version): + self.complete_files() + return self.result(1, "Broken pipe") if command == self.command else self.result(0, reported_version) + with patch.object(INSTALL, "run", side_effect=execute): + with self.assertRaisesRegex(RuntimeError, "failed version verification"): + self.install() + self.sleep.assert_not_called() + + def test_success_exit_without_package_is_not_success(self) -> None: + with patch.object(INSTALL, "run", return_value=self.result(0, "Already installed")): + with self.assertRaisesRegex(RuntimeError, "Wrighty is missing"): + self.install() + self.sleep.assert_not_called() + + def test_preexisting_install_is_not_touched(self) -> None: + self.package.mkdir() + with patch.object(INSTALL, "run") as run: + with self.assertRaisesRegex(RuntimeError, "clean disposable runner"): + self.install() + run.assert_not_called() + + def test_late_completion_is_checked_before_another_attempt(self) -> None: + self.sleep.side_effect = lambda seconds: self.complete_files() + with patch.object(INSTALL, "run", side_effect=[self.result(1, "Broken pipe"), self.result(0, self.version)]) as run: + self.install() + self.assertEqual(2, run.call_count) + self.assertIn("completed during retry delay", self.summary.read_text()) + + @unittest.skipIf(os.name == "nt", "POSIX executable fixture; Windows exercises the Scoop bridge") + def test_homebrew_helper_entry_point_installs_and_verifies_executable(self) -> None: + executable_directory = self.root / "bin" + executable_directory.mkdir() + brew = executable_directory / "brew" + cli_program = f"#!{sys.executable}\nprint({self.version!r})\n" + brew.write_text( + f"#!{sys.executable}\n" + "import os, pathlib, sys\n" + "root = pathlib.Path(os.environ['PACKAGE_FIXTURE'])\n" + "if sys.argv[1] == '--cellar': print(root / 'Cellar')\n" + "elif sys.argv[1] == '--prefix': print(root)\n" + "else:\n" + " assert sys.argv[1:] == ['install', '--verbose', 'highbyte/tap/wrighty']\n" + " (root / 'Cellar' / 'wrighty').mkdir(parents=True)\n" + " cli = root / 'bin' / 'wrighty'\n" + f" cli.write_text({cli_program!r})\n" + " cli.chmod(0o755)\n" + " print('Installed fixture')\n" + ) + brew.chmod(0o755) + environment = {**os.environ, "PACKAGE_FIXTURE": str(self.root), + "PATH": str(executable_directory) + os.pathsep + os.environ.get("PATH", "")} + result = subprocess.run( + [sys.executable, str(SCRIPT), "--manager", "homebrew", "--version", "0.19.0-alpha", + "--source-sha", "a" * 40, "--log-directory", str(self.logs)], + capture_output=True, text=True, env=environment, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("version verified on attempt 1/3", result.stdout) + self.assertIn("Installed fixture", (self.logs / "homebrew-attempt-1.log").read_text()) + + @unittest.skipUnless(os.name == "nt", "Windows native Scoop command bridge") + def test_scoop_cmd_bridge_preserves_exit_and_does_not_interpret_path(self) -> None: + directory = self.root / "scoop space & quote'" + directory.mkdir() + shim = directory / "scoop.cmd" + shim.write_text('@echo off\necho Broken pipe\nexit /b 23\n') + with patch.dict(os.environ, {"SCOOP_CMD": str(shim)}): + result = INSTALL.run(INSTALL.install_command("scoop")) + self.assertEqual(23, result.returncode) + self.assertIn("Broken pipe", result.stdout) + + +if __name__ == "__main__": + unittest.main()