From ecd65754768fd99c5be66e00fa7ff0c22690b077 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sun, 2 Aug 2026 14:26:00 +0800 Subject: [PATCH] ci: publish changed package versions automatically --- .github/workflows/publish.yml | 194 ++++++++++++++++++++++++++++++---- AGENTS.md | 29 +++++ docs/release.md | 24 +++-- scripts/release.py | 150 +++++++++++++++++++++++--- tests/test_release.py | 49 ++++++++- 5 files changed, 403 insertions(+), 43 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7ca0cf..b5ba716 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,6 +17,13 @@ on: required: true type: string push: + branches: + - main + paths: + - packages/automas_hsr/pyproject.toml + - packages/automas_script_hsr/pyproject.toml + - packages/automas_hsr_adapter_sra/pyproject.toml + - packages/automas_hsr_adapter_m7a/pyproject.toml tags: - "automas-hsr-v*" - "automas-script-hsr-v*" @@ -27,17 +34,21 @@ permissions: contents: read concurrency: - group: publish-${{ github.ref }}-${{ inputs.package || 'tag' }} + group: pypi-publish cancel-in-progress: false jobs: build: runs-on: ubuntu-latest outputs: - package: ${{ steps.release.outputs.package }} - environment: ${{ steps.release.outputs.environment }} - version: ${{ steps.release.outputs.version }} - artifact_name: ${{ steps.release.outputs.artifact_name }} + script_selected: ${{ steps.release.outputs.script_selected }} + script_artifact: ${{ steps.release.outputs.script_artifact }} + sra_selected: ${{ steps.release.outputs.sra_selected }} + sra_artifact: ${{ steps.release.outputs.sra_artifact }} + m7a_selected: ${{ steps.release.outputs.m7a_selected }} + m7a_artifact: ${{ steps.release.outputs.m7a_artifact }} + meta_selected: ${{ steps.release.outputs.meta_selected }} + meta_artifact: ${{ steps.release.outputs.meta_artifact }} steps: - uses: actions/checkout@v6 with: @@ -50,54 +61,191 @@ jobs: enable-cache: true - run: uv sync --locked --all-packages --group dev - id: release - name: Resolve and validate release target + name: Resolve version changes and release order env: EVENT_NAME: ${{ github.event_name }} RELEASE_REF: ${{ github.ref }} + BEFORE_SHA: ${{ github.event.before }} INPUT_PACKAGE: ${{ inputs.package }} INPUT_VERSION: ${{ inputs.version }} run: >- uv run python scripts/release.py --event-name "$EVENT_NAME" --ref "$RELEASE_REF" + --before-sha "$BEFORE_SHA" --package "$INPUT_PACKAGE" --version "$INPUT_VERSION" - name: Verify tag commit belongs to main - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main - - run: uv run python -m unittest discover -s tests -v - - name: Build selected distribution - env: - PACKAGE_DIR: ${{ steps.release.outputs.package_dir }} - run: uv run python -m build "$PACKAGE_DIR" --outdir dist - - run: uv run twine check dist/* - - name: Metadata-only smoke test for the selected wheel - env: - PACKAGE: ${{ steps.release.outputs.package }} + - name: Run repository tests + if: steps.release.outputs.has_targets == 'true' + run: uv run python -m unittest discover -s tests -v + + - name: Build automas-script-hsr + if: steps.release.outputs.script_selected == 'true' + run: >- + uv run python -m build packages/automas_script_hsr + --outdir dist/script + - name: Check automas-script-hsr metadata + if: steps.release.outputs.script_selected == 'true' + run: uv run twine check dist/script/* + - name: Smoke test automas-script-hsr wheel + if: steps.release.outputs.script_selected == 'true' + run: >- + uv run python scripts/smoke_wheels.py dist/script + --mode metadata-only + --expected-package automas-script-hsr + - uses: actions/upload-artifact@v4 + if: steps.release.outputs.script_selected == 'true' + with: + name: ${{ steps.release.outputs.script_artifact }} + path: dist/script/* + if-no-files-found: error + retention-days: 7 + + - name: Build automas-hsr-adapter-sra + if: steps.release.outputs.sra_selected == 'true' + run: >- + uv run python -m build packages/automas_hsr_adapter_sra + --outdir dist/sra + - name: Check automas-hsr-adapter-sra metadata + if: steps.release.outputs.sra_selected == 'true' + run: uv run twine check dist/sra/* + - name: Smoke test automas-hsr-adapter-sra wheel + if: steps.release.outputs.sra_selected == 'true' + run: >- + uv run python scripts/smoke_wheels.py dist/sra + --mode metadata-only + --expected-package automas-hsr-adapter-sra + - uses: actions/upload-artifact@v4 + if: steps.release.outputs.sra_selected == 'true' + with: + name: ${{ steps.release.outputs.sra_artifact }} + path: dist/sra/* + if-no-files-found: error + retention-days: 7 + + - name: Build automas-hsr-adapter-m7a + if: steps.release.outputs.m7a_selected == 'true' + run: >- + uv run python -m build packages/automas_hsr_adapter_m7a + --outdir dist/m7a + - name: Check automas-hsr-adapter-m7a metadata + if: steps.release.outputs.m7a_selected == 'true' + run: uv run twine check dist/m7a/* + - name: Smoke test automas-hsr-adapter-m7a wheel + if: steps.release.outputs.m7a_selected == 'true' run: >- - uv run python scripts/smoke_wheels.py dist + uv run python scripts/smoke_wheels.py dist/m7a --mode metadata-only - --expected-package "$PACKAGE" + --expected-package automas-hsr-adapter-m7a - uses: actions/upload-artifact@v4 + if: steps.release.outputs.m7a_selected == 'true' with: - name: ${{ steps.release.outputs.artifact_name }} - path: dist/* + name: ${{ steps.release.outputs.m7a_artifact }} + path: dist/m7a/* if-no-files-found: error retention-days: 7 - publish: + - name: Build automas-hsr + if: steps.release.outputs.meta_selected == 'true' + run: >- + uv run python -m build packages/automas_hsr + --outdir dist/meta + - name: Check automas-hsr metadata + if: steps.release.outputs.meta_selected == 'true' + run: uv run twine check dist/meta/* + - name: Smoke test automas-hsr wheel + if: steps.release.outputs.meta_selected == 'true' + run: >- + uv run python scripts/smoke_wheels.py dist/meta + --mode metadata-only + --expected-package automas-hsr + - uses: actions/upload-artifact@v4 + if: steps.release.outputs.meta_selected == 'true' + with: + name: ${{ steps.release.outputs.meta_artifact }} + path: dist/meta/* + if-no-files-found: error + retention-days: 7 + + publish_script: + name: Publish automas-script-hsr needs: build + if: needs.build.outputs.script_selected == 'true' + runs-on: ubuntu-latest + environment: + name: pypi-script-hsr + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.script_artifact }} + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + + publish_sra: + name: Publish automas-hsr-adapter-sra + needs: [build, publish_script] + if: ${{ always() && needs.build.result == 'success' && needs.build.outputs.sra_selected == 'true' && (needs.publish_script.result == 'success' || needs.publish_script.result == 'skipped') }} + runs-on: ubuntu-latest + environment: + name: pypi-adapter-sra + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.sra_artifact }} + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + + publish_m7a: + name: Publish automas-hsr-adapter-m7a + needs: [build, publish_script, publish_sra] + if: ${{ always() && needs.build.result == 'success' && needs.build.outputs.m7a_selected == 'true' && (needs.publish_script.result == 'success' || needs.publish_script.result == 'skipped') && (needs.publish_sra.result == 'success' || needs.publish_sra.result == 'skipped') }} + runs-on: ubuntu-latest + environment: + name: pypi-adapter-m7a + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.m7a_artifact }} + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + + publish_meta: + name: Publish automas-hsr + needs: [build, publish_script, publish_sra, publish_m7a] + if: ${{ always() && needs.build.result == 'success' && needs.build.outputs.meta_selected == 'true' && (needs.publish_script.result == 'success' || needs.publish_script.result == 'skipped') && (needs.publish_sra.result == 'success' || needs.publish_sra.result == 'skipped') && (needs.publish_m7a.result == 'success' || needs.publish_m7a.result == 'skipped') }} runs-on: ubuntu-latest environment: - name: ${{ needs.build.outputs.environment }} + name: pypi-hsr permissions: contents: read id-token: write steps: - uses: actions/download-artifact@v4 with: - name: ${{ needs.build.outputs.artifact_name }} + name: ${{ needs.build.outputs.meta_artifact }} path: dist - uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist + skip-existing: true diff --git a/AGENTS.md b/AGENTS.md index dfa4620..a3c21fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,32 @@ - 修改包的代码或发行契约时,同步更新对应测试、版本和内部依赖下限。 - 提交前运行 `uv run python -m unittest discover -s tests -v` 和 `uv run python scripts/build_all.py`。 - 不提交 `dist/`、`build/`、`*.egg-info`、虚拟环境或 Python 缓存。 + +## 版本号与 PyPI 自动发布 + +本仓库的 `.github/workflows/publish.yml` 会监视 `main` 上四个 distribution 的 +`pyproject.toml`。一旦某个 `[project].version` 相比 push 前的提交发生变化,该包就会 +自动测试、构建,并通过 PyPI Trusted Publishing 上传;修改其他字段但不修改版本号 +不会发布。 + +版本文件与 distribution 的对应关系: + +- `packages/automas_script_hsr/pyproject.toml` → `automas-script-hsr` +- `packages/automas_hsr_adapter_sra/pyproject.toml` → `automas-hsr-adapter-sra` +- `packages/automas_hsr_adapter_m7a/pyproject.toml` → `automas-hsr-adapter-m7a` +- `packages/automas_hsr/pyproject.toml` → `automas-hsr` + +Agent 修改版本时必须遵守: + +- 修改版本号等同于安排该版本在合入 `main` 后正式发布;动手前应明确告知维护者这一副作用。 +- PyPI 版本和发行文件不可覆盖。不得复用已经发布的版本号,也不得通过降版本来覆盖旧包。 +- 同一提交可以提升多个包的版本。工作流会只运行一次全仓测试和构建,再固定按 + `automas-script-hsr` → SRA adapter → M7A adapter → `automas-hsr` 的顺序发布。 +- 核心包版本变化时,同步检查两个 adapter 的核心依赖下限;发行契约变化时按需提升 + adapter 和聚合包版本。不要为了“保持一致”无条件提升没有变化的 distribution。 +- 自动发布全局串行,失败不会越过前序包继续发布;修复后重新运行原 workflow 即可。 + 上传步骤允许跳过同版本的既有文件,仅用于部分成功后的安全重跑,不代表允许复用版本。 +- `workflow_dispatch` 和包级 tag 仍作为人工恢复入口。手动发布的版本必须与对应 + `pyproject.toml` 完全一致;tag 必须指向 `main` 历史中的提交。 +- GitHub environment 与 PyPI Publisher 名称属于发布契约,不得随意改名: + `pypi-script-hsr`、`pypi-adapter-sra`、`pypi-adapter-m7a`、`pypi-hsr`。 diff --git a/docs/release.md b/docs/release.md index b81ce45..52b8fdd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -20,13 +20,13 @@ - `pypi-adapter-sra` - `pypi-adapter-m7a` -每个 environment 应配置 required reviewers,并限制部署来源:允许 `main` 用于首次 -手动发布,允许对应的包版本 tag 用于后续自动发布。仓库和 environment 都不保存 -PyPI 用户名、密码或长期 token。 +每个 environment 应限制部署来源:允许 `main` 用于版本变更自动发布和手动恢复, +允许对应的包版本 tag 用于 tag 恢复发布。若配置 required reviewers,自动发布会在 +上传前等待人工审批。仓库和 environment 都不保存 PyPI 用户名、密码或长期 token。 ## PyPI Trusted Publishing -在 PyPI 的 Publishing 页面为三个项目分别创建 pending publisher: +在 PyPI 的 Publishing 页面为四个项目分别创建 pending publisher: | PyPI 项目 | Owner | Repository | Workflow | Environment | | --- | --- | --- | --- | --- | @@ -60,9 +60,21 @@ environment 名必须与表格完全一致。 PyPI。随后,同一份已验证 artifact 才交给发布 job。手动发布若不在 `main`,或填写 版本与项目版本不一致,会在获取 PyPI OIDC 权限前失败。 -## 后续 tag 发布 +## 版本变更自动发布 -后续版本使用包级 tag,tag 中版本必须与对应 `pyproject.toml` 一致: +首次发布完成后,默认发布入口是合入 `main` 的版本号变化。工作流比较 push 前后的四个 +`pyproject.toml`;只有 `[project].version` 变化的 distribution 会进入发布计划。 +同一提交可以修改多个包,工作流会先统一测试、构建,再固定按核心包、SRA adapter、 +M7A adapter、聚合包的顺序上传。没有版本变化时不会上传。 + +PyPI 版本不可覆盖。必须先设置未使用的新版本号,再合入 `main`;不要用重新运行来替代 +升版本。工作流允许跳过已经存在的同版本文件,仅用于前面部分包已成功、后面包失败时 +安全重跑整条流程。 + +## 手动与 tag 恢复发布 + +`workflow_dispatch` 保留用于单包人工恢复。包级 tag 也会触发同一套验证和上传;tag 中 +版本必须与对应 `pyproject.toml` 一致: ```text automas-hsr-v0.1.1 diff --git a/scripts/release.py b/scripts/release.py index 2e3410a..12bd99e 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -2,7 +2,9 @@ import argparse import os +import subprocess import tomllib +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -27,6 +29,20 @@ ), } +RELEASE_ORDER = ( + "automas-script-hsr", + "automas-hsr-adapter-sra", + "automas-hsr-adapter-m7a", + "automas-hsr", +) + +PACKAGE_OUTPUT_KEYS = { + "automas-script-hsr": "script", + "automas-hsr-adapter-sra": "sra", + "automas-hsr-adapter-m7a": "m7a", + "automas-hsr": "meta", +} + @dataclass(frozen=True, slots=True) class ReleaseTarget: @@ -46,6 +62,50 @@ def project_version(package_dir: str) -> str: return str(data["project"]["version"]) +def project_version_at(package_dir: str, revision: str) -> str | None: + """Read a package version from a Git revision. + + A missing package path is treated as a newly added distribution. Invalid + revisions fail closed so an incomplete checkout cannot accidentally publish. + """ + + revision = revision.strip() + if not revision or set(revision) == {"0"}: + return None + + revision_check = subprocess.run( + ["git", "cat-file", "-e", f"{revision}^{{commit}}"], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + if revision_check.returncode != 0: + raise ValueError(f"cannot resolve previous release revision: {revision}") + + pyproject = f"{package_dir}/pyproject.toml" + file_check = subprocess.run( + ["git", "cat-file", "-e", f"{revision}:{pyproject}"], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + if file_check.returncode != 0: + return None + + content = subprocess.run( + ["git", "show", f"{revision}:{pyproject}"], + cwd=ROOT, + capture_output=True, + check=True, + text=True, + encoding="utf-8", + ).stdout + data = tomllib.loads(content) + return str(data["project"]["version"]) + + def _tag_target(ref: str) -> tuple[str, str]: prefix = "refs/tags/" if not ref.startswith(prefix): @@ -97,14 +157,73 @@ def resolve_release( ) -def _write_github_outputs(target: ReleaseTarget, output_path: Path) -> None: - values = { - "package": target.package, - "package_dir": target.package_dir, - "environment": target.environment, - "version": target.version, - "artifact_name": target.artifact_name, +def resolve_changed_releases( + previous_versions: Mapping[str, str | None], +) -> tuple[ReleaseTarget, ...]: + """Resolve changed package versions in dependency-safe publication order.""" + + targets: list[ReleaseTarget] = [] + for package in RELEASE_ORDER: + package_dir, environment = PACKAGE_CONFIG[package] + version = project_version(package_dir) + if previous_versions.get(package) == version: + continue + targets.append( + ReleaseTarget( + package=package, + package_dir=package_dir, + environment=environment, + version=version, + ) + ) + return tuple(targets) + + +def resolve_release_plan( + *, + event_name: str, + ref: str, + manual_package: str = "", + manual_version: str = "", + before_sha: str = "", +) -> tuple[ReleaseTarget, ...]: + if event_name == "workflow_dispatch" or ref.startswith("refs/tags/"): + return ( + resolve_release( + event_name=event_name, + ref=ref, + manual_package=manual_package, + manual_version=manual_version, + ), + ) + + if event_name != "push" or ref != "refs/heads/main": + raise ValueError(f"unsupported automatic release ref: {event_name} {ref}") + if not before_sha.strip(): + raise ValueError("automatic main release requires the previous commit SHA") + + previous_versions = { + package: project_version_at(package_dir, before_sha) + for package, (package_dir, _) in PACKAGE_CONFIG.items() } + return resolve_changed_releases(previous_versions) + + +def _write_github_outputs( + targets: tuple[ReleaseTarget, ...], output_path: Path +) -> None: + selected = {target.package: target for target in targets} + values: dict[str, str] = { + "has_targets": str(bool(targets)).lower(), + "packages": ",".join(target.package for target in targets), + } + for package in RELEASE_ORDER: + key = PACKAGE_OUTPUT_KEYS[package] + target = selected.get(package) + values[f"{key}_selected"] = str(target is not None).lower() + values[f"{key}_version"] = target.version if target else "" + values[f"{key}_artifact"] = target.artifact_name if target else "" + with output_path.open("a", encoding="utf-8") as output: for key, value in values.items(): output.write(f"{key}={value}\n") @@ -116,22 +235,27 @@ def main() -> None: parser.add_argument("--ref", required=True) parser.add_argument("--package", default="") parser.add_argument("--version", default="") + parser.add_argument("--before-sha", default="") args = parser.parse_args() - target = resolve_release( + targets = resolve_release_plan( event_name=args.event_name, ref=args.ref, manual_package=args.package, manual_version=args.version, + before_sha=args.before_sha, ) github_output = os.environ.get("GITHUB_OUTPUT") if github_output: - _write_github_outputs(target, Path(github_output)) + _write_github_outputs(targets, Path(github_output)) else: - print( - f"{target.package} {target.version} " - f"({target.package_dir}, {target.environment})" - ) + if not targets: + print("no package version changes detected") + for target in targets: + print( + f"{target.package} {target.version} " + f"({target.package_dir}, {target.environment})" + ) if __name__ == "__main__": diff --git a/tests/test_release.py b/tests/test_release.py index 8c45dd3..200b7e7 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,8 +1,17 @@ from __future__ import annotations import unittest +from pathlib import Path +from tempfile import TemporaryDirectory -from scripts.release import PACKAGE_CONFIG, project_version, resolve_release +from scripts.release import ( + PACKAGE_CONFIG, + RELEASE_ORDER, + _write_github_outputs, + project_version, + resolve_changed_releases, + resolve_release, +) class ReleaseTargetTests(unittest.TestCase): @@ -45,3 +54,41 @@ def test_unknown_tag_is_rejected(self) -> None: event_name="push", ref="refs/tags/v0.1.0", ) + + def test_unchanged_versions_do_not_publish(self) -> None: + previous = {package: self._version(package) for package in PACKAGE_CONFIG} + self.assertEqual(resolve_changed_releases(previous), ()) + + def test_changed_versions_follow_dependency_order(self) -> None: + previous = {package: "previous-version" for package in PACKAGE_CONFIG} + targets = resolve_changed_releases(previous) + self.assertEqual(tuple(target.package for target in targets), RELEASE_ORDER) + + def test_only_changed_distribution_is_selected(self) -> None: + previous = {package: self._version(package) for package in PACKAGE_CONFIG} + previous["automas-hsr-adapter-sra"] = "previous-version" + targets = resolve_changed_releases(previous) + self.assertEqual( + tuple(target.package for target in targets), + ("automas-hsr-adapter-sra",), + ) + + def test_github_outputs_mark_only_selected_packages(self) -> None: + previous = {package: self._version(package) for package in PACKAGE_CONFIG} + previous["automas-hsr-adapter-m7a"] = "previous-version" + targets = resolve_changed_releases(previous) + + with TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "github-output.txt" + _write_github_outputs(targets, output_path) + values = dict( + line.split("=", 1) + for line in output_path.read_text(encoding="utf-8").splitlines() + ) + + self.assertEqual(values["has_targets"], "true") + self.assertEqual(values["m7a_selected"], "true") + self.assertEqual(values["script_selected"], "false") + self.assertEqual(values["sra_selected"], "false") + self.assertEqual(values["meta_selected"], "false") + self.assertIn("automas-hsr-adapter-m7a", values["m7a_artifact"])