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()