Make meta-package release retries tag-safe [skip ci]
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user