mirror of
https://github.com/hykilpikonna/hyfetch.git
synced 2026-08-23 01:05:15 -05:00
[+] Release workflow
This commit is contained in:
349
.github/workflows/release.yml
vendored
Normal file
349
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.
|
||||
|
||||
235
tools/release.py
Executable file
235
tools/release.py
Executable file
@@ -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<version>[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())
|
||||
Reference in New Issue
Block a user