Harden package release workflows for Gitea
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user