diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54f41e83..be8c5de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ name: CI # comments the detected packs; `changepack-required` makes it a gate. # 2. On push to main with pending changepacks, the action opens an # "Update Versions" pull request that runs `changepacks update`. -# 3. Merging that PR leaves no changepacks, so the action cuts tags and -# *draft* releases and reports them in `pending_releases`. +# 3. With no changepacks left, packaging/release.py resolves the inherited +# workspace version to the canonical MCP tag and prepares a draft. # 4. `build` compiles every MCP binary for all three platforms and uploads # them onto those drafts. # 5. `bundle` collects those same three binaries back into one `.mcpb` @@ -199,6 +199,10 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo install cargo-insta --version 1.48.0 --locked - run: cargo fmt --all -- --check + # Platform-independent Git/API suite; macOS images ship only `python3`. + - name: Test canonical workspace release delivery + if: matrix.os == 'ubuntu-latest' + run: python3 packaging/test_release.py - run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs harness/runtime-audit/test/checks.test.mjs # `plugin/dist/` is committed so a user can import the plugin into Figma # without running a build. That only stays true if the committed bundle @@ -275,6 +279,15 @@ jobs: else echo "superseded=false" >> "$GITHUB_OUTPUT" fi + # Must run before the action: after opening the Update Versions PR it + # leaves HEAD on changepacks/main with the logs consumed, and prepare + # refuses any checkout other than the workflow commit. + - name: Prepare canonical MCP draft release + id: release + if: steps.freshness.outputs.superseded == 'false' && github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 packaging/release.py prepare - uses: dtolnay/rust-toolchain@1.98.0 if: steps.freshness.outputs.superseded == 'false' && github.ref == 'refs/heads/main' # The upstream action stages and commits immediately after its update, @@ -313,11 +326,15 @@ jobs: DEVUP_RELEASE_LOCK_SYNC: ${{ github.ref == 'refs/heads/main' }} with: token: ${{ secrets.GITHUB_TOKEN }} - create_release: true + # Version inheritance can emit Cargo.toml rather than the crate path. + # Own draft creation above so updates keep the tag older clients use, + # and nothing becomes public before its binaries and bundle exist. + create_release: false outputs: changepacks: ${{ steps.changepacks.outputs.changepacks }} - release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} - pending_releases: ${{ steps.changepacks.outputs.pending_releases }} + release_assets_urls: ${{ steps.release.outputs.release_assets_urls }} + pending_releases: ${{ steps.release.outputs.pending_releases }} + release_sha: ${{ steps.release.outputs.release_sha }} build: name: build (${{ matrix.os }}) @@ -349,6 +366,12 @@ jobs: ext: "" steps: - uses: actions/checkout@v7 + - name: Retain this workflow's release delivery helper + shell: bash + run: cp packaging/release.py "$RUNNER_TEMP/mcp-release.py" + - uses: actions/checkout@v7 + with: + ref: ${{ needs.changepacks.outputs.release_sha }} - uses: dtolnay/rust-toolchain@1.98.0 - uses: Swatinem/rust-cache@v2 - name: Build release binaries @@ -363,7 +386,7 @@ jobs: # Explicit release identity remains deterministic. R5 synchronizes # Cargo.lock in the version commit, preventing the old automatic # Cargo rewrite that made released binaries report -dirty. - DEVUP_MCP_BUILD_ID: ${{ github.sha }} + DEVUP_MCP_BUILD_ID: ${{ needs.changepacks.outputs.release_sha }} run: | set -euo pipefail for target in $TARGETS; do @@ -396,33 +419,20 @@ jobs: path: dist/devup-mcp-${{ matrix.suffix }}${{ matrix.ext }} if-no-files-found: error retention-days: 1 + # macOS images ship only `python3` and Windows only `python`; the helper + # needs 3.11+ under one name on all three runners. + - uses: actions/setup-python@v7 + with: + python-version: "3.13" - name: Upload binaries onto the draft release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }} + PENDING_RELEASES: ${{ needs.changepacks.outputs.pending_releases }} run: | set -euo pipefail - # release_assets_urls maps project path -> asset upload URL. The - # binaries belong to the devup-mcp crate; the library crates get - # their own releases with no assets. - upload="$(printf '%s' "$ASSET_URLS" \ - | jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')" - if [ -z "$upload" ]; then - echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2 - printf '%s\n' "$ASSET_URLS" >&2 - exit 1 - fi - # Drop the RFC 6570 template suffix, e.g. "{?name,label}". - upload="${upload%%\{*}" for file in dist/*; do - name="$(basename "$file")" - echo "uploading $name" - curl --fail-with-body -sS -X POST \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"$file" \ - "${upload}?name=${name}" >/dev/null + python "$RUNNER_TEMP/mcp-release.py" upload --file "$file" done # Six loose binaries make a user pick the right file for their machine and @@ -441,6 +451,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - name: Retain this workflow's release delivery helper + shell: bash + run: cp packaging/release.py "$RUNNER_TEMP/mcp-release.py" + - uses: actions/checkout@v7 + with: + ref: ${{ needs.changepacks.outputs.release_sha }} - uses: actions/setup-node@v6 with: node-version: 24 @@ -513,24 +529,10 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }} + PENDING_RELEASES: ${{ needs.changepacks.outputs.pending_releases }} run: | set -euo pipefail - upload="$(printf '%s' "$ASSET_URLS" \ - | jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')" - if [ -z "$upload" ]; then - echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2 - printf '%s\n' "$ASSET_URLS" >&2 - exit 1 - fi - # Drop the RFC 6570 template suffix, e.g. "{?name,label}". - upload="${upload%%\{*}" - echo "uploading $MCPB_FILE" - curl --fail-with-body -sS -X POST \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"$MCPB_FILE" \ - "${upload}?name=${MCPB_FILE}" >/dev/null + python3 "$RUNNER_TEMP/mcp-release.py" upload --file "$MCPB_FILE" finalize: name: finalize release @@ -540,10 +542,9 @@ jobs: && needs.changepacks.outputs.pending_releases != '{}' runs-on: ubuntu-latest steps: - # Finalize-only: the action neither installs changepacks nor touches the - # repository here, so no checkout is needed. Running it after `build` - # is what guarantees a published release always has its binaries. - - uses: changepacks/action@main - with: - token: ${{ secrets.GITHUB_TOKEN }} - finalize_releases: ${{ needs.changepacks.outputs.pending_releases }} + - uses: actions/checkout@v7 + - name: Verify all release assets before publishing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PENDING_RELEASES: ${{ needs.changepacks.outputs.pending_releases }} + run: python3 packaging/release.py finalize diff --git a/.gitignore b/.gitignore index 332d51df..008836a6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ # Installed by `npm ci` in harness/runtime-audit (and plugin/) to run the # executor locally; never part of the tree. node_modules/ + +# Python release checks and local tooling bytecode. +__pycache__/ +*.py[cod] diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 00000000..cf25f59c --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,35 @@ +# MCP release delivery + +All server crates inherit `[workspace.package].version`. A changepack can therefore +release `Cargo.toml` without returning a `crates/devup-mcp/Cargo.toml` upload entry. +Run 41735774706 reached exactly that state: all compilation checks passed, the +workspace-only 0.20.0 release became public without assets, and binary upload +failed while looking for the crate key. Existing clients only recognize the +canonical `devup-mcp(crates/devup-mcp/Cargo.toml)@` tag. + +Changepacks still prepares the version-update PR and synchronized lockfile. Its +own release creation is disabled. After those logs are consumed on main, +`release.py prepare` creates or resumes the canonical MCP draft and emits the +crate-keyed upload URL, pending receipt and frozen source commit. A pre-existing +workspace release is preserved; its notes are reused when recovering that version. +Published canonical releases require all expected assets before being accepted +as complete. A missing canonical 0.20.0 release is recoverable without another +version bump. + +Build and bundle jobs retain the current workflow's helper, then check out that +frozen source SHA. This keeps a retried draft's binaries, bundle version and tag +on the same source even when main has moved. Uploads verify SHA-256 and size; +identical retries are no-ops, incomplete empty draft uploads can be retried, and +different uploaded bytes are never overwritten. The final job independently +checks the tag/commit and all three platform binaries plus the MCPB bundle before +publishing and setting latest. + +Run the isolated Git/API regression suite with: + +```sh +python packaging/test_release.py +``` + +The suite never contacts GitHub or publishes a release. Live delivery runs only +in the main workflow with its scoped `GITHUB_TOKEN`. Retrying an old failed run +uses its original workflow; recovery needs a new main run containing this fix. diff --git a/packaging/release.py b/packaging/release.py new file mode 100644 index 00000000..3a5c53c5 --- /dev/null +++ b/packaging/release.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Deliver the workspace version as the canonical, asset-complete MCP release.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tomllib +from urllib.error import HTTPError +from urllib.parse import quote, urlsplit +from urllib.request import Request, urlopen + +SERVER = "crates/devup-mcp/Cargo.toml" +PREFIX = f"devup-mcp({SERVER})@" +SHA = re.compile(r"[0-9a-f]{40}\Z") + + +class ApiError(RuntimeError): + def __init__(self, status, path): + self.status = status + super().__init__(f"GitHub API returned {status} for {path}") + + +class GitHub: + def __init__(self, repository, token): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Invalid GITHUB_REPOSITORY") + self.repository, self.token = repository, token + + def request(self, method, path, body=None): + data = None if body is None else json.dumps(body).encode() + return self._send(method, f"https://api.github.com/repos/{self.repository}/{path}", data, "application/json") + + def _send(self, method, url, data, content_type): + request = Request(url, data=data, method=method, headers={ + "Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", + "Content-Type": content_type, "User-Agent": "devup-mcp-release", + "X-GitHub-Api-Version": "2022-11-28", + }) + try: + with urlopen(request, timeout=120) as response: + raw = response.read() + return json.loads(raw) if raw else None + except HTTPError as error: + raise ApiError(error.code, urlsplit(url).path) from None + + def upload(self, release, file): + endpoint = release["upload_url"].split("{", 1)[0] + expected = f"https://uploads.github.com/repos/{self.repository}/releases/{release['id']}/assets" + if endpoint != expected: + raise ValueError("Release upload endpoint does not match its repository and ID") + return self._send("POST", f"{endpoint}?name={quote(file.name, safe='')}", file.read_bytes(), "application/octet-stream") + + +def optional(api, path): + try: + return api.request("GET", path) + except ApiError as error: + if error.status != 404: + raise + return None + + +def git(root, *args): + return subprocess.check_output(["git", *args], cwd=root, text=True, encoding="utf-8").strip() + + +def version_at(root, sha=None): + contents = git(root, "show", f"{sha}:Cargo.toml") if sha else (root / "Cargo.toml").read_text(encoding="utf-8") + version = tomllib.loads(contents)["workspace"]["package"]["version"] + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("Release delivery requires a stable workspace version") + return version + + +def expected_assets(version): + return {"devup-mcp-linux-x86_64", "devup-mcp-macos-universal", "devup-mcp-windows-x86_64.exe", f"devup-mcp-{version}.mcpb"} + + +def receipt(release, version, source): + return {"releaseId": release["id"], "tagName": PREFIX + version, "makeLatest": True, "sourceSha": source, "version": version} + + +def resolve_tag(api, tag): + ref = optional(api, "git/ref/tags/" + quote(tag, safe="")) + if ref is None: + return None + obj = ref["object"] + for _ in range(8): + if obj["type"] == "commit": + source = obj["sha"] + if not SHA.fullmatch(source): + raise ValueError("Invalid release commit") + return source + if obj["type"] != "tag" or not SHA.fullmatch(obj["sha"]): + break + obj = api.request("GET", "git/tags/" + obj["sha"])["object"] + raise ValueError("Release tag does not resolve to a supported commit") + + +def tag_source(root, api, tag, version, head): + source = resolve_tag(api, tag) + if source is None: + api.request("POST", "git/refs", {"ref": "refs/tags/" + tag, "sha": head}) + return head + subprocess.run(["git", "merge-base", "--is-ancestor", source, head], cwd=root, check=True) + if version_at(root, source) != version: + raise ValueError("Existing release tag has a different workspace version") + return source + + +def find_release(api, tag): + found = [] + for page in range(1, 11): + releases = api.request("GET", f"releases?per_page=100&page={page}") + found.extend(release for release in releases if release["tag_name"] == tag) + if len(releases) < 100: + break + else: + raise ValueError("Release inventory exceeds its bounded page limit") + if len(found) > 1: + raise ValueError("Multiple releases claim the canonical MCP tag") + return found[0] if found else None + + +def assets(api, release_id): + result = [] + for page in range(1, 11): + batch = api.request("GET", f"releases/{release_id}/assets?per_page=100&page={page}") + result.extend(batch) + if len(batch) < 100: + return result + raise ValueError("Release asset inventory exceeds its bounded page limit") + + +def require_assets(items, version): + for name in expected_assets(version): + matching = [item for item in items if item["name"] == name] + if len(matching) != 1 or matching[0].get("state") != "uploaded" or matching[0].get("size", 0) <= 0: + raise ValueError(f"Release is missing a complete asset: {name}") + + +def prepare(root, api, head): + empty = {"release_assets_urls": {}, "pending_releases": {}, "release_sha": ""} + if list((root / ".changepacks").glob("changepack_log_*.json")): + return empty + if not SHA.fullmatch(head) or git(root, "rev-parse", "HEAD") != head: + raise ValueError("Release preparation must run from the workflow source commit") + if api.request("GET", "git/ref/heads/main")["object"]["sha"] != head: + return empty + version = version_at(root) + if version_at(root, head) != version: + raise ValueError("Workspace release version has uncommitted changes") + crate = tomllib.loads((root / SERVER).read_text(encoding="utf-8")) + if crate["package"].get("version") != {"workspace": True}: + raise ValueError("The MCP crate must inherit the workspace release version") + tag = PREFIX + version + release = find_release(api, tag) + if release and not release["draft"]: + require_assets(assets(api, release["id"]), version) + return empty + source = tag_source(root, api, tag, version, head) + if release is None: + workspace = find_release(api, f"devup-mcp(Cargo.toml)@{version}") + notes = {"body": workspace["body"]} if workspace and workspace.get("body") else api.request("POST", "releases/generate-notes", {"tag_name": tag, "target_commitish": source}) + release = api.request("POST", "releases", { + "tag_name": tag, "target_commitish": source, "name": tag, + "body": notes["body"], "draft": True, "make_latest": "false", + }) + if release["tag_name"] != tag or not release["draft"]: + raise ValueError("Expected the canonical MCP draft release") + return {"release_assets_urls": {SERVER: release["upload_url"]}, + "pending_releases": {SERVER: receipt(release, version, source)}, "release_sha": source} + + +def bound_release(api, saved): + if type(saved.get("releaseId")) is not int or saved["releaseId"] <= 0 or saved.get("tagName") != PREFIX + saved["version"] or not SHA.fullmatch(saved["sourceSha"]): + raise ValueError("Invalid MCP release receipt") + release = api.request("GET", f"releases/{saved['releaseId']}") + if release["tag_name"] != saved["tagName"] or release["target_commitish"] != saved["sourceSha"]: + raise ValueError("Release identity changed after preparation") + if resolve_tag(api, saved["tagName"]) != saved["sourceSha"]: + raise ValueError("Release tag changed after preparation") + return release + + +def upload(api, saved, file): + release = bound_release(api, saved) + if file.name not in expected_assets(saved["version"]) or not file.is_file() or not 0 < file.stat().st_size <= 256 * 1024 * 1024: + raise ValueError("Unexpected, empty or oversized release asset") + with file.open("rb") as stream: + digest = "sha256:" + hashlib.file_digest(stream, "sha256").hexdigest() + existing = [item for item in assets(api, release["id"]) if item["name"] == file.name] + if existing: + if len(existing) == 1 and existing[0].get("state") == "starter" and existing[0].get("size") == 0 and release["draft"]: + api.request("DELETE", f"releases/assets/{existing[0]['id']}") + elif len(existing) != 1 or existing[0].get("state") != "uploaded" or existing[0].get("size") != file.stat().st_size or existing[0].get("digest") != digest: + raise ValueError("Existing release asset differs; no asset was overwritten") + else: + return + if not release["draft"]: + raise ValueError("Refusing to add assets to an already published release") + uploaded = api.upload(release, file) + if uploaded.get("digest") != digest or uploaded.get("size") != file.stat().st_size or uploaded.get("state") != "uploaded": + raise ValueError("Uploaded release asset failed its size/digest verification") + + +def finalize(api, saved): + release = bound_release(api, saved) + require_assets(assets(api, release["id"]), saved["version"]) + if release["draft"]: + api.request("PATCH", f"releases/{release['id']}", {"draft": False, "make_latest": "true"}) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["prepare", "upload", "finalize"]) + parser.add_argument("--file", type=Path) + args = parser.parse_args() + api = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["GH_TOKEN"]) + if args.action == "prepare": + outputs = prepare(Path.cwd(), api, os.environ["GITHUB_SHA"]) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + for key, value in outputs.items(): + out.write(f"{key}={value if isinstance(value, str) else json.dumps(value, separators=(',', ':'))}\n") + print("MCP release pending" if outputs["pending_releases"] else "No MCP release delivery needed") + else: + saved = json.loads(os.environ["PENDING_RELEASES"])[SERVER] + if args.action == "upload": + if args.file is None: + parser.error("upload requires --file") + upload(api, saved, args.file) + else: + finalize(api, saved) + + +if __name__ == "__main__": + main() diff --git a/packaging/test_release.py b/packaging/test_release.py new file mode 100644 index 00000000..6c6f4371 --- /dev/null +++ b/packaging/test_release.py @@ -0,0 +1,238 @@ +"""Release lifecycle regressions using real Git commits and an isolated API.""" +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +from urllib.parse import unquote +import unittest + +SPEC = importlib.util.spec_from_file_location("delivery", Path(__file__).with_name("release.py")) +delivery = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(delivery) + + +class FakeGitHub: + def __init__(self, head): + self.head = head + self.tags, self.releases, self.uploaded, self.calls = {}, [], {}, [] + + def request(self, method, path, body=None): + self.calls.append((method, path, copy.deepcopy(body))) + if method == "GET" and path == "git/ref/heads/main": + return {"object": {"sha": self.head}} + if method == "GET" and path.startswith("git/ref/tags/"): + tag = unquote(path.split("git/ref/tags/", 1)[1]) + if tag not in self.tags: + raise delivery.ApiError(404, path) + return {"object": {"type": "commit", "sha": self.tags[tag]}} + if method == "POST" and path == "git/refs": + self.tags[body["ref"].removeprefix("refs/tags/")] = body["sha"] + return {} + if method == "GET" and path.startswith("releases?"): + return copy.deepcopy(self.releases) + if method == "POST" and path == "releases/generate-notes": + return {"body": "Generated release notes"} + if method == "POST" and path == "releases": + release = {**body, "id": len(self.releases) + 1} + release["upload_url"] = f"https://uploads.github.com/repos/example/repo/releases/{release['id']}/assets{{?name,label}}" + self.releases.append(release) + return copy.deepcopy(release) + if method == "DELETE" and path.startswith("releases/assets/"): + asset_id = int(path.split("/")[-1]) + for items in self.uploaded.values(): + items[:] = [item for item in items if item.get("id") != asset_id] + return None + if path.startswith("releases/"): + release_id = int(path.split("/")[1]) + release = next(item for item in self.releases if item["id"] == release_id) + if method == "GET" and "/assets?" in path: + return copy.deepcopy(self.uploaded.get(release_id, [])) + if method == "GET": + return copy.deepcopy(release) + if method == "PATCH": + release.update(body) + return copy.deepcopy(release) + raise AssertionError((method, path, body)) + + def upload(self, release, file): + data = file.read_bytes() + item = {"name": file.name, "size": len(data), "state": "uploaded", "digest": "sha256:" + hashlib.sha256(data).hexdigest()} + self.uploaded.setdefault(release["id"], []).append(item) + return item + + +class DeliveryTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="devup-release-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + (self.root / ".changepacks").mkdir() + (self.root / delivery.SERVER).parent.mkdir(parents=True) + (self.root / delivery.SERVER).write_text('[package]\nname="devup-mcp"\nversion.workspace=true\n', encoding="utf-8") + (self.root / "Cargo.toml").write_text('[workspace.package]\nversion="0.20.0"\n', encoding="utf-8") + self.command("init", "-b", "main") + self.command("config", "user.name", "release test") + self.command("config", "user.email", "release@example.invalid") + self.command("config", "commit.gpgsign", "false") + self.head = self.commit("version") + self.api = FakeGitHub(self.head) + + def command(self, *args): + return subprocess.check_output(["git", *args], cwd=self.root, text=True, encoding="utf-8", stderr=subprocess.PIPE).strip() + + def commit(self, message): + self.command("add", ".") + self.command("commit", "-m", message) + return self.command("rev-parse", "HEAD") + + def prepare(self): + return delivery.prepare(self.root, self.api, self.head) + + def pending(self): + return self.prepare()["pending_releases"][delivery.SERVER] + + def make_assets(self, saved): + for name in delivery.expected_assets(saved["version"]): + file = self.root / name + file.write_bytes((name + " contents").encode()) + delivery.upload(self.api, saved, file) + + def test_actual_workspace_only_release_gets_canonical_draft_and_crate_upload_key(self): + self.api.releases.append({"id": 433388180, "tag_name": "devup-mcp(Cargo.toml)@0.20.0", "draft": False, "body": "All 22 feedback items and 14 follow-ups"}) + result = self.prepare() + saved = result["pending_releases"][delivery.SERVER] + release = self.api.releases[-1] + self.assertTrue(release["draft"]) + self.assertEqual(release["tag_name"], delivery.PREFIX + "0.20.0") + self.assertEqual(release["body"], self.api.releases[0]["body"]) + self.assertEqual(saved["sourceSha"], self.head) + self.assertEqual(result["release_sha"], self.head) + self.assertEqual(list(result["release_assets_urls"]), [delivery.SERVER]) + self.assertEqual(self.api.releases[0]["draft"], False, "existing workspace release is preserved") + + def test_pending_version_change_never_releases_unbumped_code(self): + (self.root / ".changepacks/changepack_log_pending.json").write_text("{}") + self.assertEqual(self.prepare()["pending_releases"], {}) + self.assertEqual(self.api.calls, []) + + def test_superseded_workflow_does_not_create_release_or_tag(self): + self.api.head = "a" * 40 + self.assertEqual(self.prepare()["pending_releases"], {}) + self.assertEqual(self.api.tags, {}) + self.assertEqual(self.api.releases, []) + + def test_draft_retry_uses_original_commit_after_main_moves(self): + first = self.pending() + (self.root / "README.md").write_text("later unrelated documentation") + self.api.head = self.head = self.commit("docs") + retry = self.prepare() + self.assertEqual(retry["release_sha"], first["sourceSha"]) + self.assertEqual(retry["pending_releases"][delivery.SERVER]["releaseId"], first["releaseId"]) + self.assertEqual(len(self.api.releases), 1) + + def test_tag_for_wrong_workspace_version_is_refused(self): + (self.root / "Cargo.toml").write_text('[workspace.package]\nversion="0.21.0"\n') + self.api.head = self.head = self.commit("new version") + self.api.tags[delivery.PREFIX + "0.21.0"] = self.command("rev-parse", "HEAD^") + with self.assertRaisesRegex(ValueError, "different workspace version"): + self.prepare() + self.assertEqual(self.api.releases, []) + + def test_public_complete_release_is_a_noop(self): + saved = self.pending() + self.make_assets(saved) + delivery.finalize(self.api, saved) + self.assertEqual(self.prepare()["pending_releases"], {}) + self.assertEqual(len(self.api.releases), 1) + + def test_public_incomplete_release_cannot_be_mistaken_for_success(self): + self.pending() + self.api.releases[0]["draft"] = False + with self.assertRaisesRegex(ValueError, "missing a complete asset"): + self.prepare() + + def test_identical_upload_retry_is_idempotent_but_changed_bytes_are_refused(self): + saved = self.pending() + file = self.root / "devup-mcp-linux-x86_64" + file.write_bytes(b"binary") + delivery.upload(self.api, saved, file) + delivery.upload(self.api, saved, file) + self.assertEqual(len(self.api.uploaded[saved["releaseId"]]), 1) + file.write_bytes(b"edited binary") + with self.assertRaisesRegex(ValueError, "differs"): + delivery.upload(self.api, saved, file) + + def test_wrong_release_source_never_uploads_or_publishes(self): + saved = self.pending() + self.api.releases[0]["target_commitish"] = "b" * 40 + with self.assertRaisesRegex(ValueError, "identity changed"): + delivery.finalize(self.api, saved) + self.assertTrue(self.api.releases[0]["draft"]) + + def test_interrupted_empty_draft_upload_is_recovered(self): + saved = self.pending() + self.api.uploaded[saved["releaseId"]] = [{"id": 42, "name": "devup-mcp-linux-x86_64", "size": 0, "state": "starter"}] + file = self.root / "devup-mcp-linux-x86_64" + file.write_bytes(b"complete binary") + delivery.upload(self.api, saved, file) + self.assertEqual(len(self.api.uploaded[saved["releaseId"]]), 1) + self.assertEqual(self.api.uploaded[saved["releaseId"]][0]["state"], "uploaded") + + def test_corrupt_upload_receipt_keeps_release_private(self): + saved = self.pending() + file = self.root / "devup-mcp-linux-x86_64" + file.write_bytes(b"binary") + original_upload = self.api.upload + def corrupt(release, file): + uploaded = original_upload(release, file) + uploaded["digest"] = "sha256:" + "0" * 64 + return uploaded + self.api.upload = corrupt + with self.assertRaisesRegex(ValueError, "size/digest"): + delivery.upload(self.api, saved, file) + self.assertTrue(self.api.releases[0]["draft"]) + + def test_tag_moved_during_build_cannot_publish_old_binaries(self): + saved = self.pending() + self.make_assets(saved) + self.api.tags[saved["tagName"]] = "a" * 40 + with self.assertRaisesRegex(ValueError, "tag changed"): + delivery.finalize(self.api, saved) + self.assertTrue(self.api.releases[0]["draft"]) + + def test_uncommitted_version_cannot_create_a_tag(self): + (self.root / "Cargo.toml").write_text('[workspace.package]\nversion="0.21.0"\n') + with self.assertRaisesRegex(ValueError, "uncommitted"): + self.prepare() + self.assertEqual(self.api.tags, {}) + + def test_finalize_requires_all_three_binaries_and_bundle_then_marks_latest(self): + saved = self.pending() + with self.assertRaisesRegex(ValueError, "missing a complete asset"): + delivery.finalize(self.api, saved) + self.assertTrue(self.api.releases[0]["draft"]) + self.make_assets(saved) + delivery.finalize(self.api, saved) + self.assertFalse(self.api.releases[0]["draft"]) + self.assertEqual(self.api.releases[0]["make_latest"], "true") + calls = len(self.api.calls) + delivery.finalize(self.api, saved) + self.assertFalse(any(method == "PATCH" for method, _, _ in self.api.calls[calls:])) + + def test_unexpected_or_empty_asset_is_refused(self): + saved = self.pending() + file = self.root / "not-an-mcp-asset" + file.write_bytes(b"unexpected") + with self.assertRaisesRegex(ValueError, "Unexpected"): + delivery.upload(self.api, saved, file) + file = self.root / "devup-mcp-linux-x86_64" + file.touch() + with self.assertRaisesRegex(ValueError, "empty"): + delivery.upload(self.api, saved, file) + + +if __name__ == "__main__": + unittest.main()