From 0fd343e6d54a231673bc6d3ae2fd0c1fc102cebe Mon Sep 17 00:00:00 2001 From: Azalea Date: Wed, 3 Jun 2026 07:19:37 +0000 Subject: [PATCH] [+] Release workflow --- .github/workflows/release.yml | 349 ++++++++++++++++++++++++++++++++++ tools/deploy-release.py | 35 +++- tools/deploy.md | 24 ++- tools/release.py | 235 +++++++++++++++++++++++ 4 files changed, 635 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 tools/release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0e384815 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,349 @@ +name: Release + +on: + push: + tags: + - "[0-9]*.[0-9]*.[0-9]*" + - "v[0-9]*.[0-9]*.[0-9]*" + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to publish, such as 2.1.1 or v2.1.1" + required: true + type: string + create_github_release: + description: "Create or complete the GitHub Release" + required: true + default: true + type: boolean + publish_pypi: + description: "Publish the Python distributions to PyPI" + required: true + default: true + type: boolean + publish_npm: + description: "Publish package.json to npm" + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + metadata: + name: Validate release metadata + runs-on: ubuntu-22.04 + outputs: + tag: ${{ steps.metadata.outputs.tag }} + version: ${{ steps.metadata.outputs.version }} + python_package: ${{ steps.metadata.outputs.python_package }} + npm_package: ${{ steps.metadata.outputs.npm_package }} + steps: + - name: Resolve release tag + id: release-tag + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.release-tag.outputs.tag }} + + - name: Validate checked-in versions + id: metadata + run: python3 tools/release.py metadata --tag "${{ steps.release-tag.outputs.tag }}" + + release-state: + name: Check completed release stages + runs-on: ubuntu-22.04 + needs: metadata + outputs: + pypi_exists: ${{ steps.state.outputs.pypi_exists }} + npm_exists: ${{ steps.state.outputs.npm_exists }} + github_release_exists: ${{ steps.state.outputs.github_release_exists }} + github_release_complete: ${{ steps.state.outputs.github_release_complete }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.tag }} + + - name: Query registries and GitHub Release + id: state + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 tools/release.py state \ + --tag "${{ needs.metadata.outputs.tag }}" \ + --version "${{ needs.metadata.outputs.version }}" \ + --python-package "${{ needs.metadata.outputs.python_package }}" \ + --npm-package "${{ needs.metadata.outputs.npm_package }}" \ + --repo "${{ github.repository }}" + + - name: Summarize release state + run: | + { + echo "## Release state" + echo + echo "| Stage | Already complete |" + echo "| --- | --- |" + echo "| PyPI | ${{ steps.state.outputs.pypi_exists }} |" + echo "| npm | ${{ steps.state.outputs.npm_exists }} |" + echo "| GitHub Release assets | ${{ steps.state.outputs.github_release_complete }} |" + } >> "$GITHUB_STEP_SUMMARY" + + preflight: + name: Preflight release stages + runs-on: ubuntu-22.04 + needs: + - metadata + - release-state + steps: + - name: Verify pending publish stages + env: + CREATE_GITHUB_RELEASE: ${{ github.event_name != 'workflow_dispatch' || inputs.create_github_release }} + PUBLISH_PYPI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_pypi }} + PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }} + run: | + pending=() + skipped=() + + if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + if [[ "${{ needs.release-state.outputs.github_release_complete }}" == "true" ]]; then + skipped+=("GitHub Release") + else + pending+=("GitHub Release") + fi + fi + + if [[ "$PUBLISH_PYPI" == "true" ]]; then + if [[ "${{ needs.release-state.outputs.pypi_exists }}" == "true" ]]; then + skipped+=("PyPI") + else + pending+=("PyPI") + fi + fi + + if [[ "$PUBLISH_NPM" == "true" ]]; then + if [[ "${{ needs.release-state.outputs.npm_exists }}" == "true" ]]; then + skipped+=("npm") + else + pending+=("npm") + fi + fi + + { + echo "## Preflight" + echo + if ((${#pending[@]})); then + printf 'Pending stages: %s\n\n' "$(IFS=', '; echo "${pending[*]}")" + else + echo "No pending stages." + echo + fi + if ((${#skipped[@]})); then + printf 'Already complete: %s\n\n' "$(IFS=', '; echo "${skipped[*]}")" + fi + echo "PyPI and npm publishing use trusted publishing/OIDC; no registry secrets are required." + } >> "$GITHUB_STEP_SUMMARY" + + build-python-dist: + name: Build Python distributions + runs-on: ubuntu-22.04 + needs: + - metadata + - release-state + - preflight + if: >- + ${{ + always() + && needs.preflight.result == 'success' + && ( + ( + (github.event_name != 'workflow_dispatch' || inputs.publish_pypi) + && needs.release-state.outputs.pypi_exists != 'true' + ) + || ( + (github.event_name != 'workflow_dispatch' || inputs.create_github_release) + && needs.release-state.outputs.github_release_complete != 'true' + ) + ) + }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.tag }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y help2man libarchive-tools shellcheck unzip wget zip + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Build and check distributions + run: tools/build_pkg.sh + + - name: Upload distribution artifact + uses: actions/upload-artifact@v4 + with: + name: python-dist-${{ needs.metadata.outputs.version }} + path: dist/* + if-no-files-found: error + + github-release: + name: Create GitHub Release + runs-on: ubuntu-22.04 + needs: + - metadata + - release-state + - preflight + - build-python-dist + permissions: + contents: write + if: >- + ${{ + always() + && needs.preflight.result == 'success' + && needs.build-python-dist.result == 'success' + && (github.event_name != 'workflow_dispatch' || inputs.create_github_release) + && needs.release-state.outputs.github_release_complete != 'true' + }} + steps: + - uses: actions/download-artifact@v4 + with: + name: python-dist-${{ needs.metadata.outputs.version }} + path: dist + + - name: Create release if needed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.metadata.outputs.tag }} + VERSION: ${{ needs.metadata.outputs.version }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub Release $TAG already exists." + else + gh release create "$TAG" \ + --verify-tag \ + --title "HyFetch $VERSION" \ + --generate-notes + fi + + - name: Upload missing release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.metadata.outputs.tag }} + run: | + mapfile -t existing_assets < <(gh release view "$TAG" --json assets --jq '.assets[].name') + missing_assets=() + + for file in dist/*; do + name="$(basename "$file")" + found=false + for existing in "${existing_assets[@]}"; do + if [[ "$existing" == "$name" ]]; then + found=true + break + fi + done + + if [[ "$found" == "false" ]]; then + missing_assets+=("$file") + fi + done + + if ((${#missing_assets[@]} == 0)); then + echo "All release assets are already uploaded." + else + gh release upload "$TAG" "${missing_assets[@]}" + fi + + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + needs: + - metadata + - release-state + - preflight + - build-python-dist + if: >- + ${{ + always() + && needs.preflight.result == 'success' + && needs.build-python-dist.result == 'success' + && (github.event_name != 'workflow_dispatch' || inputs.publish_pypi) + && needs.release-state.outputs.pypi_exists != 'true' + }} + steps: + - uses: actions/download-artifact@v4 + with: + name: python-dist-${{ needs.metadata.outputs.version }} + path: dist + + - name: Publish distributions + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + + publish-npm: + name: Publish to npm + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + needs: + - metadata + - release-state + - preflight + if: >- + ${{ + always() + && needs.preflight.result == 'success' + && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) + && needs.release-state.outputs.npm_exists != 'true' + }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.tag }} + + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Use npm with trusted publishing support + run: npm install -g npm@11 + + - name: Confirm npm version is still unpublished + id: npm-state + env: + NPM_PACKAGE: ${{ needs.metadata.outputs.npm_package }} + VERSION: ${{ needs.metadata.outputs.version }} + run: | + if npm view "$NPM_PACKAGE@$VERSION" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "$NPM_PACKAGE@$VERSION is already published; skipping." + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Publish package + if: steps.npm-state.outputs.exists != 'true' + run: npm publish --provenance diff --git a/tools/deploy-release.py b/tools/deploy-release.py index 56692d5d..7c818f5e 100755 --- a/tools/deploy-release.py +++ b/tools/deploy-release.py @@ -14,6 +14,16 @@ from tools.list_distros import generate_help from tools.reformat_readme import reformat_readme NEOFETCH_NEW_VERSION = "" +RELEASE_FILES = [ + 'Cargo.lock', + 'Cargo.toml', + 'README.md', + 'docs/hyfetch.1', + 'docs/neofetch.1', + 'hyfetch/__version__.py', + 'neofetch', + 'package.json', +] def pre_check(): @@ -23,7 +33,8 @@ def pre_check(): assert os.path.isfile('./neofetch'), './neofetch doesn\'t exist, you are running this script in the wrong directory' assert os.stat('./neofetch').st_mode & stat.S_IEXEC, 'neofetch is not executable' assert os.path.islink('./hyfetch/scripts/neowofetch'), 'neowofetch is not a symbolic link' - # subprocess.check_call(shlex.split('git diff-index --quiet HEAD --')) # 'Please commit all changes before release' + assert not subprocess.check_output(['git', 'status', '--porcelain']).strip(), \ + 'Please commit or stash all changes before release' print('Running shellcheck... (This may take a while)') subprocess.check_call(shlex.split('shellcheck neofetch')) @@ -51,7 +62,7 @@ def edit_versions(version: str): path = Path('hyfetch/__version__.py') content = [f"VERSION = '{version}'" if l.startswith('VERSION = ') else l for l in path.read_text().split('\n')] path.write_text('\n'.join(content)) - + # 3. Cargo.toml print('Editing Cargo.toml...') path = Path('Cargo.toml') @@ -59,6 +70,9 @@ def edit_versions(version: str): content = re.sub(r'(?<=^version = ")[^"]+(?="$)', version, content, flags=re.MULTILINE) path.write_text(content) + print('Updating Cargo.lock...') + subprocess.check_call(['cargo', 'metadata', '--format-version', '1'], stdout=subprocess.DEVNULL) + # 4. README.md print('Editing README.md...') path = Path('README.md') @@ -122,7 +136,7 @@ def create_release(v: str): print('Committing changes...') # 1. Add files - subprocess.check_call(['git', 'add', '.']) + subprocess.check_call(['git', 'add', *RELEASE_FILES]) # 2. Commit subprocess.check_call(['git', 'commit', '-m', f'[U] Release {v}']) @@ -134,9 +148,10 @@ def create_release(v: str): i = input('Please check the commit is correct. Press y to continue or any other key to cancel.') if i.lower() != 'y': print('Aborting...') - subprocess.check_call(['git', 'reset', '--hard', 'HEAD~1']) subprocess.check_call(['git', 'tag', '-d', v]) subprocess.check_call(['git', 'tag', '-d', f'neofetch-{NEOFETCH_NEW_VERSION}']) + subprocess.check_call(['git', 'reset', '--soft', 'HEAD~1']) + print('Release commit was undone with changes preserved in the index.') exit(1) # 4. Push @@ -152,7 +167,7 @@ def deploy(): print('Deploying to pypi...') subprocess.check_call(['bash', 'tools/deploy.sh']) print('Done!') - + print('Deploying to crates.io...') subprocess.check_call(['bash', 'tools/deploy-crate.sh']) print('Done!') @@ -166,6 +181,11 @@ def deploy(): if __name__ == '__main__': parser = argparse.ArgumentParser(description='HyFetch Release Utility') parser.add_argument('version', help='Version to release') + parser.add_argument( + '--local-deploy', + action='store_true', + help='Publish from this machine after pushing tags. By default, GitHub Actions publishes the release.', + ) args = parser.parse_args() @@ -175,5 +195,8 @@ if __name__ == '__main__': finalize_neofetch() post_check() create_release(args.version) - deploy() + if args.local_deploy: + deploy() + else: + print('Release tag pushed. GitHub Actions will create the GitHub Release and publish packages.') diff --git a/tools/deploy.md b/tools/deploy.md index 8c44323b..2fd6fd09 100644 --- a/tools/deploy.md +++ b/tools/deploy.md @@ -1,7 +1,27 @@ (If anyone stumbles upon this file, it's just some notes for myself because I always forgor stuff) -### Things to do before deploying... +### One-time GitHub setup + +Configure trusted publishing for the release workflow: + +* PyPI project `HyFetch`: trust this repository and `.github/workflows/release.yml` +* npm package `neowofetch`: trust this repository and `.github/workflows/release.yml` + +No PyPI or npm API token secrets are needed. The publish jobs request GitHub's +OIDC token with `id-token: write`, and the registries exchange that token through +their trusted publishing flows. + +### Things to do before deploying * [ ] Update changelog (`README.md`) and commit changes -* [ ] Run `mamba activate 313` (an environment that has build deps e.g. twine) * [ ] Run `python -m tools.deploy-release {version}` +* [ ] Watch the `Release` workflow + +The local script prepares the release commit and pushes the version tag. GitHub +Actions creates/completes the GitHub Release, uploads Python artifacts, publishes +to PyPI, and publishes to npm. + +If the workflow fails after one stage already published, fix the problem and +rerun it. If the fix changes the workflow itself, run the workflow manually with +the same tag. The workflow checks PyPI, npm, and the GitHub Release first, so it +will skip completed stages instead of publishing them again. diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 00000000..20471cdb --- /dev/null +++ b/tools/release.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional, Set, Tuple + + +ROOT = Path(__file__).resolve().parents[1] +VERSION_RE = re.compile(r"^v?(?P[0-9]+\.[0-9]+\.[0-9]+)$") + + +def write_output(name: str, value) -> None: + text = str(value).lower() if isinstance(value, bool) else str(value) + output_path = os.environ.get("GITHUB_OUTPUT") + + if output_path: + with open(output_path, "a", encoding="utf-8") as f: + f.write(f"{name}={text}\n") + else: + print(f"{name}={text}") + + +def read_workspace_version() -> str: + in_workspace_package = False + + for line in (ROOT / "Cargo.toml").read_text(encoding="utf-8").splitlines(): + if line.startswith("[") and line.endswith("]"): + in_workspace_package = line == "[workspace.package]" + continue + + if in_workspace_package: + match = re.match(r'version = "([^"]+)"', line) + if match: + return match.group(1) + + raise RuntimeError("Could not find [workspace.package] version in Cargo.toml") + + +def read_cargo_lock_version() -> str: + package = {} + + for line in (ROOT / "Cargo.lock").read_text(encoding="utf-8").splitlines(): + if line == "[[package]]": + if package.get("name") == "hyfetch": + return package["version"] + package = {} + continue + + match = re.match(r'(name|version) = "([^"]+)"', line) + if match: + package[match.group(1)] = match.group(2) + + if package.get("name") == "hyfetch": + return package["version"] + + raise RuntimeError("Could not find hyfetch package version in Cargo.lock") + + +def read_python_version() -> str: + content = (ROOT / "hyfetch" / "__version__.py").read_text(encoding="utf-8") + match = re.search(r"^VERSION = ['\"]([^'\"]+)['\"]$", content, re.MULTILINE) + if not match: + raise RuntimeError("Could not find VERSION in hyfetch/__version__.py") + + return match.group(1) + + +def read_package_json() -> dict: + return json.loads((ROOT / "package.json").read_text(encoding="utf-8")) + + +def read_pyproject_name() -> str: + content = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^name = "([^"]+)"$', content, re.MULTILINE) + if not match: + raise RuntimeError("Could not find project name in pyproject.toml") + + return match.group(1) + + +def normalize_tag(tag: str) -> Tuple[str, str]: + match = VERSION_RE.match(tag) + if not match: + raise RuntimeError( + f"Release tag {tag!r} is not supported. Use a plain version tag like 2.1.1 or v2.1.1." + ) + + return tag, match.group("version") + + +def expected_python_assets(version: str) -> Set[str]: + package = "hyfetch" + platforms = [ + "any", + "win_amd64", + "manylinux_2_31_x86_64", + "manylinux_2_31_aarch64", + "manylinux_2_31_armv7l", + "musllinux_1_1_x86_64", + "macosx_11_0_x86_64", + "macosx_11_0_arm64", + ] + + assets = {f"{package}-{version}.tar.gz"} + assets.update(f"{package}-{version}-py3-none-{platform}.whl" for platform in platforms) + return assets + + +def http_json_status(url: str, token: Optional[str] = None) -> Tuple[int, Optional[dict]]: + headers = { + "Accept": "application/json", + "User-Agent": "hyfetch-release-workflow", + } + + if token: + headers["Authorization"] = f"Bearer {token}" + + request = urllib.request.Request(url, headers=headers) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read().decode("utf-8") + return response.status, json.loads(payload) if payload else None + except urllib.error.HTTPError as exc: + if exc.code == 404: + return 404, None + raise + + +def pypi_version_exists(package: str, version: str) -> bool: + package_path = urllib.parse.quote(package) + version_path = urllib.parse.quote(version) + status, _ = http_json_status(f"https://pypi.org/pypi/{package_path}/{version_path}/json") + return status == 200 + + +def npm_version_exists(package: str, version: str) -> bool: + package_path = urllib.parse.quote(package, safe="@") + version_path = urllib.parse.quote(version) + status, _ = http_json_status(f"https://registry.npmjs.org/{package_path}/{version_path}") + return status == 200 + + +def github_release_state(repo: str, tag: str, version: str) -> Tuple[bool, bool]: + repo_path = urllib.parse.quote(repo, safe="/") + tag_path = urllib.parse.quote(tag, safe="") + token = os.environ.get("GITHUB_TOKEN") + status, payload = http_json_status( + f"https://api.github.com/repos/{repo_path}/releases/tags/{tag_path}", + token=token, + ) + + if status == 404: + return False, False + + if not payload: + raise RuntimeError(f"GitHub release lookup for {repo}@{tag} returned no data") + + asset_names = {asset["name"] for asset in payload.get("assets", [])} + return True, expected_python_assets(version).issubset(asset_names) + + +def command_metadata(args: argparse.Namespace) -> None: + tag, version = normalize_tag(args.tag) + package_json = read_package_json() + + versions = { + "Cargo.toml": read_workspace_version(), + "Cargo.lock": read_cargo_lock_version(), + "hyfetch/__version__.py": read_python_version(), + "package.json": package_json["version"], + } + mismatches = {path: found for path, found in versions.items() if found != version} + + if mismatches: + details = ", ".join(f"{path} has {found}" for path, found in mismatches.items()) + raise RuntimeError(f"Release tag version {version} does not match checked-in versions: {details}") + + write_output("tag", tag) + write_output("version", version) + write_output("python_package", read_pyproject_name()) + write_output("npm_package", package_json["name"]) + + +def command_state(args: argparse.Namespace) -> None: + pypi_exists = pypi_version_exists(args.python_package, args.version) + npm_exists = npm_version_exists(args.npm_package, args.version) + github_release_exists, github_release_complete = github_release_state(args.repo, args.tag, args.version) + + write_output("pypi_exists", pypi_exists) + write_output("npm_exists", npm_exists) + write_output("github_release_exists", github_release_exists) + write_output("github_release_complete", github_release_complete) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Release workflow helpers") + subparsers = parser.add_subparsers(dest="command", required=True) + + metadata = subparsers.add_parser("metadata", help="Validate release tag and checked-in versions") + metadata.add_argument("--tag", required=True) + metadata.set_defaults(func=command_metadata) + + state = subparsers.add_parser("state", help="Check whether release stages already completed") + state.add_argument("--tag", required=True) + state.add_argument("--version", required=True) + state.add_argument("--python-package", required=True) + state.add_argument("--npm-package", required=True) + state.add_argument("--repo", required=True) + state.set_defaults(func=command_state) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + args.func(args) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())