name: Module Package Release on: push: tags: - "v*" workflow_dispatch: inputs: release_tag: description: Existing protected version tag to publish required: true type: string jobs: publish-packages: runs-on: ubuntu-latest env: GITEA_REPOSITORY: ${{ gitea.repository }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: "3.12" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - name: Select and validate protected release tag shell: bash env: REQUESTED_TAG: ${{ inputs.release_tag }} TRIGGER_TAG: ${{ gitea.ref_name }} run: | set -euo pipefail tag="${REQUESTED_TAG:-$TRIGGER_TAG}" case "$tag" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;; esac git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main tag_commit="$(git rev-list -n 1 "$tag")" git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || { echo "Release tag is not contained in main" >&2 exit 1 } git checkout --detach "$tag" printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV" printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV" - name: Validate package versions run: | python - <<'PY' import json from pathlib import Path import os import re import tomllib tag = os.environ["RELEASE_TAG"] expected = tag.removeprefix("v") project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] if project.get("version") != expected: raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}") if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None: raise SystemExit("Python distribution name must use the govoplan-* namespace") webui = Path("webui/package.json") if webui.is_file(): package = json.loads(webui.read_text(encoding="utf-8")) if package.get("version") != expected: raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}") if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None: raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace") release = Path("webui/package.release.json") if release.is_file(): release_package = json.loads(release.read_text(encoding="utf-8")) if ( release_package.get("name") != package.get("name") or release_package.get("version") != expected ): raise SystemExit("WebUI release package identity does not match package.json and the release tag") PY - name: Build immutable package artifacts shell: bash run: | set -euo pipefail python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 rm -rf dist .package-webui python -m build --wheel --outdir dist python -m twine check dist/*.whl if [[ -f webui/package.json ]]; then mkdir .package-webui cp -a webui/. .package-webui/ rm -rf .package-webui/node_modules .package-webui/dist if [[ -f .package-webui/package.release.json ]]; then cp .package-webui/package.release.json .package-webui/package.json fi node <<'NODE' const fs = require("node:fs"); const path = ".package-webui/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); const groups = ["dependencies", "optionalDependencies", "peerDependencies"]; for (const group of groups) { for (const [name, specifier] of Object.entries(packageJson[group] || {})) { if (!name.startsWith("@govoplan/")) continue; if (typeof specifier !== "string") { throw new Error(`${group}.${name} must use a string version`); } const packageSlug = name.slice("@govoplan/".length); if (!packageSlug.endsWith("-webui")) { throw new Error(`${group}.${name} is outside the WebUI package namespace`); } const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`; const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const gitTag = specifier.match( new RegExp( `^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`, ), ); if (gitTag) { packageJson[group][name] = gitTag[1]; continue; } if (specifier.startsWith("file:") || specifier.startsWith("git+")) { throw new Error( `${group}.${name} must resolve to an exact registry version for publication`, ); } } } delete packageJson.private; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`); NODE npm pkg delete private --prefix .package-webui (cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist) fi python - <<'PY' import hashlib import json from pathlib import Path import os import subprocess artifacts = [] for path in sorted(Path("dist").iterdir()): if path.suffix not in {".whl", ".tgz"}: continue digest = hashlib.sha256(path.read_bytes()).hexdigest() artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size}) payload = { "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": artifacts, } Path("dist/package-artifacts.json").write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) PY - name: Retain package hash evidence uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 with: name: module-packages-${{ gitea.ref_name }} path: dist/package-artifacts.json - name: Check immutable registry state shell: bash 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 api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN" token = os.environ["PACKAGE_TOKEN"] def should_publish(kind, name, version, path): package_url = "/".join( (api_root, kind, quote(name, safe=""), quote(version, safe=""), "files") ) request = Request( package_url, headers={"Accept": "application/json", "Authorization": f"token {token}"}, ) try: with urlopen(request, timeout=30) as response: files = json.load(response) except HTTPError as exc: if exc.code == 404: print(f"{kind} package {name}=={version} is not published yet") return True raise if not isinstance(files, list) or len(files) != 1: raise SystemExit( f"immutable {kind} package {name}=={version} has an unexpected file set" ) expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() if files[0].get("sha256") != expected_sha256: raise SystemExit( f"immutable {kind} package {name}=={version} already exists with a different SHA-256" ) print(f"verified existing {kind} package {name}=={version} ({expected_sha256})") return False project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] wheels = tuple(Path("dist").glob("*.whl")) if len(wheels) != 1: raise SystemExit("release build must contain exactly one wheel") publish_pypi = should_publish( "pypi", str(project["name"]), str(project["version"]), wheels[0] ) tarballs = tuple(Path("dist").glob("*.tgz")) if len(tarballs) > 1: raise SystemExit("release build must contain at most one npm package") publish_npm = False if tarballs: webui = json.loads( Path(".package-webui/package.json").read_text(encoding="utf-8") ) publish_npm = should_publish( "npm", str(webui["name"]), str(webui["version"]), tarballs[0] ) with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n") env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n") PY - name: Publish wheel and WebUI package shell: bash env: PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} run: | set -euo pipefail test -n "$PACKAGE_USERNAME" test -n "$PACKAGE_TOKEN" 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 wheel is already present; skipping immutable retry." fi shopt -s nullglob webui_packages=(dist/*.tgz) if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then npmrc="$(mktemp)" trap 'rm -f "$npmrc"' EXIT chmod 600 "$npmrc" printf '%s\n' \ '@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \ "//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \ > "$npmrc" NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \ --ignore-scripts --access public \ --registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/ elif (( ${#webui_packages[@]} )); then echo "Exact WebUI package is already present; skipping immutable retry." fi