From a8740170e021cc138b5cc285c3a2d4ac4c83a518 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:04:36 -0700 Subject: [PATCH] Prepare Bugbix 1.0.0 distributions and stability contract --- .github/workflows/verify.yml | 46 +++++++++- .gitignore | 2 + CHANGELOG.md | 10 ++ CONTRIBUTING.md | 17 +++- MANIFEST.in | 6 ++ README.md | 30 +++--- VERIFICATION.md | 34 ++++++- bugbix/__init__.py | 2 +- docs/recovery.md | 17 ++++ docs/stability.md | 17 ++++ pyproject.toml | 7 +- scripts/consumer.py | 129 ++++++++++++++++++++++++++ scripts/package.py | 97 ++++++++++++++++++++ scripts/release.cjs | 173 +++++++++++++++++++++++++++++++++++ 14 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 MANIFEST.in create mode 100644 docs/recovery.md create mode 100644 docs/stability.md create mode 100644 scripts/consumer.py create mode 100644 scripts/package.py create mode 100644 scripts/release.cjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 54d1aed..c672278 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -5,25 +5,61 @@ permissions: on: push: + branches: [main] pull_request: + workflow_dispatch: +concurrency: + group: bugbix-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: test: name: ${{ matrix.os }} / Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: os: [windows-latest, ubuntu-latest] python-version: ['3.11', '3.12'] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} - name: Install test dependency - run: python -m pip install pytest + run: python -m pip install pytest==9.1.1 build==1.6.1 - name: Run product tests run: python -m unittest discover -s tests -v - - name: Build wheel - run: python -m pip wheel --no-deps --wheel-dir dist . + - name: Build and verify wheel and source archive + run: python scripts/package.py + - name: Install actual artifacts and verify consumer/recovery + run: python scripts/consumer.py --out ../bugbix-clean-consumer + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + with: + name: bugbix-packages + path: artifacts/ + if-no-files-found: error + overwrite: true + publish: + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: bugbix-packages + path: release-artifacts + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const publish = require('./scripts/release.cjs'); + await publish({ github, context, core }); diff --git a/.gitignore b/.gitignore index 9853d60..7ade51a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ build/ dist/ .venv/ +artifacts/ +reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c94ff..2c89f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.0 + +2026-10-06 + +- Establish the v1 CLI, verdict/exit and report schema 2 stability contract for selected Python regression checks. +- Retain the verified unittest/pytest engine, final xfail/xpass handling and original-checkout preservation behavior. +- Deliver a prebuilt wheel and installable source archive with MIT licensing and SHA256 checksums. +- Verify both actual artifacts in fresh environments, including positive/negative verdicts, recovery and checkout preservation. +- Gate main-branch release publication on the existing Windows/Linux and Python 3.11/3.12 checks and complete artifact verification. + ## 0.4.0a2 — pytest outcome correction - Record final pytest outcomes after expected-failure processing; mixed suites containing xfail or xpass remain inconclusive. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc6f064..ec7ecb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Bugbix is an experimental Python command-line tool. Focused bug reports and +Bugbix is a Python command-line tool for bounded regression evidence. Focused bug reports and small changes that improve its stated use case are welcome. Before opening an issue, include the Bugbix version, Python and Git versions, @@ -11,7 +11,7 @@ raw test output, which may include private paths or other sensitive data. For code changes: -1. Run `python -m pip install pytest` in a disposable environment. +1. Run `python -m pip install pytest==9.1.1 build==1.6.1` in a disposable environment. 2. Run `python -m unittest discover -s tests -v`. 3. Add a test for a changed verdict or runner behavior when it protects against a concrete failure. @@ -21,3 +21,16 @@ For code changes: The current scope is selected Python unittest and pytest tests against a known pre-fix Git commit. Discuss broader runner or language support before building it. + +## Build and verify the delivered artifacts + +```sh +python scripts/package.py +python scripts/consumer.py --out ../bugbix-consumer-check +``` + +Choose a new consumer directory outside the source checkout. Packaging verifies runtime source bytes, CLI entry point, version, MIT license and archive contents, then writes the wheel/source archive and checksums under ignored `artifacts/`. The consumer installs each actual artifact in its own fresh virtual environment, exercises the installed CLI outside source, verifies positive/negative verdicts and exit codes, preserves the dirty fixture checkout, and tests recovery into a new output directory. Reports remain in the consumer directory. + +The workflow retains Windows/Linux and Python 3.11/3.12 checks. Pull requests and manual dispatches have read-only permissions. Main pushes publish only after all four lanes pass, using the same-run verified wheel/source artifact. The publisher checks the exact five-file package/checksum set, current main/tag commit, matching draft, and complete uploaded digests/sizes before publishing. Published versions remain unchanged; same-commit draft retries can resume. + +Follow the [v1 contract](docs/stability.md). Add concrete regression coverage for changes to engine behavior; documentation/version packaging changes need actual installation checks rather than duplicate engine tests. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f05c0a6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE README.md CHANGELOG.md CONTRIBUTING.md VERIFICATION.md DIRECTION.md NAMING.md +include evidence/alpha-source-tests.txt +recursive-include bugbix *.py +recursive-include tests *.py +recursive-include docs *.md +recursive-include scripts *.py *.cjs diff --git a/README.md b/README.md index 2838e9e..27e001d 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,29 @@ It runs the same selected Python tests against a Git baseline and your current code, including uncommitted edits and new, non-ignored files. It records individual test outcomes, source hashes, snapshots, and raw logs. -Version 0.4.0a2 is a public alpha for Python projects with a known pre-fix Git -commit. It has no runtime package dependencies, account, or model API. It has +Version 1.0.0 supports selected Python tests against a known pre-fix Git +commit, with a documented [v1 CLI and report contract](docs/stability.md). It has no runtime package dependencies, account, or model API. It has been tested locally on Windows with Python 3.12; see [VERIFICATION.md](VERIFICATION.md) for the exact results and current limits. ## Install -Requires Python 3.11+ and Git. Install the alpha from its tagged source: +Requires Python 3.11+ and Git. Download the wheel and `SHA256SUMS` from the [v1.0.0 release](https://github.com/agammann/bugbix/releases/tag/v1.0.0). Check the wheel's SHA256 before installing it. On PowerShell, use `Get-FileHash -Algorithm SHA256`; on Linux, use `sha256sum -c SHA256SUMS` after downloading both artifacts listed in the manifest. ```sh -python -m pip install "git+https://github.com/agammann/bugbix.git@v0.4.0-alpha.2" +python -m pip install /path/to/bugbix-1.0.0-py3-none-any.whl bugbix --version ``` -Alternatively, download the wheel from the [alpha release](https://github.com/agammann/bugbix/releases/tag/v0.4.0-alpha.2) -and install that file with `python -m pip install /path/to/bugbix-0.4.0a2-py3-none-any.whl`. -Bugbix is not published to PyPI. +Expect `Bugbix 1.0.0`. The wheel is prebuilt and has no runtime package dependencies. Bugbix is not published to PyPI. + +For a source installation, download `bugbix-1.0.0.tar.gz` from the same release, verify its checksum, and run: + +```sh +python -m pip install /path/to/bugbix-1.0.0.tar.gz +``` + +Pip builds the source package using its declared setuptools backend; obtaining build dependencies may need network access. The resulting runtime remains dependency-free. Keep wheels/source archives from one version together and check the installed CLI version after upgrading. Source and distributions are [MIT licensed](LICENSE), copyright 2026 agammann. ## Quick start @@ -88,7 +94,7 @@ repository root and its `src/` directory first. | `unstable` | Repeated runs produced different outcomes. | Exit `0` means `regression_observed`; exit `1` means another completed verdict; -exit `2` means configuration, snapshot, or orchestration failure. CLI output is JSON. +exit `2` means configuration, snapshot, or orchestration failure. Parsed check output is JSON; command syntax errors print argparse diagnostics to stderr with exit 2. For `inconclusive`, the CLI and report name the blocking run, test, and exception type when available. Runner failures include a short cause; the full trace remains in the evidence folder. An assertion failure in one test does not override an error, @@ -101,7 +107,7 @@ Python names and `test/` or `tests/` folders; review the changed-file list. Each evidence folder contains `report.md`, `report.json`, and per-run snapshots, stdout, stderr, and structured test results. Default: two runs per version, a -30-second timeout per run. Configure `--repeat` (1–10) and `--timeout` (up to 3600). +30-second timeout per run. Configure `--repeat` (1–10) and `--timeout` (up to 3600). ## How it works @@ -117,7 +123,7 @@ must already work on the baseline. A missing helper produces `inconclusive`. ## Scope and limits -- This alpha supports selected Python test files or individual cases with unittest or pytest. +- Version 1 supports selected Python test files or individual cases with unittest or pytest. Other languages and runners are not supported. Package-relative test imports and project-specific pytest plugins may require additional setup. - `--base` must be a real pre-fix commit. If `HEAD` already contains some or all @@ -147,10 +153,12 @@ must already work on the baseline. A missing helper produces `inconclusive`. ## Run the product tests ```sh -python -m pip install pytest +python -m pip install pytest==9.1.1 build==1.6.1 python -m unittest discover -s tests -v ``` See [VERIFICATION.md](VERIFICATION.md) for observed results, [DIRECTION.md](DIRECTION.md) for the product scope and alternatives, [CHANGELOG.md](CHANGELOG.md) for release changes, and [CONTRIBUTING.md](CONTRIBUTING.md) for bug reports and contributions. + +For interrupted or inconclusive checks, upgrades and removal, see [Recovery](docs/recovery.md). The [v1 stability contract](docs/stability.md) records the supported CLI, exit codes and report schema 2. diff --git a/VERIFICATION.md b/VERIFICATION.md index b1efc4b..fba210b 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -1,10 +1,35 @@ # Verification -The alpha's supported claim is narrow: selected Python tests can distinguish a +The supported claim is narrow: selected Python tests can distinguish a known pre-fix Git commit from a fix. This record separates local observations from broader product claims. The project does not yet have independent user adoption evidence. +## 1.0.0 release checks — 2026-10-06 + +Windows, CPython 3.12.14, pytest 9.1.1, build 1.6.1 and setuptools 84.0.0: + +- All 22 product tests passed without skips. The regression-check engine is + unchanged from the current 0.4.0a2 source; the runtime version is now 1.0.0. +- The wheel and source archive were built and checked for matching version, + six runtime modules, MIT license, entry point and zero runtime dependencies. + The source archive also includes the CLI/report contract and recovery guide. +- Each actual distribution was installed in its own fresh virtual environment + outside the source checkout. The installed CLI reported 1.0.0, imported from + that environment and matched all six runtime module bytes. +- Each installed distribution passed nine controlled consumer cases: a real + assertion transition, a weak test, a broken fix, an import error, refusal to + overwrite existing evidence, recovery into a new output folder, a skipped + test, a timeout and a command syntax error. Help also exited successfully. + The checks preserved the original fixture checkout, branch, index and files; + refusal to reuse an output directory preserved the existing evidence bytes. + +These are current local checks. The workflow requires the source suite, package +checks and both clean distribution consumers on Windows and Ubuntu with Python +3.11 and 3.12 before main can publish the five release assets. Check the actual +workflow and release before assuming remote success or availability. The dated +alpha and third-party records below are historical and were not rerun for v1. + ## 0.4.0a2 checks — 2026-10-02 Windows 11, CPython 3.12.14, pytest 9.1.1: @@ -71,9 +96,10 @@ error as a successful regression check. ## Reproduce the product suite ```sh -python -m pip install pytest +python -m pip install pytest==9.1.1 build==1.6.1 python -m unittest discover -s tests -v -python -m pip wheel --no-deps --wheel-dir dist . +python scripts/package.py +python scripts/consumer.py --out ../bugbix-clean-consumer ``` The GitHub Actions workflow runs the suite on Windows and Ubuntu with Python @@ -86,5 +112,5 @@ Only Windows and Python 3.12 have been verified locally. The third-party runs were focused tests, not complete upstream test suites. The two successful projects demonstrate technical utility, not demand. Generated files, external services, untracked prior baselines, and other languages remain outside this -alpha's verified scope. Test code runs with the caller's permissions; the +verified scope. Test code runs with the caller's permissions; the snapshot directories are not a security sandbox. diff --git a/bugbix/__init__.py b/bugbix/__init__.py index 1691e44..3a671e6 100644 --- a/bugbix/__init__.py +++ b/bugbix/__init__.py @@ -1,3 +1,3 @@ """Bugbix: regression evidence for the change in front of you.""" -__version__ = "0.4.0a2" +__version__ = "1.0.0" diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 0000000..b111b76 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,17 @@ +# Recover a check and remove Bugbix + +A completed `inconclusive` result has evidence. Read its blockers and the linked stdout/stderr before changing the test environment. A missing dependency, shared fixture, skipped/xfail case or runner error must be resolved in the selected interpreter or test selection. Keep the original report, correct the cause, and run again with a new `--out` directory. An existing output folder is refused with exit 2 and remains untouched. + +For an interruption or orchestration failure, a partial evidence directory may lack a complete report. Retain it while investigating; it is not a completed verdict. Stop the interrupted test processes if necessary, check the original repository state, and rerun into a new directory. Bugbix's normal timeout handling stops the main test process tree. Intentionally detached processes remain outside the supported scope. + +`--base` must identify the real pre-fix commit. If HEAD already includes the fix, choose an older saved commit. Bugbix cannot restore unsaved pre-fix working-tree state. Snapshot operations preserve the original checkout; tests themselves run with your permissions and can access paths/services outside their copied source. Use trusted code and review evidence before sharing it. + +Reports contain source copies and raw output. Retention and sharing are your responsibility. You may archive a complete evidence directory as a unit; its Markdown links refer to the adjacent per-run directories. Removing an evidence directory does not change the tested checkout. Verify its absolute path before removing anything. + +To remove the installed package, use its environment's interpreter: + +```sh +python -m pip uninstall bugbix +``` + +An isolated virtual environment can be removed after preserving any evidence saved inside it. Bugbix has no account, daemon, scheduled task, telemetry or hosted resource to remove. Existing Git repositories and saved evidence remain independently usable. diff --git a/docs/stability.md b/docs/stability.md new file mode 100644 index 0000000..0ffc630 --- /dev/null +++ b/docs/stability.md @@ -0,0 +1,17 @@ +# Bugbix v1 stability contract + +Version 1.0.0 supports selected Python unittest or pytest cases against a known pre-fix Git commit and a current working tree or committed fix. Python 3.11+ and Git are required. The installed runtime has no package dependencies, account, model API or network service. Pytest and the tested project's dependencies belong in the interpreter selected by `--python`; Bugbix does not install them. + +Within v1, `bugbix check` and the documented options (`--repo`, `--base`, `--head`, repeated `--test`, `--runner`, `--out`, `--python`, `--timeout`, `--repeat`) remain supported. Defaults remain working-tree head, unittest, two repetitions and 30 seconds per run. `--version` prints `Bugbix ` and exits 0. Help exits 0. Command syntax errors use argparse diagnostics on stderr and exit 2. + +A parsed check prints JSON to stdout. Exit 0 means `regression_observed`; exit 1 means another completed verdict (`not_demonstrated`, `fix_fails`, `inconclusive`, `unstable`). Configuration, snapshot or orchestration errors exit 2 and include a JSON error. A failure-to-pass transition needs matching assertion outcomes, passing current tests and a non-test source change. Errors, skips, expected failures, unexpected passes, missing results, timeouts and input mutation prevent a qualifying regression. Repeated disagreement produces `unstable`. + +Written `report.json` retains `schema_version: 2`. Verdict names and the documented top-level data remain supported within v1; readers should allow additional fields. Reports include source snapshots/hashes, selected tests, revisions, runs, blockers and limits. Timing, temporary paths, human-readable reason strings, raw traces and Markdown layout are observational output rather than machine parsing contracts. Old evidence is retained as produced; the CLI does not rewrite or migrate existing report folders. + +Snapshot operations leave the original checkout, branch and index intact. Evidence is written to a new directory outside that repository; existing output folders are refused. Only selected current test files are transplanted onto the baseline, so shared fixtures/helpers must already work there. The documented file/size limits and rejection of submodules, symlinks and Git LFS pointers remain part of the supported scope. Tests execute with the caller's permissions and inherited dependencies; snapshots are not an execution sandbox or a hermetic environment. + +Windows and Linux on Python 3.11/3.12 are the CI lanes. Local release checks use Windows Python 3.12.14 and pytest 9.1.1. Other interpreter versions/platforms, languages, arbitrary pytest plugins, detached background processes and full-suite/bug-relevance guarantees require separate evidence. + +## Upgrade from 0.4.0a2 + +Install the v1 wheel or source archive in your environment and check `bugbix --version`. The check options, verdicts, exit codes and report schema 2 are unchanged. Keep older evidence folders for comparison; use a new output directory for every v1 run. No repository migration, service registration or account change is needed. Follow [recovery and removal](recovery.md) for interrupted/inconclusive runs and uninstalling the CLI. diff --git a/pyproject.toml b/pyproject.toml index 1ff42ff..d6a03f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,16 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" [project] name = "bugbix" -version = "0.4.0a2" +version = "1.0.0" description = "Check whether a regression test catches the bug before your fix." requires-python = ">=3.11" readme = "README.md" authors = [{name = "agammann"}] -license = {text = "MIT"} +license = "MIT" +license-files = ["LICENSE"] dependencies = [] [project.urls] diff --git a/scripts/consumer.py b/scripts/consumer.py new file mode 100644 index 0000000..fa279f9 --- /dev/null +++ b/scripts/consumer.py @@ -0,0 +1,129 @@ +"""Install each delivered artifact outside the checkout and run the real CLI.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import venv + +from package import ROOT, release_version + +OLD = "def total(price, discount, tax):\n return price * (100 + tax) // 100 - discount\n" +FIX = "def total(price, discount, tax):\n return (price - discount) * (100 + tax) // 100\n" +TEST = "import unittest\nfrom pricing import total\nclass PricingTests(unittest.TestCase):\n def test_discount(self):\n self.assertEqual(total(100, 20, 10), 88)\n" + + +def call(command, cwd, expected=0): + result = subprocess.run([str(p) for p in command], cwd=cwd, capture_output=True, text=True, timeout=120) + if result.returncode != expected: + raise RuntimeError(f"Command exit {result.returncode}, expected {expected}: {command}\n{result.stdout}\n{result.stderr}") + return result + + +def fingerprint(directory): + return {str(p.relative_to(directory)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in directory.rglob("*") if p.is_file()} + + +def fixture(path): + path.mkdir() + call(["git", "init", "--quiet"], path) + (path / ".gitignore").write_text("__pycache__/\n", encoding="utf-8") + (path / "pricing.py").write_text(OLD, encoding="utf-8") + call(["git", "add", "."], path) + call(["git", "-c", "user.name=Bugbix test", "-c", "user.email=test@example.invalid", "commit", "--quiet", "-m", "Baseline fixture"], path) + (path / "pricing.py").write_text(FIX, encoding="utf-8") + (path / "test_pricing.py").write_text(TEST, encoding="utf-8") + + +def exercise(directory, artifact, version): + directory.mkdir() + environment = directory / "venv" + venv.EnvBuilder(with_pip=True).create(environment) + binary = environment / ("Scripts" if sys.platform == "win32" else "bin") + python = binary / ("python.exe" if sys.platform == "win32" else "python") + cli = binary / ("bugbix.exe" if sys.platform == "win32" else "bugbix") + installed = call([python, "-m", "pip", "install", "--no-deps", artifact], directory) + (directory / "install.log").write_text(installed.stdout + installed.stderr, encoding="utf-8") + if call([cli, "--version"], directory).stdout.strip() != f"Bugbix {version}": + raise ValueError("Installed CLI version mismatch") + probe = "import bugbix,json,pathlib; print(json.dumps({'version':bugbix.__version__,'file':bugbix.__file__}))" + identity = json.loads(call([python, "-I", "-c", probe], directory).stdout) + if identity["version"] != version or not Path(identity["file"]).resolve().is_relative_to(environment.resolve()): + raise ValueError("Consumer imported source checkout instead of installed package") + package = Path(identity["file"]).parent + for source in (ROOT / "bugbix").glob("*.py"): + if (package / source.name).read_bytes() != source.read_bytes(): + raise ValueError("Installed runtime bytes differ: " + source.name) + repo = directory / "repo" + fixture(repo) + cases = [] + def check(name, content, verdict, exit_code, timeout="10"): + (repo / "test_pricing.py").write_text(content, encoding="utf-8") + status = call(["git", "status", "--porcelain=v1"], repo).stdout + before = fingerprint(repo) + output = directory / name + command = [cli, "check", "--repo", repo, "--base", "HEAD", "--test", "test_pricing.py", "--out", output, "--timeout", timeout] + result = call(command, directory, exit_code) + value = json.loads(result.stdout) + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + if value["verdict"] != verdict or report["version"] != version or report["schema_version"] != 2: + raise ValueError("Installed verdict/report contract mismatch") + after_status = call(["git", "status", "--porcelain=v1"], repo).stdout + after = fingerprint(repo) + if before != after or status != after_status: + changed = sorted(name for name in before.keys() | after.keys() if before.get(name) != after.get(name)) + raise ValueError("Original checkout changed during check: " + ", ".join(changed)) + (output / "cli.json").write_text(result.stdout, encoding="utf-8") + cases.append({"name": name, "verdict": verdict, "exit": exit_code, "repositoryPreserved": True}) + return command, output + check("regression", TEST, "regression_observed", 0) + check("weak-test", TEST.replace("100, 20, 10), 88", "100, 0, 0), 100"), "not_demonstrated", 1) + (repo / "pricing.py").write_text(OLD, encoding="utf-8") + check("broken-fix", TEST, "fix_fails", 1) + (repo / "pricing.py").write_text(FIX, encoding="utf-8") + command, output = check("import-error", "import missing_consumer_dependency\n" + TEST, "inconclusive", 1) + preserved = fingerprint(output) + error = call(command, directory, 2) + if "already exists" not in json.loads(error.stdout).get("error", "") or preserved != fingerprint(output): + raise ValueError("Existing evidence was not preserved") + cases.append({"name": "existing-output-refusal", "exit": 2, "evidencePreserved": True}) + check("recovered", TEST, "regression_observed", 0) + check("skipped", TEST.replace(" def test_", " @unittest.skip('not exercised')\n def test_"), "inconclusive", 1) + check("timeout", "import time\ntime.sleep(30)\n" + TEST, "inconclusive", 1, "0.5") + syntax = call([cli, "check"], directory, 2) + if syntax.stdout or not syntax.stderr: + raise ValueError("CLI syntax error channel mismatch") + cases.append({"name": "syntax-error", "exit": 2, "diagnostic": "stderr"}) + if call([cli, "--help"], directory).returncode != 0: + raise ValueError("CLI help failed") + return {"artifact": artifact.name, "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), "installedVersion": version, + "runtimeBytes": "match", "runtimeDependencies": 0, "cases": cases} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--out", required=True) + parser.add_argument("--artifacts", default=str(ROOT / "artifacts")) + args = parser.parse_args() + destination = Path(args.out).resolve() + if destination.exists() or destination.is_relative_to(ROOT.resolve()): + raise ValueError("Use a new consumer folder outside the source checkout") + destination.mkdir(parents=True) + version = release_version() + artifacts = Path(args.artifacts).resolve() + results = [] + for label, filename in [("wheel", f"bugbix-{version}-py3-none-any.whl"), ("source", f"bugbix-{version}.tar.gz")]: + artifact = artifacts / filename + expected = hashlib.sha256(artifact.read_bytes()).hexdigest() + " " + filename + "\n" + if (artifacts / (filename + ".sha256")).read_text(encoding="utf-8") != expected: + raise ValueError("Consumer artifact checksum mismatch") + results.append(exercise(destination / label, artifact, version)) + summary = {"version": version, "python": sys.version.split()[0], "platform": sys.platform, "artifacts": results} + (destination / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..e99bb77 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,97 @@ +"""Build and verify the actual wheel/source distributions before upload.""" +import ast +from email.parser import BytesParser +import hashlib +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tarfile +import tomllib +import zipfile + +ROOT = Path(__file__).resolve().parent.parent + + +def release_version(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] + version = project["version"] + if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version): + raise ValueError("Release version must be stable SemVer") + values = [ast.literal_eval(n.value) for n in ast.parse((ROOT / "bugbix/__init__.py").read_text()).body + if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "__version__" for t in n.targets)] + if values != [version] or project["name"] != "bugbix" or project["license"] != "MIT" or project["license-files"] != ["LICENSE"] or project["dependencies"]: + raise ValueError("Package, runtime version, license or dependency metadata mismatch") + return version + + +def safe_members(names): + if len(names) != len(set(names)): + raise ValueError("Duplicate archive entry") + for name in names: + path = PurePosixPath(name.rstrip("/")) + if path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name: + raise ValueError("Unsafe archive entry") + if any(part in {".git", ".venv", "__pycache__", "artifacts", "build", "dist", "qa-v1"} for part in path.parts): + raise ValueError("Private/build content in archive") + + +def metadata(data, version): + info = BytesParser().parsebytes(data) + if info["Name"] != "bugbix" or info["Version"] != version or info["Requires-Python"] != ">=3.11" or info.get_all("Requires-Dist"): + raise ValueError("Distribution metadata mismatch") + if (info["License-Expression"] or info["License"]) != "MIT": + raise ValueError("Distribution license mismatch") + + +def verify(directory, version): + wheel = directory / f"bugbix-{version}-py3-none-any.whl" + source = directory / f"bugbix-{version}.tar.gz" + modules = {f"bugbix/{p.name}": p.read_bytes() for p in (ROOT / "bugbix").glob("*.py")} + license = (ROOT / "LICENSE").read_bytes() + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist(); safe_members(names) + if archive.testzip() is not None: + raise ValueError("Wheel CRC failure") + actual = {name for name in names if name.startswith("bugbix/")} + if actual != set(modules): + raise ValueError("Wheel contains unexpected/missing runtime files") + for name, data in modules.items(): + if archive.read(name) != data: + raise ValueError("Wheel runtime source differs: " + name) + info = f"bugbix-{version}.dist-info/" + metadata(archive.read(info + "METADATA"), version) + if "bugbix = bugbix.cli:main" not in archive.read(info + "entry_points.txt").decode(): + raise ValueError("Wheel CLI entry point missing") + licenses = [name for name in names if name.startswith(info) and name.endswith("/LICENSE")] + if len(licenses) != 1 or archive.read(licenses[0]) != license: + raise ValueError("Wheel MIT license differs") + with tarfile.open(source, "r:gz") as archive: + members = archive.getmembers(); safe_members([m.name for m in members]) + if any(not (m.isfile() or m.isdir()) for m in members): + raise ValueError("Linked/special source archive entry") + prefix = f"bugbix-{version}/" + if any(not (m.name == prefix.rstrip("/") or m.name.startswith(prefix)) for m in members): + raise ValueError("Source archive prefix mismatch") + for name, data in {**modules, "LICENSE": license}.items(): + if archive.extractfile(prefix + name).read() != data: + raise ValueError("Source distribution differs: " + name) + metadata(archive.extractfile(prefix + "PKG-INFO").read(), version) + for name in ["README.md", "docs/stability.md", "docs/recovery.md", "tests/test_product.py"]: + if archive.extractfile(prefix + name).read() != (ROOT / name).read_bytes(): + raise ValueError("Source guide/test differs: " + name) + lines = [] + for artifact in sorted([wheel, source]): + line = hashlib.sha256(artifact.read_bytes()).hexdigest() + " " + artifact.name + "\n" + artifact.with_name(artifact.name + ".sha256").write_text(line, encoding="utf-8", newline="\n") + lines.append(line) + (directory / "SHA256SUMS").write_text("".join(lines), encoding="utf-8", newline="\n") + return [wheel.name, source.name] + + +if __name__ == "__main__": + version = release_version() + directory = ROOT / "artifacts" + directory.mkdir(exist_ok=True) + subprocess.run([sys.executable, "-m", "build", "--outdir", str(directory)], cwd=ROOT, check=True) + print("Verified distributions: " + ", ".join(verify(directory, version))) diff --git a/scripts/release.cjs b/scripts/release.cjs new file mode 100644 index 0000000..f0ae6f5 --- /dev/null +++ b/scripts/release.cjs @@ -0,0 +1,173 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +module.exports = async ({ github, context, core }) => { + if (context.eventName !== 'push' || context.ref !== 'refs/heads/main' || + !/^[0-9a-f]{40}$/.test(context.sha)) { + throw new Error('Publishing requires a main push with an exact commit SHA.'); + } + const packageVersions = [...fs.readFileSync('pyproject.toml', 'utf8').matchAll(/^version = "([^"\r\n]+)"\r?$/gm)]; + const runtimeVersions = [...fs.readFileSync('bugbix/__init__.py', 'utf8').matchAll(/^__version__ = "([^"\r\n]+)"\r?$/gm)]; + if (packageVersions.length !== 1 || runtimeVersions.length !== 1 || packageVersions[0][1] !== runtimeVersions[0][1] || + !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(packageVersions[0][1])) { + throw new Error('Expected matching stable package/runtime versions.'); + } + const version = packageVersions[0][1]; + const tag = 'v' + version; + const { owner, repo } = context.repo; + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, repo, per_page: 100 + }); + const matching = releases.filter(release => release.tag_name === tag); + if (matching.length > 1) throw new Error('Multiple releases use ' + tag); + let release = matching[0]; + if (release && !release.draft) { + core.info('Release ' + tag + ' is already published; leaving it unchanged.'); + return; + } + if (release && (release.target_commitish !== context.sha || release.prerelease)) { + throw new Error('Existing draft does not belong to this exact release commit.'); + } + const requireCurrentHead = async () => { + const branch = (await github.rest.repos.getBranch({ owner, repo, branch: 'main' })).data; + if (branch.commit.sha !== context.sha) throw new Error('Main advanced beyond the checked release commit.'); + }; + await requireCurrentHead(); + + const tagCommit = async () => { + let object; + try { + object = (await github.rest.git.getRef({ owner, repo, ref: 'tags/' + tag })).data.object; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + for (let depth = 0; depth < 10; depth++) { + if (object.type === 'commit') return object.sha; + if (object.type !== 'tag') throw new Error('Release tag does not point to a commit.'); + object = (await github.rest.git.getTag({ + owner, repo, tag_sha: object.sha + })).data.object; + } + throw new Error('Release tag has too many annotated tag layers.'); + }; + const requireCorrectTag = async () => { + const commit = await tagCommit(); + if (commit !== null && commit !== context.sha) { + throw new Error('Existing release tag points to a different commit.'); + } + return commit; + }; + await requireCorrectTag(); + + const directory = 'release-artifacts'; + const filenames = fs.readdirSync(directory).sort(); + const archives = [`bugbix-${version}-py3-none-any.whl`, `bugbix-${version}.tar.gz`].sort(); + const expectedInputs = [...archives.flatMap(filename => [filename, filename + '.sha256']), 'SHA256SUMS'].sort(); + if (JSON.stringify(filenames) !== JSON.stringify(expectedInputs)) { + throw new Error('Downloaded artifacts contain unexpected or missing files.'); + } + const payloads = []; + const checksumLines = []; + const readFile = filename => { + const location = path.join(directory, filename); + if (!fs.lstatSync(location).isFile()) throw new Error('Artifact is not a regular file.'); + return fs.readFileSync(location); + }; + const digest = data => 'sha256:' + crypto.createHash('sha256').update(data).digest('hex'); + for (const filename of archives) { + const data = readFile(filename); + const checksum = readFile(filename + '.sha256'); + const expected = digest(data).slice(7) + ' ' + filename + '\n'; + if (checksum.toString('utf8') !== expected) { + throw new Error('Artifact checksum mismatch: ' + filename); + } + checksumLines.push(expected); + payloads.push({ name: filename, data }, { name: filename + '.sha256', data: checksum }); + } + const combined = Buffer.from(checksumLines.join(''), 'utf8'); + if (!readFile('SHA256SUMS').equals(combined)) throw new Error('Combined checksums do not match the package set.'); + payloads.push({ name: 'SHA256SUMS', data: combined }); + for (const payload of payloads) payload.digest = digest(payload.data); + + const changelog = fs.readFileSync('CHANGELOG.md', 'utf8').replaceAll('\r\n', '\n'); + const heading = '## ' + version + '\n'; + if (!changelog.includes(heading)) throw new Error('Changelog has no release section.'); + const notes = changelog.split(heading)[1].split('\n## ')[0].trim(); + const body = notes + '\n\nDownload the prebuilt wheel or source archive and verify SHA256SUMS. Python 3.11+ and Git are required. The runtime has no package dependencies. Distributions include the MIT license; source installation uses the declared build backend. The v1 CLI/report contract and recovery guide describe the supported regression-check scope.\n\n' + + '[Installation and upgrade instructions](https://github.com/' + owner + '/' + repo + + '/blob/' + tag + '/README.md).'; + if (!release) { + release = (await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, target_commitish: context.sha, + name: 'Bugbix ' + version, body, draft: true, prerelease: false + })).data; + } else { + release = (await github.rest.repos.updateRelease({ + owner, repo, release_id: release.id, + name: 'Bugbix ' + version, body, draft: true, prerelease: false + })).data; + } + const existing = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, per_page: 100 + }); + const wanted = new Map(payloads.map(payload => [payload.name, payload])); + const existingNames = new Set(); + for (const asset of existing) { + if (!wanted.has(asset.name) || existingNames.has(asset.name)) { + throw new Error('Draft contains an unexpected or duplicate asset.'); + } + existingNames.add(asset.name); + } + for (const payload of payloads) { + const previous = existing.find(asset => asset.name === payload.name); + if (previous && previous.digest === payload.digest && + previous.size === payload.data.length && previous.state === 'uploaded') { + continue; + } + if (previous) { + await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: previous.id }); + } + const uploaded = (await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id: release.id, name: payload.name, data: payload.data, + headers: { 'content-type': 'application/octet-stream', 'content-length': payload.data.length } + })).data; + if (uploaded.digest !== payload.digest || uploaded.size !== payload.data.length || + uploaded.state !== 'uploaded') { + throw new Error('Uploaded asset verification failed: ' + payload.name); + } + core.info('Verified uploaded asset: ' + payload.name); + } + const finalAssets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, per_page: 100 + }); + if (finalAssets.length !== payloads.length || + new Set(finalAssets.map(asset => asset.name)).size !== payloads.length || + finalAssets.some(asset => { + const expected = wanted.get(asset.name); + return !expected || asset.digest !== expected.digest || + asset.size !== expected.data.length || asset.state !== 'uploaded'; + })) { + throw new Error('Draft assets do not match the complete verified package set.'); + } + const latest = (await github.rest.repos.getRelease({ + owner, repo, release_id: release.id + })).data; + if (!latest.draft || latest.target_commitish !== context.sha || latest.tag_name !== tag) { + throw new Error('Draft changed during upload; publication stopped.'); + } + // Create the tag at the checked commit explicitly; target_commitish cannot move an existing tag. + await requireCurrentHead(); + if (await requireCorrectTag() === null) { + await github.rest.git.createRef({ owner, repo, ref: 'refs/tags/' + tag, sha: context.sha }); + } + if (await requireCorrectTag() !== context.sha) { + throw new Error('Release tag is missing before publication.'); + } + await requireCurrentHead(); + await github.rest.repos.updateRelease({ + owner, repo, release_id: release.id, draft: false, make_latest: 'true' + }); + core.info('Published Bugbix ' + version + ' at ' + context.sha); +};