diff --git a/.gitea/workflows/runtime-distribution.yml b/.gitea/workflows/runtime-distribution.yml index 7c9ac78..3e14fe6 100644 --- a/.gitea/workflows/runtime-distribution.yml +++ b/.gitea/workflows/runtime-distribution.yml @@ -320,10 +320,12 @@ jobs: working-directory: govoplan env: VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ gitea.sha }} GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }} run: | python tools/release/publish-runtime-release.py \ --tag "v$VERSION" \ + --target-commit "$SOURCE_COMMIT" \ --title "GovOPlaN v$VERSION runtime distribution" \ --asset runtime-output/govoplan-deploy.pyz \ --asset runtime-output/govoplan-deploy.pyz.sig \ diff --git a/tests/test_runtime_distribution_build.py b/tests/test_runtime_distribution_build.py index 8003d66..083f264 100644 --- a/tests/test_runtime_distribution_build.py +++ b/tests/test_runtime_distribution_build.py @@ -9,6 +9,8 @@ import shutil import sys import tempfile import unittest +from unittest.mock import patch +from urllib.error import HTTPError ROOT = Path(__file__).resolve().parents[1] @@ -32,6 +34,10 @@ DEPLOYER_BUILD = _load( "build_deployer_zipapp", ROOT / "tools/deployment/build-deployer-zipapp.py", ) +PUBLISH = _load( + "publish_runtime_release", + ROOT / "tools/release/publish-runtime-release.py", +) class RuntimeDistributionBuildTests(unittest.TestCase): @@ -99,6 +105,20 @@ class RuntimeDistributionBuildTests(unittest.TestCase): 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( self, ) -> None: @@ -128,6 +148,79 @@ class RuntimeDistributionBuildTests(unittest.TestCase): self.assertIn("runtime-smoke-amd64.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: index = { "schemaVersion": 2, diff --git a/tools/release/publish-runtime-release.py b/tools/release/publish-runtime-release.py index 1ea099a..36e1179 100644 --- a/tools/release/publish-runtime-release.py +++ b/tools/release/publish-runtime-release.py @@ -9,6 +9,7 @@ import json import mimetypes import os from pathlib import Path +import re import secrets import sys from typing import Any @@ -18,6 +19,7 @@ from urllib.request import Request, urlopen MAX_ASSET_BYTES = 256 * 1024 * 1024 +COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$") class PublishError(RuntimeError): @@ -30,6 +32,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--owner", default="GovOPlaN") parser.add_argument("--repo", default="govoplan") parser.add_argument("--tag", required=True) + parser.add_argument("--target-commit", required=True) parser.add_argument("--title", required=True) parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.") parser.add_argument("--asset", type=Path, action="append", default=[], required=True) @@ -48,25 +51,49 @@ class GiteaReleasePublisher: self.repo = repo 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='')}") try: - return self._json("GET", path) + release = self._json("GET", path) except HTTPError as exc: if exc.code != 404: raise - return self._json( - "POST", - self._repo_path("/releases"), - payload={ - "tag_name": tag, - "name": title, - "body": body, - "draft": False, - "prerelease": False, - }, - expected=201, - ) + release = self._json( + "POST", + self._repo_path("/releases"), + payload={ + "tag_name": tag, + "target_commitish": target_commit, + "name": title, + "body": body, + "draft": False, + "prerelease": False, + }, + 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: release_id = release.get("id") @@ -165,6 +192,21 @@ class GiteaReleasePublisher: def _headers(self) -> dict[str, str]: 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: return ( f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/" @@ -189,7 +231,12 @@ def main() -> int: repo=args.repo, 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)) except (HTTPError, OSError, PublishError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/tools/release/runtime/nginx.conf b/tools/release/runtime/nginx.conf index 2ddf20f..36d9668 100644 --- a/tools/release/runtime/nginx.conf +++ b/tools/release/runtime/nginx.conf @@ -13,7 +13,10 @@ http { sendfile on; server_tokens off; 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; map $http_x_forwarded_proto $govoplan_forwarded_proto { default $scheme;