diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 58c5de0..aa019fa 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -5,30 +5,62 @@ on: tags: - 'v*' +permissions: + contents: read + jobs: - build-and-publish: - runs-on: ubuntu-latest + validate: + uses: ./.github/workflows/validate-package.yml + with: + expected-version: ${{ github.ref_name }} + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' - - - name: Install build dependencies + - name: Download validated distributions + uses: actions/download-artifact@v4 + with: + name: validated-distributions + path: dist + - name: Confirm version is not already on PyPI + env: + RELEASE_VERSION: ${{ github.ref_name }} run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install build twine + python - <<'PY' + import os + from urllib.error import HTTPError + from urllib.request import urlopen - - name: Build package - run: python -m build - - - name: Publish package to PyPI + version = os.environ["RELEASE_VERSION"].removeprefix("v") + try: + with urlopen(f"https://pypi.org/pypi/how-cli/{version}/json", timeout=20): + raise SystemExit(f"Refusing to publish how-cli {version}: this version already exists on PyPI") + except HTTPError as error: + if error.code != 404: + raise SystemExit(f"Could not check PyPI for how-cli {version}: HTTP {error.code}") + except Exception as error: + raise SystemExit(f"Could not check PyPI for how-cli {version}: {type(error).__name__}") + print(f"Version {version} is not currently on PyPI") + PY + - name: Install upload tool + run: python -m pip install --upgrade twine + - name: Publish distributions to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: twine upload dist/* + run: | + if [ -z "$TWINE_PASSWORD" ]; then + echo "PyPI upload token is missing. Configure the PYPI_API_TOKEN repository secret or enable Trusted Publishing." + exit 1 + fi + python -m twine upload --non-interactive dist/* || { + echo "PyPI upload failed. Check the repository token's validity and project permissions; credentials were not printed." + exit 1 + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d648026 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + - name: Install project and validation tools + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt + python -m pip install -e . + - name: Run tests + run: python -m pytest -q + - name: Check CLI startup + run: | + how --version + how --help + python -m how --help + how setup --help + how to --help + how fix --help + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* diff --git a/.github/workflows/validate-package.yml b/.github/workflows/validate-package.yml new file mode 100644 index 0000000..c8c308d --- /dev/null +++ b/.github/workflows/validate-package.yml @@ -0,0 +1,84 @@ +name: Validate Package + +on: + workflow_call: + inputs: + expected-version: + required: true + type: string + workflow_dispatch: + inputs: + expected-version: + description: Expected package version + required: true + type: string + +permissions: + contents: read + +jobs: + build-validate-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install build and metadata tools + run: python -m pip install --upgrade pip build twine packaging + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* + - name: Verify distributions and version + env: + EXPECTED_VERSION: ${{ inputs.expected-version || github.event.inputs.expected-version }} + run: | + python - <<'PY' + import os + import tarfile + import zipfile + from pathlib import Path + from email.parser import Parser + from packaging.utils import canonicalize_name, parse_wheel_filename, parse_sdist_filename + + expected = os.environ["EXPECTED_VERSION"].removeprefix("v") + artifacts = list(Path("dist").iterdir()) + wheels = [path for path in artifacts if path.suffix == ".whl"] + sdists = [path for path in artifacts if path.name.endswith(".tar.gz")] + if len(wheels) != 1 or len(sdists) != 1: + raise SystemExit(f"Expected one wheel and one sdist, found {artifacts}") + + wheel_name, wheel_version, *_ = parse_wheel_filename(wheels[0].name) + sdist_name, sdist_version = parse_sdist_filename(sdists[0].name) + with zipfile.ZipFile(wheels[0]) as archive: + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + wheel_metadata = Parser().parsestr(archive.read(metadata_path).decode()) + with tarfile.open(sdists[0], "r:gz") as archive: + metadata_path = next(name for name in archive.getnames() if name.endswith("/PKG-INFO")) + sdist_metadata = Parser().parsestr(archive.extractfile(metadata_path).read().decode()) + if canonicalize_name(wheel_metadata["Name"]) != "how-cli" or canonicalize_name(sdist_metadata["Name"]) != "how-cli": + raise SystemExit("Distribution metadata has an unexpected package name") + versions = tuple(map(str, (wheel_version, sdist_version, wheel_metadata["Version"], sdist_metadata["Version"]))) + if any(version != expected for version in versions): + raise SystemExit(f"Expected version {expected}; found {versions}") + for archive in (wheels[0], sdists[0]): + if archive.stat().st_size == 0: + raise SystemExit(f"Empty distribution: {archive}") + print(f"Validated {wheel_name} {expected} in wheel and sdist") + PY + - name: Install wheel in a clean environment and smoke test + run: | + python -m venv /tmp/how-cli-wheel-env + /tmp/how-cli-wheel-env/bin/python -m pip install --upgrade pip + /tmp/how-cli-wheel-env/bin/python -m pip install dist/*.whl + cd /tmp + /tmp/how-cli-wheel-env/bin/how --version + /tmp/how-cli-wheel-env/bin/how --help + /tmp/how-cli-wheel-env/bin/python -c 'import how; print(how.__file__)' + - name: Save validated distributions + uses: actions/upload-artifact@v4 + with: + name: validated-distributions + path: dist/* + if-no-files-found: error diff --git a/how/core/__init__.py b/how/core/__init__.py index d4ef86d..8fd71c6 100644 --- a/how/core/__init__.py +++ b/how/core/__init__.py @@ -2,5 +2,5 @@ Package containing the core modules of the project. """ -__version__ = "0.3.0" +__version__ = "0.3.1" __author__ = "Rudransh Joshi (FireHead90544)" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b16e121 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt + +# Local and CI validation tools +pytest +build +twine