diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8bc4a2e6..32dfd4d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,11 @@ name: Build on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened] + workflow_call: + inputs: + release_id: + required: true + type: string permissions: contents: read @@ -18,7 +18,7 @@ jobs: platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} permissions: - contents: read + contents: write security-events: write steps: @@ -28,12 +28,19 @@ jobs: egress-policy: audit - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.0 + with: + persist-credentials: false + fetch-depth: 0 # Fetches all history and tags - name: Setup Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.13' + - name: Install Zig (Windows) + if: runner.os == 'Windows' + run: choco install zig -y + - name: Set up Ruby if: ${{ matrix.platform != 'windows-latest' }} uses: ruby/setup-ruby@4c24fa5ec04b2e79eb40571b1cee2a0d2b705771 #v1.278.0 @@ -71,7 +78,7 @@ jobs: path: ${{ github.workspace }}\.clcache key: ${{ github.job }}-${{ matrix.platform }} - - name: Create binary + - name: Create sbom, binary & package env: CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_NOHASHDIR: true @@ -95,8 +102,24 @@ jobs: build/dfetch-package/*.msi build/dfetch-package/*.cdx.json + - name: Upload installer to release + if: ${{ inputs.release_id }} + uses: softprops/action-gh-release@5122b4edc95f85501a71628a57dc180a03ec7588 # v2.5.0 + with: + tag_name: ${{ inputs.release_id }} + files: | + build/dfetch-package/*.deb + build/dfetch-package/*.rpm + build/dfetch-package/*.pkg + build/dfetch-package/*.msi + build/dfetch-package/*.cdx.json + draft: true + preserve_order: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test-binary: - name: test binary + name: Test dfetch from installer needs: - build strategy: @@ -146,3 +169,56 @@ jobs: - run: dfetch update - run: dfetch update - run: dfetch report -t sbom + + + build-whl: + name: Build wheel 📦 + runs-on: ubuntu-latest + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.0 + with: + persist-credentials: false + fetch-depth: 0 # Fetches all history and tags + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + - name: Install dependencies + run: python -m pip install --upgrade pip build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: python-package-distributions + path: dist/ + + release: + name: Upload wheel to release 📦 + runs-on: ubuntu-latest + if: ${{ inputs.release_id }} + needs: build-whl + permissions: + contents: write + security-events: write + steps: + - name: Download all the dists + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5 + with: + name: python-package-distributions + path: dist/ + - name: Upload artifacts to release + uses: softprops/action-gh-release@5122b4edc95f85501a71628a57dc180a03ec7588 # v2.5.0 + with: + tag_name: ${{ inputs.release_id }} + files: dist/* + draft: true + preserve_order: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1a43091a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI & Release Orchestration + +on: + push: + branches: + - main + tags: + - '[0-9]+.[0-9]+.[0-9]+' + pull_request: + types: [opened, synchronize, reopened] + + # Allows to run this workflow manually + workflow_dispatch: + +permissions: + contents: read + +jobs: + prep-release: + uses: ./.github/workflows/release.yml + permissions: + contents: write + security-events: write + + build: + needs: prep-release + uses: ./.github/workflows/build.yml + permissions: + contents: write + security-events: write + with: + release_id: ${{ needs.prep-release.outputs.release_id }} + + run: + uses: ./.github/workflows/run.yml + permissions: + contents: read + security-events: write diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3c3861a5..d762166a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: - name: Install Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: - python-version: '3.x' + python-version: '3.13' - name: Install documentation requirements run: "pip install .[docs] && pip install sphinx_design" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index e9497f82..8c19f27a 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -5,13 +5,12 @@ name: Upload Python Package on: release: - types: [created] + types: [published] # Once manually verified, draft is released + + # No support for reusable workflows (yet): https://github.com/pypi/warehouse/issues/11096 pull_request: types: [opened, synchronize, reopened] - # Allows to run this workflow manually - workflow_dispatch: - permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4b922896 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Releases + +on: + workflow_call: + outputs: + release_id: + description: "Tag name to use of release, empty if not needed" + value: ${{ jobs.prepare-release.outputs.release_id }} + +permissions: + contents: read + +jobs: + prepare-release: + runs-on: ubuntu-latest + permissions: + contents: write + security-events: write + + outputs: + release_id: ${{ steps.release_info.outputs.tag }} + + steps: + - uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.0 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + + - name: Determine release info + id: release_info + run: | + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + BRANCH="${GITHUB_HEAD_REF}" + else + BRANCH="${GITHUB_REF#refs/heads/}" + fi + + if [[ "$BRANCH" == "main" ]]; then + TAG="latest" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + else + TAG="" + fi + echo "tag=$TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Update latest tag + if: ${{ steps.release_info.outputs.tag == 'latest' }} + uses: EndBug/latest-tag@52ce15b2695f86a4ce47b72387dee54e47f6356c # v1.6.2 + with: + ref: latest + description: Last state in main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate release notes + if: ${{ steps.release_info.outputs.tag }} + id: notes + run: | + python script/create_release_notes.py + + - name: Delete existing release + if: ${{ steps.release_info.outputs.tag == 'latest' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release_info.outputs.tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + gh release delete "$TAG" --yes + else + echo "No release found for $TAG." + fi + + - name: Create release + if: ${{ steps.release_info.outputs.tag }} + uses: softprops/action-gh-release@5122b4edc95f85501a71628a57dc180a03ec7588 # v2.5.0 + with: + tag_name: ${{ steps.release_info.outputs.tag }} + name: ${{ steps.release_info.outputs.tag }} + body_path: release_notes.txt + draft: true + files: LICENSE + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 348576e1..ca4ce67d 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -1,11 +1,7 @@ name: Run on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened] + workflow_call: permissions: contents: read @@ -35,6 +31,9 @@ jobs: echo "C:\Program Files (x86)\Subversion\bin" >> $env:GITHUB_PATH svn --version # Verify installation + - name: Install Zig (Windows) + run: choco install zig --version=0.15.2 -y + - name: Install dfetch run: pip install . @@ -58,7 +57,7 @@ jobs: dfetch update dfetch report - test: + run: strategy: matrix: platform: [ubuntu-latest, macos-latest, windows-latest] @@ -102,6 +101,10 @@ jobs: echo "C:\Program Files (x86)\Subversion\bin" >> $env:GITHUB_PATH svn --version # Verify installation + - name: Install Zig (Windows) + if: runner.os == 'Windows' + run: choco install zig --version=0.15.2 -y + - name: Install dfetch run: pip install . diff --git a/.gitignore b/.gitignore index f420ad5d..efb8b658 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ doc/landing-page/_build example/Tests/ venv* *.cdx.json +release_notes.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bee8b814..65f9f309 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,10 @@ Release 0.11.0 (unreleased) ==================================== +.. note:: + + This is the latest unreleased version and may change + * Support python 3.14 * Drop python 3.7, 3.8 support (#801) * Don't show animation when running in CI (#702) diff --git a/README.md b/README.md index 28ffcdd6..f7320850 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ pip install git+https://github.com/dfetch-org/dfetch.git#egg=dfetch ### Binary distributions -The [build.yml](https://github.com/dfetch-org/dfetch/actions/workflows/build.yml) produces installers for all major platforms. -See the artifacts in the run. +Each release on the [releases page](https://github.com/dfetch-org/dfetch/releases) provides installers for all major platforms. - Linux `.deb` & `.rpm` - macOS `.pkg` diff --git a/doc/contributing.rst b/doc/contributing.rst index bdb601c7..8b9fbb75 100644 --- a/doc/contributing.rst +++ b/doc/contributing.rst @@ -108,10 +108,8 @@ Releasing git tag -a '0.5.0' -m "Release version 0.5.0" git push --tags -- If all tests ok, create release in the `GitHub webui `_. -- Make sure all dependencies in ``pyproject.toml`` are pinned. -- Copy the CHANGELOG entry of the release to github. -- When the release is created, a new package is automatically pushed to `PyPi `_. +- The ``ci.yml`` job will automatically create a draft release in `GitHub Releases `_ with all artifacts. +- Once the release is published, a new package is automatically pushed to `PyPi `_. - After release, add new header to ``CHANGELOG.rst``: diff --git a/pyproject.toml b/pyproject.toml index b2a782a5..38afeeef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "setuptools-scm", "wheel"] +requires = ["setuptools", "setuptools-scm==9.2.2", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -104,6 +104,7 @@ casts = ['asciinema==2.4.0'] build = [ 'nuitka==2.8.9', "tomli; python_version < '3.11'", # Tomllib is default in 3.11, required for letting codespell read the pyproject.toml] + "setuptools-scm==9.2.2", # For determining version ] sbom = ["cyclonedx-bom==7.2.1"] diff --git a/script/create_release_notes.py b/script/create_release_notes.py new file mode 100644 index 00000000..5241a47a --- /dev/null +++ b/script/create_release_notes.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +create_release_notes.py + +Extracts the latest section from CHANGELOG.rst. +""" + +import argparse +import re +import sys +from pathlib import Path + + +def extract_latest_section(changelog_path: Path) -> str: + """Extract the latest release section from a CHANGELOG file.""" + + content = changelog_path.read_text(encoding="utf-8") + lines = content.splitlines() + + version_header_pattern = re.compile(r"^Release \d+\.\d+\.\d+") + + start_idx, end_idx = None, None + + for idx, line in enumerate(lines): + if version_header_pattern.match(line.strip()): + start_idx = idx + break + + if start_idx is None: + raise ValueError(f"No release section found in {changelog_path}") + + for idx in range(start_idx + 1, len(lines)): + if version_header_pattern.match(lines[idx].strip()): + end_idx = idx + break + + # If end_idx is None, capture all lines to the end (single release section) + section_lines = lines[start_idx:end_idx] + return "\n".join(section_lines).strip() + + +def main(): + """Main CLI entry.""" + parser = argparse.ArgumentParser( + description="Create release notes from CHANGELOG.rst" + ) + parser.add_argument( + "--changelog", default="CHANGELOG.rst", help="Path to CHANGELOG.rst" + ) + args = parser.parse_args() + + changelog_path = Path(args.changelog) + if not changelog_path.exists(): + print(f"Error: {changelog_path} not found.") + sys.exit(1) + + Path("release_notes.txt").write_text( + extract_latest_section(changelog_path), encoding="UTF-8" + ) + + +if __name__ == "__main__": + main() diff --git a/script/create_sbom.py b/script/create_sbom.py index ddf3b5cf..0bc9bafa 100644 --- a/script/create_sbom.py +++ b/script/create_sbom.py @@ -8,21 +8,22 @@ import venv from pathlib import Path -from dfetch import __version__ - logging.basicConfig(level=logging.INFO) PROJECT_DIR = Path(__file__).parent.parent.resolve() -OUTPUT_FILE = ( - PROJECT_DIR - / "build" - / "dfetch-package" - / f"dfetch-{__version__}.{sys.platform}.cdx.json" -) + DEPS = f"{PROJECT_DIR}[sbom]" +PLATFORM_NAME = "nix" + +if sys.platform.startswith("darwin"): + PLATFORM_NAME = "osx" +elif sys.platform.startswith("win"): + PLATFORM_NAME = "win" + + @contextlib.contextmanager def temporary_venv(): """Create a temporary virtual environment and clean it up on exit.""" @@ -40,8 +41,29 @@ def temporary_venv(): with temporary_venv() as python: - OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True) subprocess.check_call([python, "-m", "pip", "install", DEPS]) # nosec + + __version__ = ( + subprocess.run( # nosec + [ + python, + "-c", + "from importlib.metadata import version; print(version('dfetch'))", + ], + check=True, + capture_output=True, + ) + .stdout.decode("UTF-8") + .strip() + ) + + OUTPUT_FILE = ( + PROJECT_DIR + / "build" + / "dfetch-package" + / f"dfetch-{__version__}-{PLATFORM_NAME}.cdx.json" + ) + OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True) subprocess.check_call( # nosec [python, "-m", "cyclonedx_py", "environment", "-o", str(OUTPUT_FILE)] ) diff --git a/script/package.py b/script/package.py index fd88afaa..bd82d296 100644 --- a/script/package.py +++ b/script/package.py @@ -7,7 +7,15 @@ import xml.etree.ElementTree as ET # nosec (only used for XML generation, not parsing untrusted input) from pathlib import Path -from dfetch import __version__ +from setuptools_scm import get_version + +from dfetch import __version__ as __digit_only_version__ # Used inside the installers + +__version__ = get_version( # Used to name the installers + root=".", + version_scheme="guess-next-dev", + local_scheme="no-local-version", +) # Configuration loading with open("pyproject.toml", "rb") as pyproject_file: @@ -38,6 +46,14 @@ WINDOWS_ICO_PATH = Path(WINDOWS_ICO).resolve() if WINDOWS_ICO else None +PLATFORM_NAME = "nix" + +if sys.platform.startswith("darwin"): + PLATFORM_NAME = "osx" +elif sys.platform.startswith("win"): + PLATFORM_NAME = "win" + + def run_command(command: list[str]) -> None: """Run a system command and handle errors.""" resolved_cmd = shutil.which(command[0]) @@ -53,7 +69,7 @@ def run_command(command: list[str]) -> None: def package_linux() -> None: """Package the build directory into .deb and .rpm installers.""" for target in ("deb", "rpm"): - output = f"{OUTPUT_DIR}/{PACKAGE_NAME}_{__version__}.{target}" + output = f"{OUTPUT_DIR}/{PACKAGE_NAME}-{__version__}-{PLATFORM_NAME}.{target}" cmd = [ "fpm", "-s", @@ -63,7 +79,7 @@ def package_linux() -> None: "-n", PACKAGE_NAME, "-v", - __version__, + __digit_only_version__, "-C", str(BUILD_DIR), "--prefix", @@ -94,7 +110,7 @@ def package_macos() -> None: "-n", PACKAGE_NAME, "-v", - __version__, + __digit_only_version__, "-C", str(BUILD_DIR), # https://github.com/jordansissel/fpm/issues/1996 This prefix results in /opt/dfetch/opt/dfetch @@ -109,7 +125,7 @@ def package_macos() -> None: "--license", LICENSE, "-p", - f"{OUTPUT_DIR}/{PACKAGE_NAME}_{__version__}.pkg", + f"{OUTPUT_DIR}/{PACKAGE_NAME}-{__version__}-{PLATFORM_NAME}.pkg", ".", ] run_command(cmd) @@ -135,7 +151,7 @@ def generate_wix_xml(build_dir: Path, output_wxs: Path) -> None: "Package", Name=PACKAGE_NAME, Manufacturer=MAINTAINER, - Version=__version__, + Version=__digit_only_version__, UpgradeCode=UPGRADE_CODE, ) @@ -232,7 +248,7 @@ def package_windows() -> None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) wix_file = OUTPUT_DIR / f"{PACKAGE_NAME}.wxs" wix_proj = OUTPUT_DIR / f"{PACKAGE_NAME}.wixproj" - msi_file = OUTPUT_DIR / f"{PACKAGE_NAME}_{__version__}.msi" + msi_file = OUTPUT_DIR / f"{PACKAGE_NAME}-{__version__}-{PLATFORM_NAME}.msi" generate_wix_xml(BUILD_DIR, wix_file) generate_wix_proj(wix_proj, wix_file)