[+] Release workflow

This commit is contained in:
Azalea
2026-06-03 07:19:37 +00:00
parent 6b6406e2db
commit 0fd343e6d5
4 changed files with 635 additions and 8 deletions

349
.github/workflows/release.yml vendored Normal file
View 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

View File

@@ -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.')

View File

@@ -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
View 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())