From 8aba74e01e7d74a0615d0ecf65811869965b92be Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 16:27:47 +0200 Subject: [PATCH] Make meta-package release retries tag-safe [skip ci] --- .../publish-developer-meta-package.yml | 151 ++++++++++++++++-- docs/PACKAGE_REGISTRY_RELEASES.md | 5 + tests/test_package_registry_release.py | 12 ++ 3 files changed, 157 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/publish-developer-meta-package.yml b/.gitea/workflows/publish-developer-meta-package.yml index ed3aac4..f79755e 100644 --- a/.gitea/workflows/publish-developer-meta-package.yml +++ b/.gitea/workflows/publish-developer-meta-package.yml @@ -4,12 +4,20 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + version: + description: Existing protected release version without leading v + required: true + type: string jobs: publish-package: runs-on: ubuntu-latest env: GITEA_REPOSITORY: ${{ gitea.repository }} + TRIGGER_TAG: ${{ gitea.ref_name }} + REQUESTED_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -25,14 +33,134 @@ jobs: import subprocess import tomllib - tag = os.environ["GITEA_REF_NAME"] - project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"] + requested_version = os.environ.get("REQUESTED_VERSION", "").strip() + tag = f"v{requested_version}" if requested_version else os.environ["TRIGGER_TAG"] + if not tag.startswith("v") or not tag[1:]: + raise SystemExit("release tag is missing") + project_text = subprocess.check_output( + ["git", "show", f"{tag}:packages/govoplan-meta/pyproject.toml"], + text=True, + ) + project = tomllib.loads(project_text)["project"] if tag != f"v{project['version']}": raise SystemExit("meta-package version does not match the release tag") - if subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"]).returncode: + tag_commit = subprocess.check_output( + ["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], text=True + ).strip() + if subprocess.run( + ["git", "merge-base", "--is-ancestor", tag_commit, "origin/main"] + ).returncode: raise SystemExit("release tag is not contained in main") + if not requested_version: + head_commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip() + if head_commit != tag_commit: + raise SystemExit("tag-triggered checkout does not match the release tag") + with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"RELEASE_TAG={tag}\n") + subprocess.run(["git", "checkout", "--detach", tag_commit], check=True) PY - - name: Build and publish developer package + - name: Build developer package + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 + python -m build --wheel --outdir dist packages/govoplan-meta + python -m twine check dist/*.whl + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + + wheels = tuple(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("meta release must contain exactly one wheel") + wheel = wheels[0] + evidence = { + "schema_version": "1", + "repository": os.environ["GITEA_REPOSITORY"], + "tag": os.environ["RELEASE_TAG"], + "commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip(), + "artifacts": [ + { + "filename": wheel.name, + "sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(), + "size": wheel.stat().st_size, + } + ], + } + Path("dist/package-artifacts.json").write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + PY + - name: Retain package hash evidence + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 + with: + name: developer-meta-package + path: dist/package-artifacts.json + - name: Check immutable registry state + env: + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_TOKEN" + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import tomllib + from urllib.error import HTTPError + from urllib.parse import quote + from urllib.request import Request, urlopen + + project = tomllib.loads( + Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8") + )["project"] + wheels = tuple(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("meta release must contain exactly one wheel") + wheel = wheels[0] + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + package_url = "/".join( + ( + "https://git.add-ideas.de/api/v1/packages/GovOPlaN", + "pypi", + quote(str(project["name"]), safe=""), + quote(str(project["version"]), safe=""), + "files", + ) + ) + request = Request( + package_url, + headers={ + "Accept": "application/json", + "Authorization": f"token {os.environ['PACKAGE_TOKEN']}", + }, + ) + publish = True + try: + with urlopen(request, timeout=30) as response: + files = json.load(response) + except HTTPError as exc: + if exc.code != 404: + raise + else: + if not isinstance(files, list) or len(files) != 1: + raise SystemExit("immutable meta-package has an unexpected file set") + if files[0].get("sha256") != digest: + raise SystemExit( + "immutable meta-package already exists with a different SHA-256" + ) + publish = False + with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISH_PYPI={int(publish)}\n") + PY + - name: Publish developer package env: PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} @@ -40,10 +168,11 @@ jobs: set -euo pipefail test -n "$PACKAGE_USERNAME" test -n "$PACKAGE_TOKEN" - python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 - python -m build --wheel --outdir dist packages/govoplan-meta - python -m twine check dist/*.whl - TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ - python -m twine upload --non-interactive \ - --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ - dist/*.whl + if [[ "$PUBLISH_PYPI" == 1 ]]; then + TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ + python -m twine upload --non-interactive \ + --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ + dist/*.whl + else + echo "Exact developer meta-package is already present; skipping immutable retry." + fi diff --git a/docs/PACKAGE_REGISTRY_RELEASES.md b/docs/PACKAGE_REGISTRY_RELEASES.md index b13dbc1..e2d4248 100644 --- a/docs/PACKAGE_REGISTRY_RELEASES.md +++ b/docs/PACKAGE_REGISTRY_RELEASES.md @@ -179,6 +179,11 @@ tags. The meta-package is for editable/developer setup and composition tests. It does not enable modules, apply migrations, provision services, or establish backup and recovery evidence. +If the tag-triggered developer meta-package job fails before publication, rerun +`publish-developer-meta-package.yml` with the existing protected version. The +manual path validates that tag against `main`, checks out its exact commit, and +publishes only when the registry does not already contain the same wheel hash. + Generic Packages are intentionally not used. Add that transport only when a consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or the OCI registry. diff --git a/tests/test_package_registry_release.py b/tests/test_package_registry_release.py index 2e80f10..3fd7f7b 100644 --- a/tests/test_package_registry_release.py +++ b/tests/test_package_registry_release.py @@ -162,6 +162,18 @@ class PackageRegistryReleaseTests(unittest.TestCase): ) self.assertEqual(expected, actual) + def test_meta_package_workflow_supports_hash_safe_tag_retry(self) -> None: + workflow = ( + ROOT / ".gitea/workflows/publish-developer-meta-package.yml" + ).read_text(encoding="utf-8") + + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("TRIGGER_TAG: ${{ gitea.ref_name }}", workflow) + self.assertNotIn("GITEA_REF_NAME", workflow) + self.assertIn('refs/tags/{tag}^{{commit}}', workflow) + self.assertIn("already exists with a different SHA-256", workflow) + self.assertIn("PUBLISH_PYPI", workflow) + if __name__ == "__main__": unittest.main()