Harden package release workflows for Gitea

This commit is contained in:
2026-08-04 14:00:32 +02:00
parent 629bfec1f1
commit e36a6573bf
4 changed files with 39 additions and 38 deletions
@@ -8,6 +8,8 @@ on:
jobs: jobs:
publish-package: publish-package:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
@@ -16,17 +18,12 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Validate protected release tag and package version - name: Validate protected release tag and package version
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
python - <<'PY' python - <<'PY'
import fnmatch
import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tomllib import tomllib
import urllib.request
tag = os.environ["GITEA_REF_NAME"] tag = os.environ["GITEA_REF_NAME"]
project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"] project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"]
@@ -34,14 +31,6 @@ jobs:
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: if subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"]).returncode:
raise SystemExit("release tag is not contained in main") raise SystemExit("release tag is not contained in main")
request = urllib.request.Request(
f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/tag_protections",
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
protections = json.load(response)
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
raise SystemExit("release tag is not protected")
PY PY
- name: Build and publish developer package - name: Build and publish developer package
env: env:
+21 -2
View File
@@ -18,24 +18,43 @@ python tools/repo/sync-module-package-workflows.py --check
``` ```
The workflow runs for `v*` tags and may be dispatched manually for an existing The workflow runs for `v*` tags and may be dispatched manually for an existing
tag. Before building, it verifies that: tag. The organization preflight verifies that every package repository protects
the `v*` namespace. Before building, the workflow itself verifies that:
- the selected tag is covered by repository tag protection;
- the tagged commit is contained in `main`; - the tagged commit is contained in `main`;
- the tag, Python project version, and optional WebUI package version agree; - the tag, Python project version, and optional WebUI package version agree;
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces. - package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
The workflow binds the repository explicitly from the Gitea Actions context.
Do not rely on GitHub-compatible environment variables being injected by the
runner image; Gitea runners may expose only the context values. Gitea 1.24 job
tokens cannot read repository tag-protection settings, so package jobs must not
receive a broad administrator token merely to repeat the organization preflight.
Run the following before the first publication and after repository or tag-rule
changes:
```bash
python tools/gitea/gitea-configure-package-releases.py
```
It builds one wheel and, where applicable, one npm tarball. The workflow records It builds one wheel and, where applicable, one npm tarball. The workflow records
the source tag, source commit, filename, size, and SHA-256 in the source tag, source commit, filename, size, and SHA-256 in
`package-artifacts.json` before publishing. Gitea rejects a second upload of the `package-artifacts.json` before publishing. Gitea rejects a second upload of the
same package version, so correction requires a new version rather than artifact same package version, so correction requires a new version rather than artifact
replacement. replacement.
The npm tarball is always published through an explicit local `./dist/...`
path. Without that prefix, npm may interpret a relative tarball name as a Git
package shorthand before it ever contacts the configured registry.
Published WebUI packages contain registry-compatible dependencies only. The Published WebUI packages contain registry-compatible dependencies only. The
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
dependencies. Repository development metadata may therefore keep local or Git dependencies. Repository development metadata may therefore keep local or Git
references without leaking them into the published package contract. references without leaking them into the published package contract.
Historical `add-ideas` and current `GovOPlaN` organization URLs are accepted
for immutable tagged releases; both normalize to the same exact registry
dependency and no branch or unversioned Git reference is accepted.
## One-time Gitea setup ## One-time Gitea setup
+12 -2
View File
@@ -17,15 +17,18 @@ class ModulePackageWorkflowTests(unittest.TestCase):
META_ROOT / "tools/repo/templates/module-package-release.yml" META_ROOT / "tools/repo/templates/module-package-release.yml"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertIn("tag_protections", workflow) self.assertIn("GITEA_REPOSITORY: ${{ gitea.repository }}", workflow)
self.assertNotIn("tag_protections", workflow)
self.assertNotIn("secrets.GITEA_TOKEN", workflow)
self.assertIn("git merge-base --is-ancestor", workflow) self.assertIn("git merge-base --is-ancestor", workflow)
self.assertIn("does not match", workflow) self.assertIn("does not match", workflow)
self.assertIn("package-artifacts.json", workflow) self.assertIn("package-artifacts.json", workflow)
self.assertIn("api/packages/GovOPlaN/pypi", workflow) self.assertIn("api/packages/GovOPlaN/pypi", workflow)
self.assertIn("api/packages/GovOPlaN/npm", workflow) self.assertIn("api/packages/GovOPlaN/npm", workflow)
self.assertIn('npm publish "./${webui_packages[0]}"', workflow)
self.assertIn("GOVOPLAN_PACKAGE_TOKEN", workflow) self.assertIn("GOVOPLAN_PACKAGE_TOKEN", workflow)
self.assertIn("must resolve to an exact registry version", workflow) self.assertIn("must resolve to an exact registry version", workflow)
self.assertIn("git\\\\.add-ideas\\\\.de/GovOPlaN", workflow) self.assertIn("git\\\\.add-ideas\\\\.de/(?:GovOPlaN|add-ideas)", workflow)
self.assertIn("release package identity does not match", workflow) self.assertIn("release package identity does not match", workflow)
self.assertNotIn("Generic", workflow) self.assertNotIn("Generic", workflow)
@@ -52,6 +55,10 @@ class ModulePackageWorkflowTests(unittest.TestCase):
"@govoplan/access-webui": ( "@govoplan/access-webui": (
"git+ssh://git@git.add-ideas.de/GovOPlaN/" "git+ssh://git@git.add-ideas.de/GovOPlaN/"
"govoplan-access.git#v0.1.11" "govoplan-access.git#v0.1.11"
),
"@govoplan/admin-webui": (
"git+ssh://git@git.add-ideas.de/add-ideas/"
"govoplan-admin.git#v0.1.8"
) )
}, },
} }
@@ -73,6 +80,9 @@ class ModulePackageWorkflowTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
"0.1.11", package["dependencies"]["@govoplan/access-webui"] "0.1.11", package["dependencies"]["@govoplan/access-webui"]
) )
self.assertEqual(
"0.1.8", package["dependencies"]["@govoplan/admin-webui"]
)
def test_sync_script_only_targets_packageable_govoplan_repositories(self) -> None: def test_sync_script_only_targets_packageable_govoplan_repositories(self) -> None:
namespace: dict[str, object] = { namespace: dict[str, object] = {
@@ -14,6 +14,8 @@ on:
jobs: jobs:
publish-packages: publish-packages:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
@@ -29,7 +31,6 @@ jobs:
env: env:
REQUESTED_TAG: ${{ inputs.release_tag }} REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }} TRIGGER_TAG: ${{ gitea.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}" tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
@@ -43,24 +44,6 @@ jobs:
echo "Release tag is not contained in main" >&2 echo "Release tag is not contained in main" >&2
exit 1 exit 1
} }
python - "$tag" <<'PY'
import fnmatch
import json
import os
import sys
import urllib.request
tag = sys.argv[1]
repository = os.environ["GITEA_REPOSITORY"]
request = urllib.request.Request(
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
protections = json.load(response)
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
PY
git checkout --detach "$tag" git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV" printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV" printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
@@ -130,7 +113,7 @@ jobs:
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match( const gitTag = specifier.match(
new RegExp( new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`, `^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
), ),
); );
if (gitTag) { if (gitTag) {
@@ -203,7 +186,7 @@ jobs:
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \ '@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \ "//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc" > "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \ NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \ --ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/ --registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
fi fi