From 1416c03cd46cba936ae1871aec33558e1e4415a4 Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Fri, 16 Oct 2026 15:11:49 +0700
Subject: [PATCH] ci(release): stop building Sparkle deltas
---
.github/workflows/build.yml | 14 +--
CHANGELOG.md | 2 +-
RELEASING.md | 9 +-
appcast.xml | 14 ---
scripts/ci/appcast_feed.py | 7 +-
scripts/ci/fetch-delta-bases.sh | 49 ----------
scripts/ci/merge-appcast.py | 11 +--
scripts/ci/name-deltas.py | 109 -----------------------
scripts/ci/sign-and-appcast.sh | 69 ++------------
scripts/ci/test_merge_appcast.py | 46 +---------
scripts/ci/test_name_deltas.py | 148 -------------------------------
11 files changed, 20 insertions(+), 458 deletions(-)
delete mode 100755 scripts/ci/fetch-delta-bases.sh
delete mode 100755 scripts/ci/name-deltas.py
delete mode 100755 scripts/ci/test_name_deltas.py
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 45e394b4c6..f77343fafc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -212,8 +212,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
# Holds APPCAST_DEPLOY_KEY, released only to v* tags.
environment: release
- # Ten deltas at about a minute of CPU each, built two at a time.
- timeout-minutes: 30
+ timeout-minutes: 10
permissions:
contents: write
@@ -369,13 +368,6 @@ jobs:
fi
echo "::warning::This release is marked critical and will interrupt every user"
- # A step of its own, so the token that reads releases never shares an environment with the
- # signing key.
- - name: Fetch earlier update archives for deltas
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: scripts/ci/fetch-delta-bases.sh "${GITHUB_REF#refs/tags/}" 5 delta-bases
-
- name: Sign update archives with Sparkle
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
@@ -408,10 +400,8 @@ jobs:
PRERELEASE: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-rc') }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
- # A first release has nothing to build deltas from.
- shopt -s nullglob
scripts/ci/draft-release.sh --prerelease "$PRERELEASE" "$TAG" "$TAG" release_notes.md \
- artifacts/*.dmg artifacts/*.tar.xz artifacts/*.delta
+ artifacts/*.dmg artifacts/*.tar.xz
- name: Publish the release
if: steps.draft.outputs.id != ''
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a3ab8e3b9..a1c23d154a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,7 +45,7 @@ Plugins signed by other developers stop loading, and HTML and XML export are rem
- tablepro command ships inside the app, linked from a Terminal line in Settings > General without an admin prompt. (#3372)
- MCP server runs only while Enable MCP Server is on in Settings > MCP, including for clients and pairing. (#3368)
- Edit > Data shown only in data file windows. (#3364)
-- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB. (#3363)
+- Smaller updates: 20 MB on Apple silicon and 23 MB on Intel, down from 32 MB and 35 MB. (#3363)
- PostgreSQL on iPhone and iPad commits several statements one at a time, keeping the ones before a failure. (#3399, #3404)
- MySQL dumps write TIMESTAMP values in UTC and turn off foreign key checks while they load, as mysqldump does. (#3427)
- AI chat code blocks show all their code in the editor font and colors, without line numbers. (#3432)
diff --git a/RELEASING.md b/RELEASING.md
index d5b16bcb55..65678774c8 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -93,11 +93,10 @@ ref, so a dispatch from a branch only builds. The tag runs no test suite.
the tag disagrees with `MARKETING_VERSION` (`tag v... does not match MARKETING_VERSION ...`).
- It also fails when `CURRENT_PROJECT_VERSION` is not above the newest published release's. Sparkle
compares that number as ``, so a flat one ships a release no install is offered.
-- It signs both update archives with the Sparkle key, builds deltas from the previous five
- releases' archives, and takes the notes from `CHANGELOG.md`.
-- It checks that the appcast deploy key works before publishing anything. Then it puts both DMGs,
- both update archives and the deltas on a draft release, publishes the draft in one step, and only
- then commits `appcast.xml`, whose download URLs 404 until the release is public.
+- It signs both update archives with the Sparkle key and takes the notes from `CHANGELOG.md`.
+- It checks that the appcast deploy key works before publishing anything. Then it puts both DMGs
+ and both update archives on a draft release, publishes the draft in one step, and only then
+ commits `appcast.xml`, whose download URLs 404 until the release is public.
- The appcast is merged with the tag's own copy of `scripts/ci/merge-appcast.py` and pushed with
`APPCAST_DEPLOY_KEY`, up to 3 attempts, each re-merging onto the current `origin/main`.
- `generate_appcast` sees only the new release. The merge script splices its items in and moves
diff --git a/appcast.xml b/appcast.xml
index 209544bcac..17a7157bbb 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -24,13 +24,6 @@ Plugins signed by other developers stop loading, and HTML and XML export are rem
[View full changelog](https://docs.tablepro.app/changelog)
]]>
-
-
-
-
-
-
-
-
0.81.0
@@ -53,13 +46,6 @@ Plugins signed by other developers stop loading, and HTML and XML export are rem
[View full changelog](https://docs.tablepro.app/changelog)
]]>
-
-
-
-
-
-
-
-
0.80.0
diff --git a/scripts/ci/appcast_feed.py b/scripts/ci/appcast_feed.py
index 73353e134c..0aba8980e4 100644
--- a/scripts/ci/appcast_feed.py
+++ b/scripts/ci/appcast_feed.py
@@ -19,7 +19,6 @@
SHORT_VERSION = f"{{{SPARKLE_NS}}}shortVersionString"
BUNDLE_VERSION = f"{{{SPARKLE_NS}}}version"
HARDWARE = f"{{{SPARKLE_NS}}}hardwareRequirements"
-DELTAS = f"{{{SPARKLE_NS}}}deltas"
ED_SIGNATURE = f"{{{SPARKLE_NS}}}edSignature"
@@ -66,11 +65,7 @@ def item_bundle_version(item):
def item_enclosures(item):
- enclosures = list(item.findall("enclosure"))
- deltas = item.find(DELTAS)
- if deltas is not None:
- enclosures.extend(deltas.findall("enclosure"))
- return enclosures
+ return list(item.findall("enclosure"))
SKIPPABLE = (("", "CDATA section"), ("", "comment"))
diff --git a/scripts/ci/fetch-delta-bases.sh b/scripts/ci/fetch-delta-bases.sh
deleted file mode 100755
index f2a4db0027..0000000000
--- a/scripts/ci/fetch-delta-bases.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/usr/bin/env bash
-#
-# Downloads the update archives of the newest published releases other than , into
-# /arm64 and /x86_64, for sign-and-appcast.sh to build deltas from.
-#
-# Usage: fetch-delta-bases.sh
-
-set -euo pipefail
-
-TAG="${1:?Usage: fetch-delta-bases.sh }"
-COUNT="${2:?Usage: fetch-delta-bases.sh }"
-DEST="${3:?Usage: fetch-delta-bases.sh }"
-
-case "$COUNT" in
- '' | *[!0-9]*)
- echo "::error::count '$COUNT' is not a number"
- exit 2
- ;;
-esac
-
-export GH_REPO="${GH_REPO:-${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}}"
-
-mkdir -p "$DEST/arm64" "$DEST/x86_64"
-
-# Plugin releases share the list, so the limit has to reach past a bulk plugin release.
-TAGS="$(gh release list --limit 400 --exclude-drafts --json tagName,isPrerelease,publishedAt \
- -q '[.[] | select(.tagName|startswith("v")) | select(.isPrerelease==false)]
- | sort_by(.publishedAt) | reverse | .[].tagName')"
-
-PICKED=0
-while IFS= read -r tag; do
- if [ -z "$tag" ] || [ "$tag" = "$TAG" ]; then
- continue
- fi
- if [ "$PICKED" -ge "$COUNT" ]; then
- break
- fi
- # Releases before the switch to tar.xz carry a zip.
- for arch in arm64 x86_64; do
- gh release download "$tag" --dir "$DEST/$arch" \
- --pattern "TablePro-${tag#v}-${arch}.tar.xz" --pattern "TablePro-${tag#v}-${arch}.zip"
- done
- PICKED=$((PICKED + 1))
-done <` when the staged archive has no sibling notes
@@ -89,7 +88,6 @@ def check_new_items(arm64_item, x86_64_item, version, download_prefix):
f"arm64 {arm64_bundle!r}, x86_64 {x86_64_bundle!r}"
)
- advertised = {}
for label, item in (("arm64", arm64_item), ("x86_64", x86_64_item)):
enclosures = item_enclosures(item)
if not enclosures:
@@ -100,11 +98,6 @@ def check_new_items(arm64_item, x86_64_item, version, download_prefix):
raise FeedError(
f"the {label} item advertises {url!r}, which is not under {download_prefix!r}"
)
- # One release directory holds both architectures, so a shared name is one asset
- # serving two different files.
- if url in advertised:
- raise FeedError(f"{url!r} is advertised twice, by the {advertised[url]} and {label} items")
- advertised[url] = label
# generate_appcast signs an archive only when the app's own Info.plist declares
# SUPublicEDKey (Appcast.swift:199 in 2.9.5). Drop that key and it writes an unsigned
# enclosure, reports "Wrote 1 new update" and exits 0, and every installed updater
diff --git a/scripts/ci/name-deltas.py b/scripts/ci/name-deltas.py
deleted file mode 100755
index baab333132..0000000000
--- a/scripts/ci/name-deltas.py
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env python3
-"""Renames the deltas generate_appcast wrote for one architecture and moves them out for upload.
-
-generate_appcast names a delta after the bundle and the two build numbers (Appcast.swift:261 in
-2.10.0). Both architectures share a build number, so without the suffix their deltas collide as
-release assets. It drops a delta larger than 7/8 of the full archive, or one it failed to build,
-and still exits 0, so a shortfall against the staged archives is reported as a warning.
-
-Run: python3 scripts/ci/name-deltas.py --staging --version 0.81.0 --arch arm64 \
- --download-prefix https://.../download/v0.81.0/ --expect 5 --out-dir artifacts
-"""
-
-import argparse
-import pathlib
-import shutil
-import sys
-import urllib.parse
-from xml.sax.saxutils import quoteattr
-
-sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
-
-from appcast_feed import ( # noqa: E402
- DELTAS,
- SPARKLE_NS,
- FeedError,
- channel_items,
- item_short_version,
- parse_text,
- read,
-)
-
-DELTA_FROM = f"{{{SPARKLE_NS}}}deltaFrom"
-
-
-def delta_name(version, delta_from, arch):
- return f"TablePro-{version}-from-{delta_from}-{arch}.delta"
-
-
-def name_deltas(staging, version, arch, download_prefix, out_dir):
- staging = pathlib.Path(staging)
- out_dir = pathlib.Path(out_dir)
- appcast = staging / "appcast.xml"
- text = read(appcast)
-
- items = channel_items(parse_text(text), appcast)
- if len(items) != 1 or item_short_version(items[0]) != version:
- raise FeedError(f"{appcast}: expected exactly one item, for {version}")
- deltas = items[0].find(DELTAS)
- enclosures = [] if deltas is None else deltas.findall("enclosure")
-
- moves = []
- for enclosure in enclosures:
- url = enclosure.get("url", "")
- delta_from = enclosure.get(DELTA_FROM)
- if not url.startswith(download_prefix):
- raise FeedError(f"the {arch} delta {url!r} is not under {download_prefix!r}")
- if not delta_from:
- raise FeedError(f"the {arch} delta {url!r} carries no sparkle:deltaFrom")
- source = staging / urllib.parse.unquote(url[len(download_prefix):])
- if not source.is_file():
- raise FeedError(f"the {arch} feed advertises {source.name}, which generate_appcast did not write")
- target = out_dir / delta_name(version, delta_from, arch)
- if target.exists() or target in (move[1] for move in moves):
- raise FeedError(f"{target} would be written twice")
-
- attribute = f"url={quoteattr(url)}"
- if text.count(attribute) != 1:
- raise FeedError(f"{appcast}: {url!r} does not appear exactly once")
- text = text.replace(attribute, f"url={quoteattr(download_prefix + target.name)}")
- moves.append((source, target))
-
- out_dir.mkdir(parents=True, exist_ok=True)
- for source, target in moves:
- # The staging directory is under $TMPDIR, which need not share a volume with the output.
- shutil.move(source, target)
- appcast.write_text(text, encoding="utf-8")
- return [target for _, target in moves]
-
-
-def main(argv=None):
- parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
- parser.add_argument("--staging", required=True, help="the directory generate_appcast ran in")
- parser.add_argument("--version", required=True, help="marketing version being released")
- parser.add_argument("--arch", required=True, choices=("arm64", "x86_64"))
- parser.add_argument("--download-prefix", required=True, help="every delta URL must start with this")
- parser.add_argument("--expect", required=True, type=int, help="how many earlier archives were staged")
- parser.add_argument("--out-dir", required=True, help="where the renamed deltas go for upload")
- args = parser.parse_args(argv)
-
- try:
- moved = name_deltas(args.staging, args.version, args.arch, args.download_prefix, args.out_dir)
- except (FeedError, OSError) as error:
- print(f"::error::name-deltas: {error}", file=sys.stderr)
- return 1
- for target in moved:
- print(f"{args.arch} delta: {target}")
- if len(moved) > args.expect:
- print(f"::error::name-deltas: {len(moved)} {args.arch} deltas from {args.expect} staged releases", file=sys.stderr)
- return 1
- if len(moved) < args.expect:
- print(
- f"::warning::generate_appcast wrote {len(moved)} of {args.expect} {args.arch} deltas; "
- "the rest download the full archive. Its log says which it skipped."
- )
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/ci/sign-and-appcast.sh b/scripts/ci/sign-and-appcast.sh
index 99bd8ebb92..68cc314b89 100755
--- a/scripts/ci/sign-and-appcast.sh
+++ b/scripts/ci/sign-and-appcast.sh
@@ -4,13 +4,12 @@ set -euo pipefail
# Signs release archives and publishes them into appcast.xml using Sparkle's generate_appcast,
# the official tool for building Sparkle update feeds.
#
-# generate_appcast sees the release being published plus the archives of earlier releases it builds
-# deltas from, and writes one item. It is never handed the existing feed, and it never rewrites an
-# item it did not create. Everything already published is merged in afterwards by
-# scripts/ci/merge-appcast.py, which splices text rather than re-serializing. The reason is in that
-# script's docstring: two writes in Sparkle's FeedXML.swift run outside its `if createNewItem`
-# guard, so a previously published item that a staged archive happens to match has its download URL
-# rewritten and its release notes dropped.
+# generate_appcast only ever sees the release being published. It is never handed the existing
+# feed, and it never rewrites an item it did not create. Everything already published is merged
+# in afterwards by scripts/ci/merge-appcast.py, which splices text rather than re-serializing.
+# The reason is in that script's docstring: two writes in Sparkle's FeedXML.swift run outside its
+# `if createNewItem` guard, so a previously published item that a staged archive happens to match
+# has its download URL rewritten and its release notes dropped.
#
# generate_appcast rejects two archives sharing a bundle version in one directory
# (Unarchive.swift:96, SUSparkleErrorDomain 1002, reproduced against 2.9.5 and 2.10.0), so each
@@ -18,13 +17,11 @@ set -euo pipefail
#
# Usage: sign-and-appcast.sh
# Requires: SPARKLE_PRIVATE_KEY env var, artifacts/ directory with both architectures' tar.xz
-# archives, and $DELTA_BASES// holding the earlier archives (fetch-delta-bases.sh), which may
-# be empty.
+# archives.
# Optional: CRITICAL_UPDATE=1 to mark the release critical.
BASE_APPCAST="${BASE_APPCAST:-appcast.xml}"
CRITICAL_UPDATE="${CRITICAL_UPDATE:-0}"
-DELTA_BASES="${DELTA_BASES:-delta-bases}"
VERSION="${1:?Usage: sign-and-appcast.sh }"
if [ -z "${SPARKLE_PRIVATE_KEY:-}" ]; then
@@ -37,13 +34,6 @@ if [ ! -f "$BASE_APPCAST" ]; then
exit 1
fi
-for arch in arm64 x86_64; do
- if [ ! -d "$DELTA_BASES/$arch" ]; then
- echo "::error::$DELTA_BASES/$arch does not exist; run scripts/ci/fetch-delta-bases.sh first"
- exit 1
- fi
-done
-
# ---------------------------------------------------------------------------
# 1. Extract the notes
# ---------------------------------------------------------------------------
@@ -88,8 +78,6 @@ generate_item() {
local arch="$1"
local archive="artifacts/TablePro-${VERSION}-${arch}.tar.xz"
local staging="$STAGING_ROOT/$arch"
- local bases=0
- local base
if [ ! -f "$archive" ]; then
echo "::error::$archive is missing, so the $arch build produced no update archive"
@@ -98,12 +86,6 @@ generate_item() {
mkdir -p "$staging"
cp "$archive" "$staging/"
- for base in "$DELTA_BASES/$arch"/*; do
- if [ -f "$base" ]; then
- cp "$base" "$staging/"
- bases=$((bases + 1))
- fi
- done
# Sparkle 2.9 renders Markdown natively, including code and links. Feeding hand-built HTML
# left Markdown visible and interpreted literal SQL/XML angle brackets as HTML tags.
@@ -131,14 +113,13 @@ generate_item() {
fi
# --maximum-versions 1 states the invariant merge-appcast.py checks rather than leaving it as a
- # consequence of the directory holding one new archive. The earlier archives only feed deltas.
+ # consequence of the directory holding one archive.
"$SPARKLE_BIN/generate_appcast" \
--ed-key-file "$KEY_FILE" \
--download-url-prefix "$DOWNLOAD_PREFIX" \
--embed-release-notes \
--full-release-notes-url "https://docs.tablepro.app/changelog" \
--maximum-versions 1 \
- --maximum-deltas "$bases" \
"${flags[@]}" \
"$staging"
@@ -146,43 +127,11 @@ generate_item() {
echo "::error::generate_appcast produced no feed for $arch"
return 1
fi
-
- python3 "$(dirname "$0")/name-deltas.py" \
- --staging "$staging" \
- --version "$VERSION" \
- --arch "$arch" \
- --download-prefix "$DOWNLOAD_PREFIX" \
- --expect "$bases" \
- --out-dir artifacts
}
-# Building a delta takes about a minute and generate_appcast builds them one at a time, so the two
-# architectures run side by side. bash 3.2 on the runners has no associative arrays.
-generate_item arm64 > "$STAGING_ROOT/arm64.log" 2>&1 &
-ARM64_PID=$!
-generate_item x86_64 > "$STAGING_ROOT/x86_64.log" 2>&1 &
-X86_64_PID=$!
-
-FAILED=0
for arch in arm64 x86_64; do
- if [ "$arch" = "arm64" ]; then
- pid="$ARM64_PID"
- else
- pid="$X86_64_PID"
- fi
- status=0
- wait "$pid" || status=$?
- echo "::group::generate_appcast $arch"
- cat "$STAGING_ROOT/$arch.log"
- echo "::endgroup::"
- if [ "$status" -ne 0 ]; then
- echo "::error::the $arch item could not be generated (exit $status)"
- FAILED=1
- fi
+ generate_item "$arch"
done
-if [ "$FAILED" -ne 0 ]; then
- exit 1
-fi
# ---------------------------------------------------------------------------
# 4. Keep both signed items, and merge once here as a gate
diff --git a/scripts/ci/test_merge_appcast.py b/scripts/ci/test_merge_appcast.py
index 28aae4fc3b..5f04348fb7 100755
--- a/scripts/ci/test_merge_appcast.py
+++ b/scripts/ci/test_merge_appcast.py
@@ -26,7 +26,7 @@
PREFIX = "https://github.com/TableProApp/TablePro/releases/download/v0.75.0/"
-def item(version, build, arch, notes="Fixed a thing.", deltas=(), prefix=None):
+def item(version, build, arch, notes="Fixed a thing.", prefix=None):
prefix = PREFIX if prefix is None else prefix
hardware = (
f" arch\n".replace(
@@ -35,14 +35,6 @@ def item(version, build, arch, notes="Fixed a thing.", deltas=(), prefix=None):
if arch
else ""
)
- delta_block = ""
- if deltas:
- enclosures = "\n".join(
- f' '
- for url in deltas
- )
- delta_block = f" \n{enclosures}\n \n"
suffix = f"-{arch}" if arch else "-x86_64"
return (
"
- \n"
@@ -53,7 +45,6 @@ def item(version, build, arch, notes="Fixed a thing.", deltas=(), prefix=None):
" 14.0\n"
f"{hardware}"
f" {notes}
]]>\n"
- f"{delta_block}"
f' \n'
" \n"
@@ -174,46 +165,11 @@ def test_refuses_an_enclosure_outside_the_release_directory(self):
stray = feed(item("0.75.0", 131, "arm64")).replace(PREFIX, PREFIX.replace("v0.75.0", "v0.74.0"))
self.assertRefused("is not under", arm64=stray)
- def test_refuses_a_delta_enclosure_outside_the_release_directory(self):
- strayed = feed(
- item("0.75.0", 131, "arm64", deltas=[PREFIX.replace("v0.75.0", "v0.74.0") + "d.delta"])
- )
- self.assertRefused("is not under", arm64=strayed)
-
- def test_accepts_a_delta_enclosure_inside_the_release_directory(self):
- merged = self.merge(
- arm64=feed(item("0.75.0", 131, "arm64", deltas=[PREFIX + "TablePro0.74.0-0.75.0.delta"]))
- )
- self.assertIn("sparkle:deltas", merged)
-
- def test_keeps_both_architectures_deltas_byte_for_byte(self):
- arm64 = item("0.75.0", 131, "arm64", deltas=[PREFIX + "TablePro-0.75.0-from-130-arm64.delta", PREFIX + "TablePro-0.75.0-from-129-arm64.delta"])
- x86_64 = item("0.75.0", 131, None, deltas=[PREFIX + "TablePro-0.75.0-from-130-x86_64.delta", PREFIX + "TablePro-0.75.0-from-129-x86_64.delta"])
- merged = self.merge(arm64=feed(arm64), x86_64=feed(x86_64))
- self.assertIn(arm64 + x86_64, merged)
- items = merge_appcast.channel_items(merge_appcast.parse_text(merged), "merged")
- for new_item in items[:2]:
- self.assertEqual(len(new_item.find(appcast_feed.DELTAS).findall("enclosure")), 2)
-
- def test_refuses_a_delta_both_architectures_advertise(self):
- shared = PREFIX + "TablePro131-130.delta"
- self.assertRefused(
- "is advertised twice",
- arm64=feed(item("0.75.0", 131, "arm64", deltas=[shared])),
- x86_64=feed(item("0.75.0", 131, None, deltas=[shared])),
- )
-
def test_refuses_an_unsigned_enclosure(self):
"""generate_appcast drops the signature and still exits 0 when SUPublicEDKey is absent."""
unsigned = feed(item("0.75.0", 131, "arm64")).replace(' sparkle:edSignature="sig=="', "")
self.assertRefused("carries no sparkle:edSignature", arm64=unsigned)
- def test_refuses_an_unsigned_delta_enclosure(self):
- strayed = feed(item("0.75.0", 131, "arm64", deltas=[PREFIX + "d.delta"])).replace(
- ' sparkle:edSignature="dsig=="', ""
- )
- self.assertRefused("carries no sparkle:edSignature", arm64=strayed)
-
def test_refuses_more_than_one_item_per_architecture(self):
two = feed(item("0.75.0", 131, "arm64"), item("0.74.0", 130, "arm64"))
self.assertRefused("expected exactly one item", arm64=two)
diff --git a/scripts/ci/test_name_deltas.py b/scripts/ci/test_name_deltas.py
deleted file mode 100755
index e92fbd0d37..0000000000
--- a/scripts/ci/test_name_deltas.py
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/usr/bin/env python3
-"""Regression tests for the delta renaming in sign-and-appcast.sh.
-
-Run: python3 scripts/ci/test_name_deltas.py
-"""
-
-import contextlib
-import importlib.util
-import io
-import pathlib
-import sys
-import tempfile
-import unittest
-
-sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
-SPEC = importlib.util.spec_from_file_location("name_deltas", pathlib.Path(__file__).with_name("name-deltas.py"))
-name_deltas = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(name_deltas)
-
-import appcast_feed # noqa: E402
-
-PREFIX = "https://github.com/TableProApp/TablePro/releases/download/v0.81.0/"
-
-
-def generated(deltas, arch="arm64", prefix=PREFIX):
- """The shape generate_appcast 2.10.0 writes: the bundle's directory name plus both builds."""
- enclosures = "".join(
- f' \n'
- for build in deltas
- )
- delta_block = f" \n{enclosures} \n" if deltas else ""
- return (
- '\n'
- '\n'
- " \n"
- " TablePro\n"
- " - \n"
- " 0.81.0\n"
- " 140\n"
- " 0.81.0\n"
- f"{delta_block}"
- f' \n'
- "
\n"
- " \n"
- "\n"
- )
-
-
-class NameDeltasTests(unittest.TestCase):
- def setUp(self):
- self.directory = tempfile.TemporaryDirectory()
- self.addCleanup(self.directory.cleanup)
- self.staging = pathlib.Path(self.directory.name) / "staging"
- self.out = pathlib.Path(self.directory.name) / "artifacts"
- self.staging.mkdir()
-
- def stage(self, builds, arch="arm64", files=None):
- (self.staging / "appcast.xml").write_text(generated(builds, arch), encoding="utf-8")
- for build in builds if files is None else files:
- (self.staging / f"TablePro-{arch}140-{build}.delta").write_bytes(f"delta {build}".encode())
-
- def run_names(self, arch="arm64"):
- return name_deltas.name_deltas(self.staging, "0.81.0", arch, PREFIX, self.out)
-
- def run_main(self, expect):
- output = io.StringIO()
- argv = [
- "--staging", str(self.staging), "--version", "0.81.0", "--arch", "arm64",
- "--download-prefix", PREFIX, "--expect", str(expect), "--out-dir", str(self.out),
- ]
- with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
- status = name_deltas.main(argv)
- return status, output.getvalue()
-
- def assertRefused(self, fragment, arch="arm64"):
- with self.assertRaises(appcast_feed.FeedError) as caught:
- self.run_names(arch)
- self.assertIn(fragment, str(caught.exception))
-
- def test_suffixes_each_delta_with_its_architecture_and_moves_it(self):
- self.stage([139, 138])
- self.run_names()
- self.assertEqual(
- sorted(path.name for path in self.out.iterdir()),
- ["TablePro-0.81.0-from-138-arm64.delta", "TablePro-0.81.0-from-139-arm64.delta"],
- )
- self.assertEqual((self.out / "TablePro-0.81.0-from-139-arm64.delta").read_bytes(), b"delta 139")
- self.assertEqual(list(self.staging.glob("*.delta")), [])
-
- def test_rewrites_only_the_delta_urls(self):
- self.stage([139])
- self.run_names()
- text = (self.staging / "appcast.xml").read_text(encoding="utf-8")
- expected = generated([139]).replace(
- f"{PREFIX}TablePro-arm64140-139.delta", f"{PREFIX}TablePro-0.81.0-from-139-arm64.delta"
- )
- self.assertEqual(text, expected)
-
- def test_architectures_never_share_a_name(self):
- self.stage([139], arch="arm64")
- arm64 = self.run_names(arch="arm64")
- self.stage([139], arch="x86_64")
- x86_64 = self.run_names(arch="x86_64")
- self.assertNotEqual(arm64[0].name, x86_64[0].name)
- self.assertEqual(len(list(self.out.iterdir())), 2)
-
- def test_warns_when_a_staged_release_got_no_delta(self):
- """generate_appcast drops a delta over 7/8 of the full archive and still exits 0."""
- self.stage([139])
- status, output = self.run_main(2)
- self.assertEqual(status, 0)
- self.assertIn("::warning::generate_appcast wrote 1 of 2 arm64 deltas", output)
- self.assertTrue((self.out / "TablePro-0.81.0-from-139-arm64.delta").is_file())
-
- def test_refuses_more_deltas_than_staged_releases(self):
- self.stage([139, 138])
- status, output = self.run_main(1)
- self.assertEqual(status, 1)
- self.assertIn("::error::", output)
-
- def test_refuses_a_delta_the_feed_names_but_the_directory_lacks(self):
- self.stage([139, 138], files=[139])
- self.assertRefused("did not write")
- self.assertFalse(self.out.exists())
-
- def test_refuses_a_delta_outside_the_release_directory(self):
- stray = generated([139]).replace(PREFIX + "TablePro-arm64", PREFIX.replace("v0.81.0", "v0.80.0") + "TablePro-arm64")
- (self.staging / "appcast.xml").write_text(stray, encoding="utf-8")
- self.assertRefused("is not under")
-
- def test_refuses_a_delta_without_a_source_build(self):
- self.stage([139])
- path = self.staging / "appcast.xml"
- path.write_text(path.read_text(encoding="utf-8").replace(' sparkle:deltaFrom="139"', ""), encoding="utf-8")
- self.assertRefused("carries no sparkle:deltaFrom")
-
- def test_nothing_staged_leaves_the_feed_alone(self):
- self.stage([])
- self.assertEqual(self.run_main(0), (0, ""))
- self.assertEqual((self.staging / "appcast.xml").read_text(encoding="utf-8"), generated([]))
-
-
-if __name__ == "__main__":
- unittest.main()