Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 230 additions & 14 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,57 @@
name: Publish release

on:
release:
types: [published]
push:
tags:
- "v*"

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
Expand All @@ -33,28 +64,213 @@ 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 }}
Comment thread
BenWestgate marked this conversation as resolved.
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
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"
- 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 \
--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:
PYTHONNOUSERSITE: "1"
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: 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 \
Comment on lines +262 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set the repository explicitly for release commands

The publish job downloads artifacts but never checks out the repository, and neither this step nor the workflow sets GH_REPO or passes --repo. Consequently, gh release view fails outside a Git checkout, its error is swallowed, and the fallback gh release create also fails, preventing every qualified release from reaching PyPI. I reproduced the failure from an empty directory: failed to run git: fatal: not a git repository. The local gh help environment documents GH_REPO as specifying the repository for commands “that otherwise operate on a local repository.” Set GH_REPO: ${{ github.repository }} for both GitHub release steps, pass --repo to each command, or check out the repository in this job.

Useful? React with 👍 / 👎.

--title "$GITHUB_REF_NAME" --generate-notes dist/* "$artifact"
Comment on lines +266 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark automatically created release candidates as prereleases

For the current package version, 1.0.0rc1 in pyproject.toml, pushing the matching tag creates a draft without the prerelease flag, and the final gh release edit --draft=false publishes it as a regular GitHub release. Previously, this workflow operated on an already-published release and preserved the publisher's prerelease selection; the new automatic creation path loses that distinction. The local gh release create --help explicitly documents --prerelease as “Mark the release as a prerelease.” Derive that flag from the package version when creating the draft so release candidates are not presented to users as stable releases.

Useful? React with 👍 / 👎.

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
4 changes: 4 additions & 0 deletions tools/verify_installed_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import importlib.util
import sys
from pathlib import Path

import codex32
from codex32 import (
Expand All @@ -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

Expand Down
41 changes: 34 additions & 7 deletions tools/verify_wheel_environment.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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),
Expand All @@ -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,
)


Expand Down
Loading