Make meta-package release retries tag-safe [skip ci]

This commit is contained in:
2026-08-04 16:27:47 +02:00
parent a24c94435e
commit 8aba74e01e
3 changed files with 157 additions and 11 deletions
@@ -4,12 +4,20 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
inputs:
version:
description: Existing protected release version without leading v
required: true
type: string
jobs: jobs:
publish-package: publish-package:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
TRIGGER_TAG: ${{ gitea.ref_name }}
REQUESTED_VERSION: ${{ inputs.version }}
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
@@ -25,14 +33,134 @@ jobs:
import subprocess import subprocess
import tomllib import tomllib
tag = os.environ["GITEA_REF_NAME"] requested_version = os.environ.get("REQUESTED_VERSION", "").strip()
project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"] 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']}": if tag != f"v{project['version']}":
raise SystemExit("meta-package version does not match the release tag") 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") 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 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: env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
@@ -40,10 +168,11 @@ jobs:
set -euo pipefail set -euo pipefail
test -n "$PACKAGE_USERNAME" test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN" test -n "$PACKAGE_TOKEN"
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 if [[ "$PUBLISH_PYPI" == 1 ]]; then
python -m build --wheel --outdir dist packages/govoplan-meta
python -m twine check dist/*.whl
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \ python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl dist/*.whl
else
echo "Exact developer meta-package is already present; skipping immutable retry."
fi
+5
View File
@@ -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 does not enable modules, apply migrations, provision services, or establish
backup and recovery evidence. 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 Generic Packages are intentionally not used. Add that transport only when a
consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or
the OCI registry. the OCI registry.
+12
View File
@@ -162,6 +162,18 @@ class PackageRegistryReleaseTests(unittest.TestCase):
) )
self.assertEqual(expected, actual) 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__": if __name__ == "__main__":
unittest.main() unittest.main()