From 02bfc6a8fe1f39fd7b75e13a4d89cafbd4a9007a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 14:11:45 -0500 Subject: [PATCH 1/2] Gate release on exact artifacts Build distributions once, qualify the exact wheel and sdist across the supported Python/OS matrix, run optimized/static/differential and real-Core checks, and publish only after those jobs pass. Record source SHA, dependency environments, Bitcoin Core version, artifact hashes, and qualification results in a release provenance bundle. Scrub PYTHONPATH and user-site imports in installed-artifact checks so the source checkout cannot satisfy the test accidentally. Security: release qualification uses pinned Bitcoin Core v32.0rc2 bytes and read-only permissions until the final publication job. Validation: 865 normal and 865 optimized tests passed; mypy, Ruff check/format, build and Twine passed; exact wheel and sdist both passed isolated installed-package verification with PYTHONPATH deliberately contaminated; workflow YAML parses successfully. Fixes #5. --- .github/workflows/publish.yml | 222 ++++++++++++++++++++++++++++-- tools/verify_installed_wheel.py | 4 + tools/verify_wheel_environment.py | 41 +++++- 3 files changed, 248 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7edcac9..63708e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,21 +6,51 @@ on: env: SOURCE_DATE_EPOCH: "1763060600" + BITCOIN_CORE_ARCHIVE: bitcoin-32.0rc2-x86_64-linux-gnu.tar.gz + BITCOIN_CORE_SHA256: 0255103718033e6aee15fa944717fc277e047b845bff1e7408af0ea732d8d0c1 + BITCOIN_CORE_URL: https://bitcoincore.org/bin/bitcoin-core-32.0/test.rc2 + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest permissions: - contents: write + contents: read + outputs: + python_versions: ${{ steps.metadata.outputs.python_versions }} + version: ${{ steps.metadata.outputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - - run: >- - python -c "import os,tomllib; - version=tomllib.load(open('pyproject.toml','rb'))['project']['version']; - assert os.environ['GITHUB_REF_NAME'] == f'v{version}'" + - id: metadata + name: Check release metadata + run: | + python - <<'PY' + import json + import os + import tomllib + + with open("pyproject.toml", "rb") as file: + project = tomllib.load(file)["project"] + version = project["version"] + if os.environ["GITHUB_REF_NAME"] != f"v{version}": + raise SystemExit("release tag does not match package version") + prefix = "Programming Language :: Python :: " + versions = sorted( + classifier.removeprefix(prefix) + for classifier in project["classifiers"] + if classifier.startswith(prefix) and classifier.removeprefix(prefix).count(".") == 1 + ) + if not versions: + raise SystemExit("no supported Python versions are classified") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={version}\n") + output.write(f"python_versions={json.dumps(versions, separators=(',', ':'))}\n") + PY - run: >- python -m pip install --require-hashes -r requirements/release-build-dependencies.txt @@ -33,28 +63,196 @@ jobs: tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \ -C normalized-sdist -cf - "codex32-${GITHUB_REF_NAME#v}" | gzip -n > "$archive.new" mv "$archive.new" "$archive" - - name: Attach distributions to the GitHub release - run: gh release upload "$GITHUB_REF_NAME" dist/* - env: - GH_TOKEN: ${{ github.token }} + - name: Record built artifact provenance + run: | + mkdir provenance + git rev-parse HEAD > provenance/source-commit.txt + sha256sum dist/* > provenance/artifact-sha256.txt + python -m pip freeze --all > provenance/build-environment.txt - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: distributions path: dist/ + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: build-provenance + path: provenance/ - publish: + supported-matrix: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ${{ fromJSON(needs.build.outputs.python_versions) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - name: Install legacy wallet-test oracle when present + shell: bash + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -m pip check + - run: python -m pytest -q + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install and test the exact wheel and sdist + shell: bash + run: | + for artifact in dist/codex32-*.whl dist/codex32-*.tar.gz; do + python tools/verify_wheel_environment.py --artifact "$artifact" + done + - name: Record qualification dependencies + shell: bash + run: >- + python -m pip freeze --all > + "qualification-${RUNNER_OS}-${{ matrix.python-version }}.txt" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualification-${{ runner.os }}-${{ matrix.python-version }} + path: qualification-*.txt + + quality: needs: build runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - name: Install legacy wallet-test oracle when present + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -O -m pytest -q + - run: python -m mypy src/codex32 + - run: python -m ruff check . + - run: python -m ruff format --check . + - run: python tools/differential_correction.py --verify + - name: Run legacy wallet differential when present + run: | + if [[ -f tools/differential_wallet.py ]]; then + python tools/differential_wallet.py --verify + fi + + bitcoin-core: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - name: Download pinned Bitcoin Core + run: | + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" \ + "$BITCOIN_CORE_URL/$BITCOIN_CORE_ARCHIVE" + echo "$BITCOIN_CORE_SHA256 $RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" -C "$RUNNER_TEMP" + - name: Run real-Core release integration + env: + PYTHONPATH: ${{ github.workspace }}/src + run: | + core="$RUNNER_TEMP/bitcoin-32.0rc2/bin" + "$core/bitcoind" --version | head -1 | tee bitcoin-core-version.txt + python tools/bitcoin_core_regtest.py \ + --bitcoind "$core/bitcoind" \ + --bitcoin-cli "$core/bitcoin-cli" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bitcoin-core-provenance + path: bitcoin-core-version.txt + + publish: + needs: [build, supported-matrix, quality, bitcoin-core] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/codex32 permissions: - contents: read + contents: write id-token: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: distributions path: dist/ - - name: Publish distributions to PyPI + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: build-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: bitcoin-core-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: qualification-* + path: provenance/dependencies/ + merge-multiple: true + - name: Verify qualified artifact bytes + run: sha256sum --check provenance/artifact-sha256.txt + - name: Record release qualification + env: + RELEASE_VERSION: ${{ needs.build.outputs.version }} + MATRIX_RESULT: ${{ needs.supported-matrix.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + CORE_RESULT: ${{ needs.bitcoin-core.result }} + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + hashes = {} + for line in Path("provenance/artifact-sha256.txt").read_text().splitlines(): + digest, path = line.split(maxsplit=1) + hashes[Path(path).name] = digest + record = { + "source_commit": Path("provenance/source-commit.txt").read_text().strip(), + "tag": os.environ["GITHUB_REF_NAME"], + "version": os.environ["RELEASE_VERSION"], + "bitcoin_core": Path("provenance/bitcoin-core-version.txt").read_text().strip(), + "artifact_sha256": hashes, + "qualification": { + "supported_matrix": os.environ["MATRIX_RESULT"], + "quality": os.environ["QUALITY_RESULT"], + "bitcoin_core": os.environ["CORE_RESULT"], + }, + } + Path("provenance/release-qualification.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Bundle release provenance + run: >- + tar -czf "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + -C provenance . + - name: Attach qualified distributions and provenance + run: >- + gh release upload "$GITHUB_REF_NAME" dist/* + "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" --clobber + env: + GH_TOKEN: ${{ github.token }} + - name: Publish qualified distributions to PyPI uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + with: + packages-dir: dist/ diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 373b532..b456759 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -4,6 +4,7 @@ import importlib.util import sys +from pathlib import Path import codex32 from codex32 import ( @@ -26,6 +27,9 @@ def main() -> None: + source_package = Path(__file__).resolve().parents[1] / "src" / "codex32" + imported_package = Path(codex32.__file__).resolve().parent + assert imported_package != source_package assert importlib.util.find_spec("bip32") is None assert importlib.util.find_spec("coincurve") is None diff --git a/tools/verify_wheel_environment.py b/tools/verify_wheel_environment.py index 689e1ee..50cc0f8 100644 --- a/tools/verify_wheel_environment.py +++ b/tools/verify_wheel_environment.py @@ -1,4 +1,4 @@ -"""Install the built wheel without dependencies and run the installed-wheel verifier.""" +"""Install a built distribution in isolation and run the installed-package verifier.""" from __future__ import annotations @@ -13,22 +13,46 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--wheel", type=Path) + parser.add_argument("--artifact", type=Path) arguments = parser.parse_args() root = Path(__file__).resolve().parents[1] - if arguments.wheel is None: + if arguments.wheel is not None and arguments.artifact is not None: + parser.error("choose only one of --wheel or --artifact") + requested = arguments.artifact or arguments.wheel + if requested is None: wheels = tuple((root / "dist").glob("codex32-*.whl")) if len(wheels) != 1: raise RuntimeError(f"expected exactly one codex32 wheel, found {len(wheels)}") - wheel = wheels[0] + artifact = wheels[0] else: - wheel = arguments.wheel.resolve() - if not wheel.is_file(): - raise FileNotFoundError(wheel) + artifact = requested.resolve() + if not artifact.is_file(): + raise FileNotFoundError(artifact) + if artifact.suffix != ".whl" and not artifact.name.endswith(".tar.gz"): + raise ValueError("artifact must be a wheel or .tar.gz source distribution") with tempfile.TemporaryDirectory(prefix="codex32-wheel-") as temporary: environment = Path(temporary) venv.EnvBuilder(with_pip=True).create(environment) python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + clean_env = os.environ.copy() + clean_env.pop("PYTHONPATH", None) + clean_env["PYTHONNOUSERSITE"] = "1" + if artifact.name.endswith(".tar.gz"): + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--require-hashes", + "-r", + str(root / "requirements" / "cli-build-dependencies.txt"), + ], + check=True, + env=clean_env, + ) subprocess.run( [ str(python), @@ -37,14 +61,17 @@ def main() -> None: "install", "--disable-pip-version-check", "--no-deps", - str(wheel), + "--no-build-isolation", + str(artifact), ], check=True, + env=clean_env, ) subprocess.run( [str(python), str(root / "tools" / "verify_installed_wheel.py")], check=True, cwd=temporary, + env=clean_env, ) From 9cf9f440daa5bc63ba22830eda55572dc9189b78 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Sep 2026 19:48:54 -0500 Subject: [PATCH 2/2] release: Gate publication on qualified artifacts --- .github/workflows/publish.yml | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 63708e7..675aa93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ name: Publish release on: - release: - types: [published] + push: + tags: + - "v*" env: SOURCE_DATE_EPOCH: "1763060600" @@ -91,6 +92,7 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - run: python -m pip install --upgrade pip - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt - name: Install legacy wallet-test oracle when present @@ -159,6 +161,12 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install qualified wheel + run: python -m pip install --no-deps dist/codex32-*.whl - name: Download pinned Bitcoin Core run: | curl --fail --location --proto '=https' --tlsv1.2 \ @@ -168,7 +176,7 @@ jobs: tar -xzf "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" -C "$RUNNER_TEMP" - name: Run real-Core release integration env: - PYTHONPATH: ${{ github.workspace }}/src + PYTHONNOUSERSITE: "1" run: | core="$RUNNER_TEMP/bitcoin-32.0rc2/bin" "$core/bitcoind" --version | head -1 | tee bitcoin-core-version.txt @@ -246,13 +254,23 @@ jobs: run: >- tar -czf "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" -C provenance . - - name: Attach qualified distributions and provenance - run: >- - gh release upload "$GITHUB_REF_NAME" dist/* - "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" --clobber + - name: Stage draft GitHub release env: GH_TOKEN: ${{ github.token }} + run: | + artifact="codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + if draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft 2>/dev/null)"; then + [ "$draft" = true ] || { echo "release is already public" >&2; exit 1; } + gh release upload "$GITHUB_REF_NAME" dist/* "$artifact" --clobber + else + gh release create "$GITHUB_REF_NAME" --draft --verify-tag \ + --title "$GITHUB_REF_NAME" --generate-notes dist/* "$artifact" + fi - name: Publish qualified distributions to PyPI uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 with: packages-dir: dist/ + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false