Skip to content

Commit c8eeeaf

Browse files
committed
ci: standardize package publishing
Publish unique default-branch development builds to TestPyPI and gate PyPI releases on authorized version tags.
1 parent 5c39d55 commit c8eeeaf

3 files changed

Lines changed: 232 additions & 38 deletions

File tree

‎.github/PUBLISHING.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Publishing
2+
3+
This repository uses GitHub OIDC Trusted Publishing. Pushes to the configured
4+
default branch publish `<pyproject version>.dev<GITHUB_RUN_ID>` to TestPyPI;
5+
valid authorized version tags publish the committed version to PyPI.
6+
7+
Configure `testpypi` and `pypi` GitHub environments and register
8+
`.github/workflows/publish.yml` as a trusted publisher at both indexes. The
9+
`pypi` environment has no reviewer gate: GitHub tag rulesets must restrict
10+
creation, updates, and deletion of `v*.*.*` and `*.*.*` tags to the release
11+
maintainers, and the workflow additionally requires the pusher to hold
12+
`maintain` or `admin` access.
13+
14+
The TestPyPI smoke test installs `onekey_client` and runs `onekey --version`.

‎.github/scripts/package_version.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env python3
2+
"""Read, validate, or temporarily rewrite the package version for CI releases."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import re
9+
from pathlib import Path
10+
11+
PYPROJECT = Path("pyproject.toml")
12+
RELEASE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)$")
13+
VERSION_VALUE = re.compile(r"""^version\s*=\s*["']([^"']+)["']\s*$""")
14+
SECTION = re.compile(r"^\[([^]]+)]\s*$")
15+
16+
17+
def package_version() -> tuple[str, str]:
18+
active_section = None
19+
for line in PYPROJECT.read_text().splitlines():
20+
if header := SECTION.match(line.strip()):
21+
active_section = header.group(1)
22+
continue
23+
if version := VERSION_VALUE.match(line):
24+
if active_section in {"project", "tool.poetry"}:
25+
return active_section, version.group(1)
26+
raise SystemExit("Could not find a package version in pyproject.toml.")
27+
28+
29+
def rewrite_version(section: str, version: str) -> None:
30+
lines = PYPROJECT.read_text().splitlines(keepends=True)
31+
active_section = None
32+
replacements = 0
33+
for index, line in enumerate(lines):
34+
if header := SECTION.match(line.strip()):
35+
active_section = header.group(1)
36+
continue
37+
if active_section == section and VERSION_VALUE.match(line):
38+
lines[index] = f'version = "{version}"\n'
39+
replacements += 1
40+
if replacements != 1:
41+
raise SystemExit(f"Expected one version entry in [{section}], found {replacements}.")
42+
PYPROJECT.write_text("".join(lines))
43+
44+
45+
def set_output(version: str) -> None:
46+
print(version)
47+
if output := os.environ.get("GITHUB_OUTPUT"):
48+
with Path(output).open("a") as stream:
49+
stream.write(f"version={version}\n")
50+
51+
52+
parser = argparse.ArgumentParser()
53+
parser.add_argument("--test-run-id")
54+
parser.add_argument("--validate-release-tag")
55+
args = parser.parse_args()
56+
57+
section, version = package_version()
58+
if args.validate_release_tag:
59+
match = RELEASE_VERSION.fullmatch(args.validate_release_tag)
60+
if not match:
61+
raise SystemExit(
62+
f"Release tag {args.validate_release_tag!r} must be X.Y.Z or vX.Y.Z."
63+
)
64+
if match.group(1) != version:
65+
raise SystemExit(
66+
f"Release tag {args.validate_release_tag!r} does not match package version "
67+
f"{version!r}."
68+
)
69+
elif args.test_run_id:
70+
version = f"{version}.dev{args.test_run_id}"
71+
rewrite_version(section, version)
72+
73+
set_output(version)

‎.github/workflows/publish.yml‎

Lines changed: 145 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,183 @@
1-
name: PyPI Publish
1+
name: Publish Python distribution
22

33
on:
4-
release:
5-
types: [created]
6-
workflow_dispatch:
7-
inputs:
8-
test_release:
9-
description: If true, publish to test.pypi.org
10-
required: true
11-
default: true
12-
type: boolean
4+
push:
5+
branches: [master]
6+
tags: ["*"]
7+
8+
permissions:
9+
contents: read
10+
11+
# Publishing runs must not be cancelled: every default-branch push gets a
12+
# distinct TestPyPI version.
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.run_id }}
15+
cancel-in-progress: false
16+
17+
env:
18+
PACKAGE_NAME: onekey_client
1319

1420
jobs:
21+
quality:
22+
name: Quality checks
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v6
26+
with:
27+
persist-credentials: false
28+
- uses: astral-sh/setup-uv@v7
29+
with:
30+
enable-cache: true
31+
cache-dependency-glob: uv.lock
32+
- run: uv run ruff format --diff --exit-non-zero-on-format
33+
- run: uv run ruff check
34+
1535
build:
1636
name: Build distribution
37+
needs: quality
1738
runs-on: ubuntu-latest
39+
outputs:
40+
version: ${{ steps.package-version.outputs.version }}
41+
steps:
42+
- uses: actions/checkout@v6
43+
with:
44+
persist-credentials: false
45+
- uses: actions/setup-python@v6
46+
with:
47+
python-version: "3.10"
48+
- id: package-version
49+
name: Set TestPyPI development version
50+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
51+
run: python .github/scripts/package_version.py --test-run-id "${{ github.run_id }}"
52+
- name: Read production version
53+
if: github.ref != format('refs/heads/{0}', github.event.repository.default_branch)
54+
run: python .github/scripts/package_version.py
55+
- name: Install build tools
56+
run: python -m pip install --user build twine
57+
- name: Build distribution packages
58+
run: python -m build
59+
- name: Check distribution metadata
60+
run: python -m twine check dist/*
61+
- uses: actions/upload-artifact@v5
62+
with:
63+
name: python-package-distributions
64+
path: dist/
1865

66+
validate-release-tag:
67+
name: Validate release tag
68+
if: startsWith(github.ref, 'refs/tags/')
69+
runs-on: ubuntu-latest
70+
outputs:
71+
version: ${{ steps.version.outputs.version }}
1972
steps:
2073
- uses: actions/checkout@v6
2174
with:
75+
fetch-depth: 0
2276
persist-credentials: false
23-
- name: Install uv
24-
uses: astral-sh/setup-uv@v7
77+
- id: version
78+
env:
79+
RELEASE_TAG: ${{ github.ref_name }}
80+
run: python .github/scripts/package_version.py --validate-release-tag "$RELEASE_TAG"
81+
- name: Require tag commit on the default branch
82+
env:
83+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
84+
run: |
85+
git fetch origin "$DEFAULT_BRANCH"
86+
git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"
87+
88+
authorize-release-pusher:
89+
name: Authorize release pusher
90+
if: startsWith(github.ref, 'refs/tags/')
91+
runs-on: ubuntu-latest
92+
steps:
93+
- uses: actions/github-script@v8
2594
with:
26-
enable-cache: true
27-
cache-dependency-glob: "uv.lock"
28-
- name: Build distribution
29-
run: uv build
30-
- name: Store distribution packages
31-
uses: actions/upload-artifact@v5
95+
script: |
96+
const username = context.payload.sender?.login;
97+
if (!username) core.setFailed("The tag push has no GitHub user identity.");
98+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
99+
owner: context.repo.owner, repo: context.repo.repo, username,
100+
});
101+
if (!["maintain", "admin"].includes(data.role_name)) {
102+
core.setFailed(`${username} has ${data.role_name}, not maintain/admin.`);
103+
}
104+
105+
publish-to-testpypi:
106+
name: Publish distribution to TestPyPI
107+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
108+
needs: build
109+
runs-on: ubuntu-latest
110+
environment:
111+
name: testpypi
112+
url: https://test.pypi.org/p/onekey_client
113+
permissions:
114+
id-token: write
115+
steps:
116+
- uses: actions/download-artifact@v6
32117
with:
33118
name: python-package-distributions
34119
path: dist/
120+
- uses: pypa/gh-action-pypi-publish@release/v1
121+
with:
122+
repository-url: https://test.pypi.org/legacy/
123+
124+
smoke-test-testpypi:
125+
name: Smoke test TestPyPI distribution
126+
needs: [build, publish-to-testpypi]
127+
runs-on: ubuntu-latest
128+
steps:
129+
- uses: actions/setup-python@v6
130+
with:
131+
python-version: "3.10"
132+
- run: python -m venv .venv
133+
- name: Install the uploaded distribution
134+
env:
135+
VERSION: ${{ needs.build.outputs.version }}
136+
run: |
137+
for attempt in 1 2 3 4 5; do
138+
.venv/bin/python -m pip install --pre \
139+
--index-url https://test.pypi.org/simple \
140+
--extra-index-url https://pypi.org/simple \
141+
"$PACKAGE_NAME==$VERSION" && exit 0
142+
sleep 15
143+
done
144+
exit 1
145+
- run: .venv/bin/onekey --version
35146

36147
publish-to-pypi:
37148
name: Publish distribution to PyPI
38-
if: github.event_name == 'release'
39-
needs: build
149+
if: startsWith(github.ref, 'refs/tags/')
150+
needs: [build, validate-release-tag, authorize-release-pusher]
40151
runs-on: ubuntu-latest
41152
environment:
42153
name: pypi
43154
url: https://pypi.org/p/onekey_client
44155
permissions:
45156
id-token: write
46-
47157
steps:
48-
- name: Download distribution packages
49-
uses: actions/download-artifact@v6
158+
- uses: actions/download-artifact@v6
50159
with:
51160
name: python-package-distributions
52161
path: dist/
53-
- name: Publish distribution to PyPI
54-
uses: pypa/gh-action-pypi-publish@release/v1
162+
- uses: pypa/gh-action-pypi-publish@release/v1
55163

56-
publish-to-testpypi:
57-
name: Publish distribution to TestPyPI
58-
if: github.event_name == 'workflow_dispatch' && inputs.test_release
59-
needs: build
164+
release:
165+
name: Create GitHub Release
166+
if: startsWith(github.ref, 'refs/tags/')
167+
needs: [build, validate-release-tag, authorize-release-pusher, publish-to-pypi]
60168
runs-on: ubuntu-latest
61-
environment:
62-
name: testpypi
63-
url: https://test.pypi.org/p/onekey_client
64169
permissions:
170+
attestations: write
171+
contents: write
65172
id-token: write
66-
67173
steps:
68-
- name: Download distribution packages
69-
uses: actions/download-artifact@v6
174+
- uses: actions/download-artifact@v6
70175
with:
71176
name: python-package-distributions
72177
path: dist/
73-
- name: Publish distribution to TestPyPI
74-
uses: pypa/gh-action-pypi-publish@release/v1
178+
- uses: actions/attest-build-provenance@v3
75179
with:
76-
repository-url: https://test.pypi.org/legacy/
180+
subject-path: dist/*
181+
- env:
182+
GH_TOKEN: ${{ github.token }}
183+
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes dist/*

0 commit comments

Comments
 (0)