From 86121d793102a1f7f83fca190040551b452f4cf6 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 10 Oct 2026 22:17:04 -0500 Subject: [PATCH] ci: separate manual release publishing --- .gitea/workflows/ci.yml | 74 +---------- .gitea/workflows/publish-release.yml | 180 +++++++++++++++++++++++++++ ci/check-cleanup.sh | 59 ++++++--- ci/release-assets.py | 65 +++++++--- ci/test_release_assets.py | 78 +++++++++--- 5 files changed, 337 insertions(+), 119 deletions(-) create mode 100644 .gitea/workflows/publish-release.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5316505..cfcdf36 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,15 +16,13 @@ name: CI # The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and # `timeout-minutes` are declared so the workflow is correct wherever they are enforced. # Artifact actions v3 are deliberate: Gitea implements that protocol, while v4 refuses non-GitHub -# servers. A tag job uses Gitea's automatic per-job token only after every required job succeeds. +# servers. This workflow never publishes or mutates a release; publishing is a separate manual gate. on: pull_request: push: branches: - main - tags: - - "v*" permissions: contents: read @@ -156,8 +154,7 @@ jobs: - name: Validate the built module and its migrations run: pnpm validate - # Build the canonical pair once. Integration downloads these exact bytes, and a successful tag - # run's release job later downloads the same artifact; neither consumer is allowed to repackage. + # Build the canonical pair once. Integration downloads these exact bytes and may not repackage. package: name: package runs-on: ubuntu-latest @@ -254,70 +251,3 @@ jobs: if [ -f build/platform/scripts/ci-sweep.sh ]; then bash build/platform/scripts/ci-sweep.sh --job fi - - # Existing Signage releases and tags are created by a person. On a v* tag, attach only the - # package pair this run's integration consumed, plus ADR 0055's required corresponding-source - # material. No release or tag is created here, and an existing asset is never replaced. - release: - name: release - needs: - - lint - - typecheck - - test - - build - - package - - integration - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Node.js at the repository's version - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: .node-version - - - name: Install the reviewed release tooling - run: | - corepack enable - corepack install - pnpm install --frozen-lockfile - - - name: Receive the exact pair integration tested - uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 - with: - name: signage-module-package - path: build/release-assets/ - - - name: Verify tag, revision, package bytes, and public-distribution material - env: - EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }} - RELEASE_TAG: ${{ github.ref_name }} - RELEASE_REVISION: ${{ github.sha }} - run: | - python3 ci/release-assets.py verify-package build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" - package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')" - pnpm exec ekklesia-corresponding-source acquire \ - --package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" - pnpm exec ekklesia-corresponding-source build \ - --package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \ - --out build/release-assets - source="${package%.tar.gz}-corresponding-source.tar.gz" - metadata="${package%.tar.gz}.redistribution.json" - pnpm exec ekklesia-corresponding-source verify \ - --package "$package" --source "$source" --metadata "$metadata" - python3 ci/release-assets.py verify build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" - - - name: Attach the exact tested package and its required source material - env: - EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }} - GITHUB_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - RELEASE_REVISION: ${{ github.sha }} - run: python3 ci/release-assets.py upload build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" diff --git a/.gitea/workflows/publish-release.yml b/.gitea/workflows/publish-release.yml new file mode 100644 index 0000000..93037dc --- /dev/null +++ b/.gitea/workflows/publish-release.yml @@ -0,0 +1,180 @@ +name: Publish Signage Release + +# Builds, proves, and publishes one Signage release. This is deliberately separate from CI: only an +# operator's workflow_dispatch on a selected branch can start it. The normal release ref is main. +# Every local gate, including the full external-package integration and ADR 0055 redistribution +# verification, finishes before the release/tag is created or an existing release is touched. + +on: + workflow_dispatch: + inputs: + version: + description: The Signage version to publish, equal to package and module metadata. + required: true + type: string + +permissions: + contents: read + +# Never cancel a publication part way, and never run two at once. A rerun safely resumes an +# identical partial upload, but refuses a tag, release, or asset that differs from this run. +concurrency: + group: publish-signage-release + cancel-in-progress: false + +jobs: + release-gate: + name: release gate + runs-on: ubuntu-latest + timeout-minutes: 75 + outputs: + sha256: ${{ steps.canonical.outputs.sha256 }} + env: + DOCKER_BUILDKIT: "1" + EKKLESIA_CI_JOB_KEY: signage-publish-release + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: v${{ inputs.version }} + RELEASE_REVISION: ${{ github.sha }} + steps: + - name: Check out the operator-selected revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js at the repository's version + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Install the reviewed build and release tooling + run: | + corepack enable + corepack install + pnpm install --frozen-lockfile + + # Match Core's manual publisher: releases come only from reviewed main. The source version is + # checked before the expensive gate, and the selected commit remains the immutable tag target. + - name: Validate requested version and selected revision + run: | + [[ "$GITHUB_REF" == refs/heads/main ]] || { + echo "publish: select main; got $GITHUB_REF" >&2 + exit 1 + } + [[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "publish: version must be X.Y.Z; got $RELEASE_VERSION" >&2 + exit 1 + } + [[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] + [[ "$(git rev-parse HEAD)" == "$RELEASE_REVISION" ]] + [[ -z "$(git status --porcelain)" ]] + node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const requested = process.env.RELEASE_VERSION; + const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version; + const moduleVersion = JSON.parse(readFileSync("ekklesia.module.json", "utf8")).version; + if (packageVersion !== requested || moduleVersion !== requested) { + console.error(`publish: requested ${requested}, package ${packageVersion}, module ${moduleVersion}`); + process.exit(1); + } + console.log(`publish: source metadata agrees on ${requested}`); + ' + + # This is the only package build in the workflow. Every later step consumes this directory. + - name: Build the canonical Module Package pair once + id: canonical + run: bash ci/package.sh build/release-assets + + - name: Verify the canonical Module Package + env: + EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }} + run: | + pnpm validate + python3 ci/release-assets.py verify-package \ + build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" + + - name: Fetch the pinned Ekklesia integration harness + run: bash ci/fetch-platform.sh + + - name: Sweep stale CI stacks from the runner + run: bash build/platform/scripts/ci-sweep.sh --stale + + - name: Run full integration with the exact release package + env: + EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }} + run: bash ci/integration.sh "$GITHUB_WORKSPACE/build/release-assets" + + - name: Remove this publication's CI stacks + if: always() + run: | + if [ -f build/platform/scripts/ci-sweep.sh ]; then + bash build/platform/scripts/ci-sweep.sh --job + fi + + - name: Build and verify required redistribution assets + env: + EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }} + run: | + package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')" + pnpm exec ekklesia-corresponding-source acquire \ + --package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" + pnpm exec ekklesia-corresponding-source build \ + --package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \ + --out build/release-assets + source="${package%.tar.gz}-corresponding-source.tar.gz" + metadata="${package%.tar.gz}.redistribution.json" + pnpm exec ekklesia-corresponding-source verify \ + --package "$package" --source "$source" --metadata "$metadata" + python3 ci/release-assets.py verify \ + build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" + + # The write-authorized job receives only the completely proven bundle. Artifact actions v3 + # are required by Gitea; v4 refuses servers other than github.com. + - name: Hand the proven release bundle to the publisher + uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20 + with: + name: signage-release-assets + path: build/release-assets/ + if-no-files-found: error + retention-days: 90 + + publish: + name: publish + needs: release-gate + if: github.ref == 'refs/heads/main' + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + EXPECTED_PACKAGE_SHA256: ${{ needs.release-gate.outputs.sha256 }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: v${{ inputs.version }} + RELEASE_REVISION: ${{ github.sha }} + steps: + # The checked-out helper is the same reviewed revision the gate proved. Credentials are not + # persisted; the automatic token is exposed only to the final API call below. + - name: Check out the proven revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Receive the proven release bundle + uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 + with: + name: signage-release-assets + path: build/release-assets/ + + - name: Recheck the received release bundle + run: | + python3 ci/release-assets.py verify \ + build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" + + # Only this final step can mutate Gitea. It creates v at the selected commit and a + # prerelease if absent, then uploads the already-tested bytes. A rerun resumes only identical + # state. Every asset is read back without Authorization and checked byte for byte. + - name: Create the release and publish the exact tested assets + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 ci/release-assets.py publish \ + build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION" diff --git a/ci/check-cleanup.sh b/ci/check-cleanup.sh index afa776c..8f0089d 100755 --- a/ci/check-cleanup.sh +++ b/ci/check-cleanup.sh @@ -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 diff --git a/ci/release-assets.py b/ci/release-assets.py index 46daf1d..8ba4d5c 100755 --- a/ci/release-assets.py +++ b/ci/release-assets.py @@ -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) diff --git a/ci/test_release_assets.py b/ci/test_release_assets.py index 7f6348f..d3f7df4 100755 --- a/ci/test_release_assets.py +++ b/ci/test_release_assets.py @@ -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__": -- 2.54.0