diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e72d049..57dc374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -415,17 +415,25 @@ jobs: 404) rm -f "$appcast" ;; *) echo "Cannot read appcast seed: HTTP $seed_status." >&2; exit 1 ;; esac + if [[ "$RELEASE_CHANNEL" == "alpha" ]]; then + test -f "$appcast" + cp "$appcast" "$RUNNER_TEMP/retention-stable.xml" + fi printf '%s' "$SPARKLE_PRIVATE_ED_KEY" \ | "$generate_appcast" \ --ed-key-file - \ --download-url-prefix "https://github.com/euforic/AgentDock/releases/download/$GITHUB_REF_NAME/" \ --link "https://github.com/euforic/AgentDock" \ - --maximum-versions 3 \ + --maximum-versions 1 \ --maximum-deltas 0 \ "${channel_arguments[@]}" \ -o "$appcast" \ "$archive_dir" + python3 script/release_retention.py prune "$appcast" + printf '%s' "$SPARKLE_PRIVATE_ED_KEY" \ + | "$sign_update" --ed-key-file - "$appcast" >/dev/null + /usr/bin/xmllint --noout "$appcast" /usr/bin/grep -Fq \ "url=\"https://github.com/euforic/AgentDock/releases/download/$GITHUB_REF_NAME/$archive_name\"" \ @@ -438,6 +446,9 @@ jobs: printf '%s' "$SPARKLE_PRIVATE_ED_KEY" \ | "$sign_update" --verify --ed-key-file - "$appcast" >/dev/null cp "$appcast" "$RUNNER_TEMP/$output_filename" + if [[ "$output_filename" == "appcast.xml" ]]; then + cp "$appcast" "$RUNNER_TEMP/retention-stable.xml" + fi done - name: Publish GitHub Pages appcast @@ -487,3 +498,20 @@ jobs: printf '%s' "$SPARKLE_PRIVATE_ED_KEY" \ | "$sign_update" --verify --ed-key-file - "$public_appcast" >/dev/null done + + - name: Remove superseded release downloads + env: + GH_TOKEN: ${{ github.token }} + run: | + # Use the script from the release commit after switching to gh-pages. + git show "$GITHUB_SHA:script/release_retention.py" > "$RUNNER_TEMP/release_retention.py" + gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + > "$RUNNER_TEMP/releases.json" + python3 "$RUNNER_TEMP/release_retention.py" obsolete \ + "$RUNNER_TEMP/releases.json" \ + "$RUNNER_TEMP/retention-stable.xml" \ + "$RUNNER_TEMP/appcast-alpha.xml" \ + > "$RUNNER_TEMP/obsolete-releases.txt" + while IFS= read -r release_id; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/$release_id" + done < "$RUNNER_TEMP/obsolete-releases.txt" diff --git a/.gitignore b/.gitignore index 753236d..6e17e45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .build/ +__pycache__/ dist/ .DS_Store artifacts/ diff --git a/README.md b/README.md index 16ba494..52f261a 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,6 @@ settings. ## Install -Previous releases have been withdrawn during repository privacy cleanup. -Downloads and automatic updates are temporarily unavailable. You can -[build from source](docs/development.md) in the meantime. - -When a new release is available: - 1. Download the DMG from the [latest release](https://github.com/euforic/AgentDock/releases/latest). 2. Open it and drag AgentDock onto the Applications shortcut. diff --git a/Tests/CodexerCoreTests/ReleaseWorkflowTests.swift b/Tests/CodexerCoreTests/ReleaseWorkflowTests.swift index 076469d..432221f 100644 --- a/Tests/CodexerCoreTests/ReleaseWorkflowTests.swift +++ b/Tests/CodexerCoreTests/ReleaseWorkflowTests.swift @@ -2,6 +2,16 @@ import Foundation import XCTest final class ReleaseWorkflowTests: XCTestCase { + func testReleaseRetentionUsesRealFeedAndReleaseMetadata() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", "-m", "unittest", "discover", "-s", "Tests/ReleaseRetentionTests"] + process.currentDirectoryURL = repositoryRoot + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + } + func testReleaseWorkflowRunsForStableTagsAndDispatchedAlphaTags() throws { let workflow = try String(contentsOf: repositoryRoot .appendingPathComponent(".github/workflows/release.yml"), encoding: .utf8) diff --git a/Tests/ReleaseRetentionTests/test_release_retention.py b/Tests/ReleaseRetentionTests/test_release_retention.py new file mode 100644 index 0000000..2b192ad --- /dev/null +++ b/Tests/ReleaseRetentionTests/test_release_retention.py @@ -0,0 +1,135 @@ +import importlib.util +import base64 +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "script/release_retention.py" +spec = importlib.util.spec_from_file_location("release_retention", SCRIPT) +retention = importlib.util.module_from_spec(spec) +spec.loader.exec_module(retention) +STABLE = "v0.1.26" +OLD_STABLE = "v0.1.25" +ALPHA = "alpha-20261008000000-abcdef123456" +OLD_ALPHA = "alpha-20261007000000-abcdef123456" + + +class ReleaseRetentionTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def feed(self, name, releases): + path = self.root / name + rss = ET.Element("rss") + channel = ET.SubElement(rss, "channel") + for tag, version in releases: + item = ET.SubElement(channel, "item") + ET.SubElement(item, f"{{{retention.SPARKLE}}}version").text = str(version) + if tag.startswith("alpha-"): + ET.SubElement(item, f"{{{retention.SPARKLE}}}channel").text = "alpha" + ET.SubElement(item, "enclosure", url=f"https://github.com/euforic/AgentDock/releases/download/{tag}/AgentDock.zip") + ET.ElementTree(rss).write(path) + return path + + def test_new_stable_removes_older_alpha_and_stable(self): + path = self.feed("stable.xml", [(STABLE, 103), (ALPHA, 102), (OLD_STABLE, 101)]) + retention.prune(path) + self.assertEqual([entry[3] for entry in retention.entries(path)[2]], [STABLE]) + + def test_new_alpha_keeps_only_latest_alpha_and_stable(self): + path = self.feed("alpha.xml", [(ALPHA, 104), (OLD_ALPHA, 103), (STABLE, 102), (OLD_STABLE, 101)]) + retention.prune(path) + self.assertEqual({entry[3] for entry in retention.entries(path)[2]}, {STABLE, ALPHA}) + + def test_superseded_downloads_are_deleted_but_tags_drafts_and_other_releases_are_not(self): + stable = self.feed("stable.xml", [(STABLE, 103)]) + alpha = self.feed("alpha.xml", [(ALPHA, 104), (STABLE, 103)]) + releases = self.root / "releases.json" + pages = [ + [{"id": 1, "tag_name": STABLE, "draft": False}, {"id": 2, "tag_name": ALPHA, "draft": False}], + [{"id": 3, "tag_name": OLD_STABLE, "draft": False}, {"id": 4, "tag_name": OLD_ALPHA, "draft": False}, + {"id": 5, "tag_name": "v0.1.27", "draft": True}, {"id": 6, "tag_name": "preview", "draft": False}], + ] + for page in pages: + for release in page: + release["published_at"] = "2026-10-08T01:00:00Z" if release["tag_name"] == ALPHA else "2026-10-08T00:00:00Z" + releases.write_text(json.dumps(pages)) + result = subprocess.run(["python3", str(SCRIPT), "obsolete", str(releases), str(stable), str(alpha)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "3\n4\n") + + def test_cleanup_fails_closed_for_missing_release_or_inconsistent_feeds(self): + stable = self.feed("stable.xml", [(STABLE, 103)]) + alpha = self.feed("alpha.xml", [(STABLE, 103)]) + releases = self.root / "releases.json" + releases.write_text("[[]]") + with self.assertRaisesRegex(ValueError, "not published"): + retention.obsolete(releases, stable, alpha) + alpha = self.feed("alpha.xml", [(OLD_STABLE, 102)]) + with self.assertRaisesRegex(ValueError, "do not agree"): + retention.obsolete(releases, stable, alpha) + + def test_cleanup_preserves_newer_uploads_whose_feed_publication_failed(self): + stable = self.feed("stable.xml", [(STABLE, 103)]) + alpha = self.feed("alpha.xml", [(STABLE, 103)]) + releases = self.root / "releases.json" + current = {"id": 1, "tag_name": STABLE, "draft": False, "published_at": "2026-10-08T00:00:00Z"} + for tag, message in [("v0.1.27", "Latest Stable"), (ALPHA, "Latest Alpha")]: + newer = {"id": 2, "tag_name": tag, "draft": False, "published_at": "2026-10-08T01:00:00Z"} + releases.write_text(json.dumps([[current, newer]])) + with self.assertRaisesRegex(ValueError, message): + retention.obsolete(releases, stable, alpha) + + def test_pruning_removes_delta_downloads_and_accepts_legacy_build_attributes(self): + path = self.feed("stable.xml", [(STABLE, 103)]) + tree = ET.parse(path) + item = tree.find("channel/item") + item.remove(item.find(f"{{{retention.SPARKLE}}}version")) + item.find("enclosure").set(f"{{{retention.SPARKLE}}}version", "103") + ET.SubElement(item, f"{{{retention.SPARKLE}}}deltas") + tree.write(path) + retention.prune(path) + self.assertEqual(retention.entries(path)[2][0][1], 103) + self.assertIsNone(ET.parse(path).find(f"channel/item/{{{retention.SPARKLE}}}deltas")) + + def test_invalid_metadata_is_rejected(self): + for version in ["bad", "1.2"]: + path = self.feed("bad.xml", [(STABLE, version)]) + with self.assertRaisesRegex(ValueError, "numeric"): + retention.prune(path) + path = self.feed("bad.xml", [(STABLE, 103)]) + tree = ET.parse(path) + tree.find("channel/item/enclosure").set("url", "https://example.com/download.zip") + tree.write(path) + with self.assertRaisesRegex(ValueError, "Unexpected release"): + retention.prune(path) + with self.assertRaisesRegex(ValueError, "requires a Stable"): + retention.prune(self.feed("alpha-only.xml", [(ALPHA, 104)])) + + def test_pruned_feed_can_be_resigned_and_verified_by_sparkle(self): + signer = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" + # Public RFC 8032 test vector; no production key or Keychain access. + key = base64.b64encode(bytes.fromhex( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" + )).decode() + path = self.feed("signed.xml", [(STABLE, 103), (ALPHA, 102)]) + for verify in [False, True]: + command = [str(signer), "--ed-key-file", "-", str(path)] + if verify: + retention.prune(path) + result = subprocess.run(command, input=key, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + command.append("--verify") + result = subprocess.run(command, input=key, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([entry[3] for entry in retention.entries(path)[2]], [STABLE]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/operations.md b/docs/operations.md index fbdbcf0..7e29f6b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,11 +2,9 @@ ## Publication Reset -Previous releases, tags, and update feeds have been withdrawn during repository -privacy cleanup. Downloads and automatic updates remain unavailable until a -new signed release is published. The Alpha trigger is paused in GitHub Actions; -explicitly enable it after reviewing publication readiness. Existing release -signing and feed verification requirements still apply. +Previous releases, tags, and update feeds were withdrawn during repository +privacy cleanup. New releases use the cleaned public source and the existing +release signing and feed verification requirements. The release workflow can create a new signed feed when its public URL returns HTTP 404. An existing feed must pass signature verification before it is used @@ -77,12 +75,16 @@ The tag workflow: artifact before generating an Ed25519-signed appcast; 8. pushes the signed feeds to `gh-pages` only after every earlier gate succeeds; 9. polls each published Pages URL until its bytes exactly match the generated - feed, then verifies its Ed25519 signature again. + feed, then verifies its Ed25519 signature again; +10. removes superseded GitHub Releases and their downloads only after public + feed verification succeeds. Git tags remain available for version checks. Stable clients use `https://gh.euforic.one/AgentDock/appcast.xml`. Alpha clients use `https://gh.euforic.one/AgentDock/appcast-alpha.xml`, which -also retains Stable entries as a fallback. Every Stable release refreshes both -feeds, preserving the latest Alpha entry in the Alpha feed. Alpha subscribers +also retains the latest Stable entry as a fallback. Every Stable release refreshes +both feeds. Each feed retains only the latest Stable and, for the Alpha feed, +the latest Alpha when its numeric build number is newer than Stable. Feed pruning +is followed by re-signing before publication. Alpha subscribers receive a Stable build when its numeric build number is newer than their installed build, without changing their selected channel. Older Stable builds do not replace newer Alpha builds. Stable is the app default and users @@ -103,7 +105,10 @@ AgentDock is maintained in the personal `euforic/AgentDock` repository. CI and release jobs use GitHub-hosted `macos-26` (Apple silicon) and `ubuntu-24.04` runners, so they do not depend on organization runner access. Standard runner minutes are free while this repository is public. Temporary workflow artifacts -expire after one day; GitHub Release assets remain available for downloads. Repository +expire after one day. GitHub Releases retain the latest Stable and an Alpha only +when it is newer than Stable; a newer Stable release removes the superseded Alpha +downloads. Drafts and releases outside the Stable/Alpha tag formats are untouched. +If publication or feed verification fails, cleanup does not run. Repository **Settings → Actions → General** must allow Actions; publication jobs explicitly request `contents: write`. Keep the `release` environment, its signing secret, and its `v*` / `alpha-*` tag deployment policies configured. @@ -195,8 +200,9 @@ curl --fail --silent --show-error \ ## Rollback -GitHub Releases are immutable historical records once downloaded. If a release -is defective, document the issue, fix `main`, and publish a new patch release. +Retained GitHub Release assets are immutable. Superseded releases are removed by +the retention policy after successful feed publication. If a release is +defective, document the issue, fix `main`, and publish a new patch release. Do not silently replace trusted artifacts or move a published tag. Because the appcast is published last, a failed build, notarization, release upload, or appcast-signing job leaves clients on the previous valid feed. diff --git a/script/release_retention.py b/script/release_retention.py new file mode 100644 index 0000000..0ca9dd9 --- /dev/null +++ b/script/release_retention.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Keep the latest Stable and, only when newer, the latest Alpha download.""" + +import argparse +from datetime import datetime +import json +from pathlib import Path +import re +from urllib.parse import urlparse +import xml.etree.ElementTree as ET + +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE) +STABLE_TAG = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+") +ALPHA_TAG = re.compile(r"alpha-[0-9]{14}-[0-9a-f]{7,40}") + + +def entries(path): + tree = ET.parse(path) + channel = tree.find("channel") + if channel is None: + raise ValueError("Appcast has no channel") + result = [] + for item in channel.findall("item"): + enclosure = item.find("enclosure") + if enclosure is None: + raise ValueError("Appcast item has no enclosure") + version = item.findtext(f"{{{SPARKLE}}}version") or enclosure.get(f"{{{SPARKLE}}}version", "") + if not re.fullmatch(r"[0-9]+", version): + raise ValueError("Appcast build number must be numeric") + release_channel = item.findtext(f"{{{SPARKLE}}}channel", "stable") + url = urlparse(enclosure.get("url", "")) + parts = url.path.split("/") + if (url.scheme != "https" or url.netloc != "github.com" or url.query or url.fragment + or len(parts) != 7 or parts[1:5] != ["euforic", "AgentDock", "releases", "download"] + or not parts[6].endswith(".zip")): + raise ValueError("Unexpected release download URL") + tag = parts[5] + pattern = {"stable": STABLE_TAG, "alpha": ALPHA_TAG}.get(release_channel) + if pattern is None or not pattern.fullmatch(tag): + raise ValueError("Release tag does not match its channel") + result.append((item, int(version), release_channel, tag)) + if not result: + raise ValueError("Appcast has no releases") + return tree, channel, result + + +def retained(entries): + stable = max((entry for entry in entries if entry[2] == "stable"), key=lambda e: e[1], default=None) + alpha = max((entry for entry in entries if entry[2] == "alpha"), key=lambda e: e[1], default=None) + if stable is None: + raise ValueError("Retention requires a Stable release") + return [stable] + ([alpha] if alpha and alpha[1] > stable[1] else []) + + +def prune(path): + tree, channel, items = entries(path) + keep = {entry[0] for entry in retained(items)} + for item, _, _, _ in items: + if item not in keep: + channel.remove(item) + else: + for deltas in item.findall(f"{{{SPARKLE}}}deltas"): + item.remove(deltas) + # The caller must re-sign the modified feed before publication. + tree.write(path, encoding="utf-8", xml_declaration=True) + + +def obsolete(releases_path, stable_path, alpha_path): + stable_items = entries(stable_path)[2] + alpha_items = entries(alpha_path)[2] + if len(stable_items) != 1 or stable_items[0][2] != "stable": + raise ValueError("Stable feed must contain exactly one Stable release") + keep = retained(alpha_items) + if len(keep) != len(alpha_items) or keep[0][3] != stable_items[0][3]: + raise ValueError("Feeds do not agree on release retention") + keep_tags = {entry[3] for entry in keep} + pages = json.loads(Path(releases_path).read_text()) + releases = [release for page in pages for release in page] + published_tags = {r["tag_name"] for r in releases if not r["draft"]} + if not keep_tags <= published_tags: + raise ValueError("A retained release is not published; refusing cleanup") + stable_releases = [r for r in releases if not r["draft"] and STABLE_TAG.fullmatch(r["tag_name"])] + latest_stable = max(stable_releases, key=lambda r: tuple(map(int, r["tag_name"][1:].split(".")))) + if latest_stable["tag_name"] != keep[0][3]: + raise ValueError("Latest Stable release is absent from the feeds; refusing cleanup") + alpha_releases = [r for r in releases if not r["draft"] and ALPHA_TAG.fullmatch(r["tag_name"])] + published_at = lambda r: datetime.fromisoformat(r["published_at"].replace("Z", "+00:00")) + latest_alpha = max(alpha_releases, key=published_at, default=None) + expected_alpha = latest_alpha if latest_alpha and published_at(latest_alpha) > published_at(latest_stable) else None + feed_alpha = next((entry[3] for entry in keep if entry[2] == "alpha"), None) + if feed_alpha != (expected_alpha["tag_name"] if expected_alpha else None): + raise ValueError("Latest Alpha release disagrees with the feeds; refusing cleanup") + for release in releases: + tag = release["tag_name"] + if not release["draft"] and (STABLE_TAG.fullmatch(tag) or ALPHA_TAG.fullmatch(tag)) and tag not in keep_tags: + print(int(release["id"])) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("prune").add_argument("appcast") + cleanup = commands.add_parser("obsolete") + cleanup.add_argument("releases") + cleanup.add_argument("stable") + cleanup.add_argument("alpha") + args = parser.parse_args() + try: + if args.command == "prune": + prune(args.appcast) + else: + obsolete(args.releases, args.stable, args.alpha) + except (ValueError, KeyError, ET.ParseError) as error: + parser.exit(1, f"Release retention failed: {error}\n") + + +if __name__ == "__main__": + main()