diff --git a/.github/scripts/test_update_registry.py b/.github/scripts/test_update_registry.py index 62357803e..2a244a542 100644 --- a/.github/scripts/test_update_registry.py +++ b/.github/scripts/test_update_registry.py @@ -83,9 +83,99 @@ class Args: assert all(update_registry.kit_version(b) is not None for b in entry["binaries"]) +def _args(version, pkv, keep=2): + class Args: + id = "com.TablePro.DynamoDBDriverPlugin" + name = "DynamoDB" + summary = "new" + db_type_ids = '["dynamodb"]' + arm64_url = "https://x/arm64" + arm64_sha = "a" + x86_64_url = "https://x/x86_64" + x86_64_sha = "b" + min_app_version = "0.43.0" + icon = "icon" + homepage = "https://tablepro.app" + category = "database-driver" + + Args.version = version + Args.plugin_kit_version = pkv + Args.keep_kit_versions = keep + return Args + + +def _manifest(*kit_versions): + return { + "schemaVersion": 2, + "plugins": [ + { + "id": "com.TablePro.DynamoDBDriverPlugin", + "name": "DynamoDB", + "version": "1.0.15", + "summary": "old", + "category": "database-driver", + "binaries": [ + { + "architecture": arch, + "pluginKitVersion": pkv, + "downloadURL": f"old-{pkv}-{arch}", + "sha256": "x", + } + for pkv in kit_versions + for arch in ("arm64", "x86_64") + ], + } + ], + } + + +def test_publishing_a_new_kit_version_keeps_the_previous_one(): + """The retention policy's whole point: a user on the previous app can still install. + + Nothing covered this. The one existing merge test starts from a null-version binary, which + is dropped rather than retained, so the surviving-binary path was never exercised. + """ + result = update_registry.update_plugin_entry(_manifest(14), _args("1.0.16", 15)) + entry = next(p for p in result["plugins"] if p["id"] == "com.TablePro.DynamoDBDriverPlugin") + kits = sorted(update_registry.kit_version(b) for b in entry["binaries"]) + assert kits == [14, 14, 15, 15], kits + urls = {b["downloadURL"] for b in entry["binaries"]} + assert "old-14-arm64" in urls, urls + + +def test_a_third_kit_version_evicts_the_oldest(): + result = update_registry.update_plugin_entry(_manifest(13, 14), _args("1.0.17", 15)) + entry = next(p for p in result["plugins"] if p["id"] == "com.TablePro.DynamoDBDriverPlugin") + kits = sorted(update_registry.kit_version(b) for b in entry["binaries"]) + assert kits == [14, 14, 15, 15], kits + + +def test_republishing_the_same_kit_version_replaces_its_binaries(): + result = update_registry.update_plugin_entry(_manifest(14), _args("1.0.16", 14)) + entry = next(p for p in result["plugins"] if p["id"] == "com.TablePro.DynamoDBDriverPlugin") + kits = sorted(update_registry.kit_version(b) for b in entry["binaries"]) + assert kits == [14, 14], kits + urls = {b["downloadURL"] for b in entry["binaries"]} + assert urls == {"https://x/arm64", "https://x/x86_64"}, urls + + +def test_publishing_below_the_retained_window_refuses(): + """Otherwise the entry advertises a version whose binary the prune just discarded.""" + try: + update_registry.update_plugin_entry(_manifest(15, 16), _args("1.0.18", 14)) + except SystemExit as error: + assert "older than the" in str(error), error + else: + raise AssertionError("expected SystemExit when the new binary would be pruned away") + + if __name__ == "__main__": test_kit_version_rejects_non_int() test_prune_drops_null_kit_binary() test_prune_keeps_only_two_newest() test_update_entry_drops_legacy_null_binary() + test_publishing_a_new_kit_version_keeps_the_previous_one() + test_a_third_kit_version_evicts_the_oldest() + test_republishing_the_same_kit_version_replaces_its_binaries() + test_publishing_below_the_retained_window_refuses() print("All update-registry tests passed.") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32ab36a0a..51d91976e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -229,17 +229,24 @@ jobs: echo "Contents of artifacts directory:" ls -la artifacts/ - # Note: DMG files should already have correct names from build - # ZIP files need to be renamed - - # Rename ZIP files if they exist - if [ -f "artifacts/TablePro-arm64.zip" ]; then - mv artifacts/TablePro-arm64.zip "artifacts/TablePro-${VERSION}-arm64.zip" - fi + # The DMGs are already versioned by package-artifacts.sh; the ZIPs are not. + # Both renames used to be guarded by `if [ -f ... ]` with no else, so a missing or + # differently named ZIP printed "organized successfully" and the release published + # whatever the upload glob happened to match. + for ARCH in arm64 x86_64; do + if [ ! -f "artifacts/TablePro-${ARCH}.zip" ]; then + echo "::error::artifacts/TablePro-${ARCH}.zip is missing; the ${ARCH} build did not produce an update archive" + ls -la artifacts/ >&2 + exit 1 + fi + mv "artifacts/TablePro-${ARCH}.zip" "artifacts/TablePro-${VERSION}-${ARCH}.zip" - if [ -f "artifacts/TablePro-x86_64.zip" ]; then - mv artifacts/TablePro-x86_64.zip "artifacts/TablePro-${VERSION}-x86_64.zip" - fi + if [ ! -f "artifacts/TablePro-${VERSION}-${ARCH}.dmg" ]; then + echo "::error::artifacts/TablePro-${VERSION}-${ARCH}.dmg is missing" + ls -la artifacts/ >&2 + exit 1 + fi + done echo "✅ Artifacts organized successfully" echo "Final artifacts:" diff --git a/scripts/ci/verify-build.sh b/scripts/ci/verify-build.sh index a771c686a..806ec3923 100755 --- a/scripts/ci/verify-build.sh +++ b/scripts/ci/verify-build.sh @@ -82,11 +82,37 @@ if [ ! -d "$PLUGINS_DIR" ]; then fi echo "✅ PlugIns directory exists" -REQUIRED_PLUGINS=( - "MySQLDriver.tableplugin" - "PostgreSQLDriver.tableplugin" - "SQLiteDriver.tableplugin" +# Derived from project.yml's copy phase, which is what decides what ends up in the bundle. +# This used to be a hardcoded list of three, so a release could ship missing eleven of the +# fourteen embedded plugins and still print "All bundled plugin bundles present". +REQUIRED_PLUGINS=() +while IFS= read -r plugin; do + REQUIRED_PLUGINS+=("${plugin}.tableplugin") +done < <(python3 -c ' +import re, sys + +# Parsed without PyYAML on purpose: this runs on the release path and the module is not part of +# a stock runner image. The shape being read is fixed and small: +# +# - target: MySQLDriver +# copy: +# destination: plugins +# +text = open("project.yml").read() +names = re.findall( + r"-\s*target:\s*(\S+)[^\n]*\n(?:\s+\w+:[^\n]*\n)*?\s*copy:\s*\{\s*destination:\s*plugins\s*\}", + text, ) +if not names: + sys.exit("project.yml declares no plugins copied into the app bundle") +print("\n".join(names)) +') + +if [ "${#REQUIRED_PLUGINS[@]}" -eq 0 ]; then + echo "❌ ERROR: could not read the bundled plugin list from project.yml" + exit 1 +fi +echo "project.yml embeds ${#REQUIRED_PLUGINS[@]} plugins" MISSING_PLUGINS=0 for PLUGIN in "${REQUIRED_PLUGINS[@]}"; do