Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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\"" \
Expand All @@ -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
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.build/
__pycache__/
dist/
.DS_Store
artifacts/
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Tests/CodexerCoreTests/ReleaseWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions Tests/ReleaseRetentionTests/test_release_retention.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 17 additions & 11 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Loading
Loading