Fix read-only Web runtime publication

This commit is contained in:
2026-08-03 18:56:23 +02:00
parent 3b3d5b3386
commit cb45251c59
4 changed files with 160 additions and 15 deletions
@@ -320,10 +320,12 @@ jobs:
working-directory: govoplan working-directory: govoplan
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
SOURCE_COMMIT: ${{ gitea.sha }}
GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }} GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }}
run: | run: |
python tools/release/publish-runtime-release.py \ python tools/release/publish-runtime-release.py \
--tag "v$VERSION" \ --tag "v$VERSION" \
--target-commit "$SOURCE_COMMIT" \
--title "GovOPlaN v$VERSION runtime distribution" \ --title "GovOPlaN v$VERSION runtime distribution" \
--asset runtime-output/govoplan-deploy.pyz \ --asset runtime-output/govoplan-deploy.pyz \
--asset runtime-output/govoplan-deploy.pyz.sig \ --asset runtime-output/govoplan-deploy.pyz.sig \
+93
View File
@@ -9,6 +9,8 @@ import shutil
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
from urllib.error import HTTPError
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -32,6 +34,10 @@ DEPLOYER_BUILD = _load(
"build_deployer_zipapp", "build_deployer_zipapp",
ROOT / "tools/deployment/build-deployer-zipapp.py", ROOT / "tools/deployment/build-deployer-zipapp.py",
) )
PUBLISH = _load(
"publish_runtime_release",
ROOT / "tools/release/publish-runtime-release.py",
)
class RuntimeDistributionBuildTests(unittest.TestCase): class RuntimeDistributionBuildTests(unittest.TestCase):
@@ -99,6 +105,20 @@ class RuntimeDistributionBuildTests(unittest.TestCase):
dockerfile, dockerfile,
) )
def test_web_runtime_uses_only_writable_tmpfs_for_nginx_temp_files(self) -> None:
nginx = (ROOT / "tools/release/runtime/nginx.conf").read_text(
encoding="utf-8"
)
for temporary_path in (
"client_body_temp_path /tmp/client_temp;",
"fastcgi_temp_path /tmp/fastcgi_temp;",
"proxy_temp_path /tmp/proxy_temp;",
"scgi_temp_path /tmp/scgi_temp;",
"uwsgi_temp_path /tmp/uwsgi_temp;",
):
self.assertIn(temporary_path, nginx)
def test_workflow_verifies_portable_bootstrap_artifacts_before_execution( def test_workflow_verifies_portable_bootstrap_artifacts_before_execution(
self, self,
) -> None: ) -> None:
@@ -128,6 +148,79 @@ class RuntimeDistributionBuildTests(unittest.TestCase):
self.assertIn("runtime-smoke-amd64.json", workflow) self.assertIn("runtime-smoke-amd64.json", workflow)
self.assertIn("runtime-smoke-arm64.json", workflow) self.assertIn("runtime-smoke-arm64.json", workflow)
def test_workflow_binds_the_release_tag_to_the_workflow_commit(self) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
publisher = (ROOT / "tools/release/publish-runtime-release.py").read_text(
encoding="utf-8"
)
self.assertIn("SOURCE_COMMIT: ${{ gitea.sha }}", workflow)
self.assertIn('--target-commit "$SOURCE_COMMIT"', workflow)
self.assertIn('"target_commitish": target_commit', publisher)
self.assertIn("self._resolve_commit(tag) != target_commit", publisher)
def test_runtime_publisher_rejects_a_tag_on_another_commit(self) -> None:
publisher = PUBLISH.GiteaReleasePublisher(
base_url="https://git.example.test",
owner="GovOPlaN",
repo="govoplan",
token="secret",
)
target = "1" * 40
with (
patch.object(
publisher,
"_resolve_commit",
side_effect=(target, "2" * 40),
),
self.assertRaisesRegex(PUBLISH.PublishError, "another commit"),
):
publisher.release(
tag="v1.2.3",
target_commit=target,
title="Release",
body="Body",
)
def test_runtime_publisher_creates_the_tag_at_the_exact_commit(self) -> None:
publisher = PUBLISH.GiteaReleasePublisher(
base_url="https://git.example.test",
owner="GovOPlaN",
repo="govoplan",
token="secret",
)
target = "1" * 40
requests: list[tuple[str, dict[str, object] | None]] = []
def request(method: str, _url: str, **kwargs):
payload = kwargs.get("payload")
requests.append((method, payload))
if method == "GET":
raise HTTPError(_url, 404, "not found", {}, None)
return {"id": 1}
with (
patch.object(
publisher,
"_resolve_commit",
side_effect=(target, None, target),
),
patch.object(publisher, "_json", side_effect=request),
):
release = publisher.release(
tag="v1.2.3",
target_commit=target,
title="Release",
body="Body",
)
self.assertEqual({"id": 1}, release)
self.assertEqual("POST", requests[-1][0])
assert requests[-1][1] is not None
self.assertEqual(target, requests[-1][1]["target_commitish"])
def test_resolves_platforms_and_builds_evidence_descriptor(self) -> None: def test_resolves_platforms_and_builds_evidence_descriptor(self) -> None:
index = { index = {
"schemaVersion": 2, "schemaVersion": 2,
+62 -15
View File
@@ -9,6 +9,7 @@ import json
import mimetypes import mimetypes
import os import os
from pathlib import Path from pathlib import Path
import re
import secrets import secrets
import sys import sys
from typing import Any from typing import Any
@@ -18,6 +19,7 @@ from urllib.request import Request, urlopen
MAX_ASSET_BYTES = 256 * 1024 * 1024 MAX_ASSET_BYTES = 256 * 1024 * 1024
COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$")
class PublishError(RuntimeError): class PublishError(RuntimeError):
@@ -30,6 +32,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--owner", default="GovOPlaN") parser.add_argument("--owner", default="GovOPlaN")
parser.add_argument("--repo", default="govoplan") parser.add_argument("--repo", default="govoplan")
parser.add_argument("--tag", required=True) parser.add_argument("--tag", required=True)
parser.add_argument("--target-commit", required=True)
parser.add_argument("--title", required=True) parser.add_argument("--title", required=True)
parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.") parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.")
parser.add_argument("--asset", type=Path, action="append", default=[], required=True) parser.add_argument("--asset", type=Path, action="append", default=[], required=True)
@@ -48,25 +51,49 @@ class GiteaReleasePublisher:
self.repo = repo self.repo = repo
self.token = token self.token = token
def release(self, *, tag: str, title: str, body: str) -> dict[str, Any]: def release(
self,
*,
tag: str,
target_commit: str,
title: str,
body: str,
) -> dict[str, Any]:
if COMMIT_SHA.fullmatch(target_commit) is None:
raise PublishError("release target must be an exact lowercase commit SHA")
resolved_target = self._resolve_commit(target_commit)
if resolved_target != target_commit:
raise PublishError("release target did not resolve to the requested commit")
existing_tag = self._resolve_commit(tag, allow_missing=True)
if existing_tag is not None and existing_tag != target_commit:
raise PublishError(
f"release tag {tag!r} already points to another commit"
)
path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}") path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}")
try: try:
return self._json("GET", path) release = self._json("GET", path)
except HTTPError as exc: except HTTPError as exc:
if exc.code != 404: if exc.code != 404:
raise raise
return self._json( release = self._json(
"POST", "POST",
self._repo_path("/releases"), self._repo_path("/releases"),
payload={ payload={
"tag_name": tag, "tag_name": tag,
"name": title, "target_commitish": target_commit,
"body": body, "name": title,
"draft": False, "body": body,
"prerelease": False, "draft": False,
}, "prerelease": False,
expected=201, },
) expected=201,
)
if self._resolve_commit(tag) != target_commit:
raise PublishError(
f"release tag {tag!r} does not resolve to the requested commit"
)
return release
def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None: def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None:
release_id = release.get("id") release_id = release.get("id")
@@ -165,6 +192,21 @@ class GiteaReleasePublisher:
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self.token}", "Accept": "application/json"} return {"Authorization": f"token {self.token}", "Accept": "application/json"}
def _resolve_commit(self, ref: str, *, allow_missing: bool = False) -> str | None:
path = self._repo_path(f"/git/commits/{quote(ref, safe='')}")
try:
commit = self._json("GET", path)
except HTTPError as exc:
if allow_missing and exc.code == 404:
return None
raise
if not isinstance(commit, dict):
raise PublishError(f"Gitea returned an invalid commit for {ref!r}")
sha = commit.get("sha")
if not isinstance(sha, str) or COMMIT_SHA.fullmatch(sha) is None:
raise PublishError(f"Gitea returned an invalid commit SHA for {ref!r}")
return sha
def _repo_path(self, suffix: str) -> str: def _repo_path(self, suffix: str) -> str:
return ( return (
f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/" f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/"
@@ -189,7 +231,12 @@ def main() -> int:
repo=args.repo, repo=args.repo,
token=os.environ.get(args.token_env, ""), token=os.environ.get(args.token_env, ""),
) )
release = publisher.release(tag=args.tag, title=args.title, body=args.body) release = publisher.release(
tag=args.tag,
target_commit=args.target_commit,
title=args.title,
body=args.body,
)
publisher.upload_assets(release, tuple(args.asset)) publisher.upload_assets(release, tuple(args.asset))
except (HTTPError, OSError, PublishError, ValueError) as exc: except (HTTPError, OSError, PublishError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
+3
View File
@@ -13,7 +13,10 @@ http {
sendfile on; sendfile on;
server_tokens off; server_tokens off;
client_body_temp_path /tmp/client_temp; client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
proxy_temp_path /tmp/proxy_temp; proxy_temp_path /tmp/proxy_temp;
scgi_temp_path /tmp/scgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
map $http_x_forwarded_proto $govoplan_forwarded_proto { map $http_x_forwarded_proto $govoplan_forwarded_proto {
default $scheme; default $scheme;