ci: separate manual release publishing
This commit is contained in:
+42
-17
@@ -11,6 +11,7 @@ set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
workflow="$root/.gitea/workflows/ci.yml"
|
||||
publish_workflow="$root/.gitea/workflows/publish-release.yml"
|
||||
failures=0
|
||||
check() {
|
||||
if eval "$1"; then
|
||||
@@ -44,30 +45,54 @@ check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$r
|
||||
check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
|
||||
"the fetch verifies that the pinned ref resolves to the pinned commit"
|
||||
|
||||
# The package job is the only place that builds the canonical archive. Both integration and release
|
||||
# receive the same Gitea Actions v3 artifact, and release cannot run until every required job passed.
|
||||
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
|
||||
# Gitea Actions v3 artifact. It must contain no release trigger, permission, or mutation path.
|
||||
package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||
release_job="$(awk '/^ release:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||
check 'grep -q "tags:" "$workflow" && grep -q '\"v\*\"' "$workflow"' \
|
||||
"v tags start the gated workflow"
|
||||
check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \
|
||||
"normal CI still runs for pull requests and main pushes"
|
||||
check '! grep -q "workflow_dispatch:" "$workflow" && ! grep -q "tags:" "$workflow"' \
|
||||
"normal CI has no manual-publish or tag trigger"
|
||||
check '! grep -q "contents: write" "$workflow" && ! grep -q "github.token" "$workflow"' \
|
||||
"normal CI has no release-specific permission or token"
|
||||
check '! grep -q "release-assets.py publish" "$workflow" && ! grep -q "ekklesia-corresponding-source" "$workflow"' \
|
||||
"normal CI cannot publish or prepare release assets"
|
||||
check 'grep -q "bash ci/package.sh" <<<"$package_job" && grep -q "actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de" <<<"$package_job"' \
|
||||
"the package job builds and transfers one canonical artifact"
|
||||
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$job"' \
|
||||
"integration downloads that artifact and never rebuilds it"
|
||||
check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \
|
||||
"integration binds the downloaded bytes to the package job's digest"
|
||||
check 'for required in lint typecheck test build package integration; do grep -q " - $required" <<<"$release_job" || exit 1; done' \
|
||||
"release waits for every required job"
|
||||
check 'grep -q "if: startsWith(github.ref, '\''refs/tags/v'\'')" <<<"$release_job" && grep -q "contents: write" <<<"$release_job"' \
|
||||
"only a v tag gives the release job write permission"
|
||||
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$release_job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$release_job"' \
|
||||
"release downloads the tested artifact and never rebuilds it"
|
||||
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*needs.package.outputs.sha256" <<<"$release_job"' \
|
||||
"release binds its selected and uploaded bytes to the package job's digest"
|
||||
check 'grep -q "ekklesia-corresponding-source verify" <<<"$release_job" && grep -q "release-assets.py upload" <<<"$release_job"' \
|
||||
"the redistribution gate runs before the exact assets are uploaded"
|
||||
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$release_job"' \
|
||||
"upload uses Gitea's automatic per-job token"
|
||||
|
||||
# Publishing is an explicit operator action. One job validates its dispatch input and selected ref,
|
||||
# builds the package exactly once, integration-tests that directory, prepares redistribution files,
|
||||
# and only then gives the same directory to the Gitea publisher.
|
||||
gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||
publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||
check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \
|
||||
"publishing is workflow_dispatch only and cannot run for a PR or push"
|
||||
check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \
|
||||
"manual publishing requires an explicit version input"
|
||||
check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \
|
||||
"the publish gate requires selected main and validates both source versions"
|
||||
check '[[ "$(grep -c "bash ci/package.sh" "$publish_workflow")" -eq 1 ]]' \
|
||||
"the publish workflow builds the canonical Module Package exactly once"
|
||||
check 'grep -q "ci/integration.sh.*build/release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$publish_job" && grep -q "release-assets.py publish" <<<"$publish_job"' \
|
||||
"integration proves the package handed unchanged to the publisher"
|
||||
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*steps.canonical.outputs.sha256" <<<"$gate_job" && grep -q "EXPECTED_PACKAGE_SHA256:.*needs.release-gate.outputs.sha256" <<<"$publish_job"' \
|
||||
"the publish gate binds consumers to the canonical package digest"
|
||||
check 'grep -q "ekklesia-corresponding-source verify" <<<"$gate_job" && grep -q "needs: release-gate" <<<"$publish_job"' \
|
||||
"corresponding-source verification finishes before the publishing job"
|
||||
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$publish_job" && grep -q "contents: write" <<<"$publish_job" && ! grep -q "contents: write" <<<"$gate_job"' \
|
||||
"only the final manual publishing job receives release write authority"
|
||||
|
||||
# The manual workflow also owns a Docker integration stack, so it follows the same cleanup rule.
|
||||
publish_stale="$(grep -n -F -- 'ci-sweep.sh --stale' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
publish_run="$(grep -n -F -- 'bash ci/integration.sh' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
publish_own="$(grep -n -F -- 'ci-sweep.sh --job' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
check '[[ -n "$publish_stale" && -n "$publish_run" ]] && ((publish_stale < publish_run))' \
|
||||
"manual publishing sweeps stale stacks before integration"
|
||||
check '[[ -n "$publish_own" && -n "$publish_run" ]] && ((publish_own > publish_run))' \
|
||||
"manual publishing always has a post-integration cleanup step"
|
||||
|
||||
((failures == 0)) || {
|
||||
echo "check-cleanup: ${failures} check(s) failed." >&2
|
||||
|
||||
+50
-15
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and attach one tested Signage release bundle to an existing Gitea release."""
|
||||
"""Validate and publish one tested Signage release bundle through the Gitea API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -124,7 +124,8 @@ class Gitea:
|
||||
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
|
||||
|
||||
def request(self, method: str, path: str, body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None, *, authenticated: bool = True) -> bytes:
|
||||
headers: dict[str, str] | None = None, *, authenticated: bool = True,
|
||||
missing_ok: bool = False) -> bytes | None:
|
||||
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
|
||||
request_headers = dict(headers or {})
|
||||
if authenticated:
|
||||
@@ -134,29 +135,61 @@ class Gitea:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
if missing_ok and error.code == 404:
|
||||
return None
|
||||
detail = error.read()[:300].decode("utf-8", "replace")
|
||||
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
|
||||
|
||||
def json(self, method: str, path: str) -> object:
|
||||
data = self.request(method, path)
|
||||
return json.loads(data) if data else None
|
||||
def json(self, method: str, path: str, body: object | None = None,
|
||||
*, missing_ok: bool = False) -> object:
|
||||
encoded = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if encoded is not None else None
|
||||
data = self.request(method, path, encoded, headers, missing_ok=missing_ok)
|
||||
return json.loads(data) if data is not None else None
|
||||
|
||||
def public_download(self, url: str) -> bytes:
|
||||
return self.request("GET", url, authenticated=False)
|
||||
|
||||
|
||||
def upload(directory: Path, tag: str, revision: str) -> None:
|
||||
def publish(directory: Path, tag: str, revision: str) -> None:
|
||||
# This validates every local byte before the first API request, and therefore before creating or
|
||||
# mutating a release. Corresponding-source policy verification happens in the workflow first.
|
||||
files = expected_paths(directory, tag, revision)
|
||||
gitea = Gitea()
|
||||
quoted_tag = urllib.parse.quote(tag, safe="")
|
||||
release = gitea.json("GET", f"/releases/tags/{quoted_tag}")
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
||||
if not isinstance(release, dict):
|
||||
raise Refused(f"release {tag} does not exist")
|
||||
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
||||
raise Refused(f"tag {tag} does not name {revision}")
|
||||
release = gitea.json("GET", f"/releases/tags/{quoted_tag}", missing_ok=True)
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}", missing_ok=True)
|
||||
if tagged is not None and (
|
||||
not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision
|
||||
):
|
||||
raise Refused(f"existing tag {tag} does not name {revision}")
|
||||
if release is not None and tagged is None:
|
||||
raise Refused(f"release {tag} exists without a readable tag")
|
||||
if release is None:
|
||||
release = gitea.json("POST", "/releases", {
|
||||
"tag_name": tag,
|
||||
# A full immutable commit, never a moving branch, is the release's tag target.
|
||||
"target_commitish": revision,
|
||||
"name": f"Ekklesia Signage {tag}",
|
||||
"body": (
|
||||
"Published by the manual Publish Signage Release workflow after package, "
|
||||
"external integration, and corresponding-source verification."
|
||||
),
|
||||
# Existing Signage 0.x releases use Gitea's prerelease convention.
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
})
|
||||
if not isinstance(release, dict):
|
||||
raise Refused(f"Gitea did not return the created release {tag}")
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
||||
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
||||
raise Refused(f"created tag {tag} does not name {revision}")
|
||||
elif not isinstance(release, dict):
|
||||
raise Refused(f"cannot read release {tag}")
|
||||
|
||||
release_id = release.get("id")
|
||||
if not isinstance(release_id, int):
|
||||
raise Refused(f"release {tag} has no numeric id")
|
||||
listed = gitea.json("GET", f"/releases/{release_id}/assets")
|
||||
present = {asset["name"]: asset for asset in listed or []}
|
||||
unexpected = sorted(set(present) - {path.name for path in files})
|
||||
@@ -207,8 +240,10 @@ def upload(directory: Path, tag: str, revision: str) -> None:
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "upload"}:
|
||||
raise Refused("usage: release-assets.py (verify-package|verify|upload) DIRECTORY TAG REVISION")
|
||||
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "publish"}:
|
||||
raise Refused(
|
||||
"usage: release-assets.py (verify-package|verify|publish) DIRECTORY TAG REVISION"
|
||||
)
|
||||
command, directory, tag, revision = argv
|
||||
selected = (package_pair(Path(directory), tag, revision)
|
||||
if command == "verify-package"
|
||||
@@ -217,7 +252,7 @@ def main(argv: list[str]) -> int:
|
||||
for path in selected:
|
||||
print(path)
|
||||
else:
|
||||
upload(Path(directory), tag, revision)
|
||||
publish(Path(directory), tag, revision)
|
||||
return 0
|
||||
except (OSError, json.JSONDecodeError, Refused) as error:
|
||||
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
|
||||
|
||||
+63
-15
@@ -63,9 +63,13 @@ def release_bundle(directory: Path) -> list[Path]:
|
||||
|
||||
class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
assets: dict[str, bytes] = {}
|
||||
release_exists = True
|
||||
tag_revision: str | None = REVISION
|
||||
api_authorizations: list[str | None] = []
|
||||
public_authorizations: list[str | None] = []
|
||||
posts = 0
|
||||
asset_posts = 0
|
||||
release_posts = 0
|
||||
release_request: dict[str, object] | None = None
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
pass
|
||||
@@ -100,9 +104,15 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||
if "/releases/tags/" in self.path:
|
||||
self.answer(200, {"id": 7})
|
||||
if self.release_exists:
|
||||
self.answer(200, {"id": 7, "tag_name": f"v{VERSION}"})
|
||||
else:
|
||||
self.answer(404, {"message": "not found"})
|
||||
elif "/tags/" in self.path:
|
||||
self.answer(200, {"commit": {"sha": REVISION}})
|
||||
if self.tag_revision is not None:
|
||||
self.answer(200, {"commit": {"sha": self.tag_revision}})
|
||||
else:
|
||||
self.answer(404, {"message": "not found"})
|
||||
elif self.path.endswith("/releases/7/assets"):
|
||||
self.answer(200, self.asset_list())
|
||||
else:
|
||||
@@ -110,10 +120,19 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
|
||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||
self.__class__.posts += 1
|
||||
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
|
||||
length = int(self.headers["Content-Length"])
|
||||
body = self.rfile.read(length)
|
||||
if self.path.endswith("/releases"):
|
||||
request = json.loads(body)
|
||||
self.__class__.release_posts += 1
|
||||
self.__class__.release_request = request
|
||||
self.__class__.release_exists = True
|
||||
self.__class__.tag_revision = request["target_commitish"]
|
||||
self.answer(201, {"id": 7, "tag_name": request["tag_name"]})
|
||||
return
|
||||
|
||||
self.__class__.asset_posts += 1
|
||||
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
|
||||
boundary = self.headers["Content-Type"].split("boundary=", 1)[1].encode()
|
||||
payload = body.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n--" + boundary + b"--", 1)[0]
|
||||
self.assets[name] = payload
|
||||
@@ -121,11 +140,16 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_gitea(initial: dict[str, bytes] | None = None):
|
||||
def fake_gitea(initial: dict[str, bytes] | None = None, *, release_exists: bool = True,
|
||||
tag_revision: str | None = REVISION):
|
||||
FakeGitea.assets = dict(initial or {})
|
||||
FakeGitea.release_exists = release_exists
|
||||
FakeGitea.tag_revision = tag_revision
|
||||
FakeGitea.api_authorizations = []
|
||||
FakeGitea.public_authorizations = []
|
||||
FakeGitea.posts = 0
|
||||
FakeGitea.asset_posts = 0
|
||||
FakeGitea.release_posts = 0
|
||||
FakeGitea.release_request = None
|
||||
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
@@ -174,13 +198,25 @@ class ReleaseAssetsTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
|
||||
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
|
||||
|
||||
def test_uploads_each_selected_byte_and_reads_it_back_anonymously(self) -> None:
|
||||
def test_creates_release_uploads_each_byte_and_reads_it_back_anonymously(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
expected = release_bundle(directory)
|
||||
with fake_gitea():
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 6)
|
||||
with fake_gitea(release_exists=False, tag_revision=None):
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 1)
|
||||
self.assertEqual(FakeGitea.release_request, {
|
||||
"tag_name": f"v{VERSION}",
|
||||
"target_commitish": REVISION,
|
||||
"name": f"Ekklesia Signage v{VERSION}",
|
||||
"body": (
|
||||
"Published by the manual Publish Signage Release workflow after package, "
|
||||
"external integration, and corresponding-source verification."
|
||||
),
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
})
|
||||
self.assertEqual(FakeGitea.asset_posts, 6)
|
||||
self.assertEqual(FakeGitea.assets, {path.name: path.read_bytes() for path in expected})
|
||||
self.assertTrue(all(value == "token test-token" for value in FakeGitea.api_authorizations))
|
||||
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
|
||||
@@ -191,14 +227,26 @@ class ReleaseAssetsTest(unittest.TestCase):
|
||||
expected = release_bundle(directory)
|
||||
initial = {path.name: path.read_bytes() for path in expected}
|
||||
with fake_gitea(initial):
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 0)
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
conflicting = {expected[1].name: b"different published bytes"}
|
||||
with fake_gitea(conflicting):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 0)
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
def test_refuses_existing_tag_at_another_revision_before_mutation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
release_bundle(directory)
|
||||
with fake_gitea(release_exists=False, tag_revision="b" * 40):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "existing tag"):
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user