Fix read-only Web runtime publication
This commit is contained in:
@@ -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 \
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,18 +51,37 @@ 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,
|
||||||
|
"target_commitish": target_commit,
|
||||||
"name": title,
|
"name": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"draft": False,
|
"draft": False,
|
||||||
@@ -67,6 +89,11 @@ class GiteaReleasePublisher:
|
|||||||
},
|
},
|
||||||
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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user