From 19adc31b0faad7454fd6c0799bb90a39c365ee7a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 04:36:35 +0700 Subject: [PATCH 1/4] refactor(plugins): stamp the PluginKit version from one build setting --- .claude/rules/plugin-system.md | 5 +- .github/workflows/build-plugin.yml | 15 +++--- .github/workflows/build.yml | 8 +-- Configs/Base.xcconfig | 9 ++-- Configs/PluginKit.xcconfig | 3 ++ Plugins/BeancountDriverPlugin/Info.plist | 2 +- Plugins/BigQueryDriverPlugin/Info.plist | 2 +- Plugins/BigtableDriverPlugin/Info.plist | 2 +- Plugins/CSVExportPlugin/Info.plist | 2 +- Plugins/CSVImportPlugin/Info.plist | 2 +- Plugins/CassandraDriverPlugin/Info.plist | 2 +- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- Plugins/CloudflareD1DriverPlugin/Info.plist | 2 +- .../CloudflareR2SQLDriverPlugin/Info.plist | 2 +- Plugins/DamengDriverPlugin/Info.plist | 2 +- Plugins/DuckDBDriverPlugin/Info.plist | 2 +- Plugins/DynamoDBDriverPlugin/Info.plist | 2 +- Plugins/ElasticsearchDriverPlugin/Info.plist | 2 +- Plugins/EtcdDriverPlugin/Info.plist | 2 +- Plugins/FirebaseDriverPlugin/Info.plist | 2 +- Plugins/HanaDriverPlugin/Info.plist | 2 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/JSONImportPlugin/Info.plist | 2 +- Plugins/KafkaDriverPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 2 +- Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MarkdownExportPlugin/Info.plist | 2 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- Plugins/MySQLDriverPlugin/Info.plist | 2 +- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/ParquetExportPlugin/Info.plist | 2 +- Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- Plugins/RedisDriverPlugin/Info.plist | 2 +- Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- Plugins/SnowflakeDriverPlugin/Info.plist | 2 +- Plugins/SpannerDriverPlugin/Info.plist | 2 +- Plugins/SurrealDBDriverPlugin/Info.plist | 2 +- Plugins/TeradataDriverPlugin/Info.plist | 2 +- Plugins/TrinoDriverPlugin/Info.plist | 2 +- Plugins/TypesenseDriverPlugin/Info.plist | 2 +- Plugins/WeaviateDriverPlugin/Info.plist | 2 +- Plugins/XLSXExportPlugin/Info.plist | 2 +- Plugins/XLSXImportPlugin/Info.plist | 2 +- TablePro/Core/Plugins/PluginManager.swift | 14 ++++- TablePro/Info.plist | 4 ++ .../Plugins/BundledPluginStampTests.swift | 41 +++++++++++++++ project.yml | 12 +++++ scripts/ci/check-pluginkit-bump-cadence.py | 41 +++++++++------ .../ci/test_check_pluginkit_bump_cadence.py | 52 +++++++++++++++---- scripts/release-plugin-for-shipped-app.sh | 12 +++-- 53 files changed, 208 insertions(+), 90 deletions(-) create mode 100644 Configs/PluginKit.xcconfig create mode 100644 TableProTests/Core/Plugins/BundledPluginStampTests.swift diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index de573eb205..0d52c96e8a 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -24,9 +24,10 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke - Add a requirement only with a default implementation, then bridge it in `PluginDriverAdapter`. - Additive: a new requirement with a default, a new field on a non-`@frozen` struct, a new case on a non-`@frozen` enum, a reordering. - Breaking: a changed, removed or renamed requirement or signature, a requirement without a default, a new case on a `@frozen` enum, a change to a frozen type's layout. -- Every ABI change bumps `currentPluginKitVersion` (in `PluginManager.swift`) and `TableProPluginKitVersion` in every plugin `Info.plist`, at most once per release cycle: later changes in the cycle reuse the pending number. `scripts/ci/check-pluginkit-bump-cadence.py` checks it in Repo Hygiene. +- Every ABI change bumps `TABLEPRO_PLUGINKIT_VERSION` in `Configs/PluginKit.xcconfig`, at most once per release cycle: later changes in the cycle reuse the pending number. `scripts/ci/check-pluginkit-bump-cadence.py` checks it in Repo Hygiene. +- The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `TABLEPRO_PLUGINKIT_VERSION` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`. - An additive change re-publishes nothing. Built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load. -- A breaking change also raises `minimumCompatiblePluginKitVersion`, and every registry plugin is re-released with `scripts/release-all-plugins.sh ` before the app tag, because the release's `Registry Readiness` job fails while a registry driver has no binary in the app's kit range (`RELEASING.md`, The release pipeline). +- A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`, and every registry plugin is re-released with `scripts/release-all-plugins.sh ` before the app tag, because the release's `Registry Readiness` job fails while a registry driver has no binary in the app's kit range (`RELEASING.md`, The release pipeline). - Check with `verify.sh abi ` (`scripts/check-pluginkit-abi.sh`). It builds PluginKit at HEAD and at the base with the same toolchain and diffs the public `.swiftinterface`. A base with no interface passes; a diff prints the additive and breaking rules and exits 1. - It refuses uncommitted changes, so run it after the commit and before the push. No workflow runs it. - The diff is textual, so a `-` line can be a declaration that moved. Only a removed line with no matching added line, or a requirement added without a default, is breaking. This lists the removed lines that do not come back: diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 6dfcfff2ea..e36897cfaa 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -8,7 +8,7 @@ on: tags: description: > Comma-separated plugin tag:pluginKitVersion pairs. - pluginKitVersion defaults to currentPluginKitVersion from PluginManager.swift. + pluginKitVersion defaults to TABLEPRO_PLUGINKIT_VERSION from Configs/PluginKit.xcconfig. Examples: plugin-mongodb-v1.0.25, plugin-mongodb-v1.0.25:13 required: true type: string @@ -21,7 +21,7 @@ on: required: false type: string -# Declared per job rather than here. resolve-tags reads two integers out of a Swift file and needs +# Declared per job rather than here. resolve-tags reads an integer out of an xcconfig and needs # nothing, and a workflow-level grant hands it a write token it has no use for. permissions: contents: read @@ -66,18 +66,17 @@ jobs: - name: Check the plugin manifest against the plugin classes run: python3 scripts/ci/check-plugin-manifest.py - - name: Read currentPluginKitVersion + - name: Read the PluginKit version id: pkv run: | - VERSION=$(grep -E 'static let currentPluginKitVersion\s*=\s*[0-9]+' \ - TablePro/Core/Plugins/PluginManager.swift \ - | grep -oE '[0-9]+$' | head -1) + VERSION=$(sed -nE 's/^TABLEPRO_PLUGINKIT_VERSION[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' \ + Configs/PluginKit.xcconfig) if [ -z "$VERSION" ]; then - echo "::error::Could not parse currentPluginKitVersion from PluginManager.swift" + echo "::error::Could not parse TABLEPRO_PLUGINKIT_VERSION from Configs/PluginKit.xcconfig" exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "currentPluginKitVersion=$VERSION" + echo "TABLEPRO_PLUGINKIT_VERSION=$VERSION" - name: Build matrix id: tags diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f77343fafc..14e249bfa2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -185,7 +185,7 @@ jobs: build/Release/TablePro-*.dmg build/Release/TablePro-*.tar.xz - # Reads two integers out of a Swift file and fetches a JSON manifest, so it wants nothing + # Reads two integers out of an xcconfig and fetches a JSON manifest, so it wants nothing # from macOS and does not need to spend a macOS runner minute (billed at ten times Linux). registry-readiness: name: Registry Readiness @@ -199,9 +199,9 @@ jobs: - name: Verify registry has compatible plugin binaries run: | - MANAGER="TablePro/Core/Plugins/PluginManager.swift" - CURRENT=$(grep -E 'static let currentPluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) - FLOOR=$(grep -E 'static let minimumCompatiblePluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) + CONFIG="Configs/PluginKit.xcconfig" + CURRENT=$(sed -nE 's/^TABLEPRO_PLUGINKIT_VERSION[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' "$CONFIG") + FLOOR=$(sed -nE 's/^TABLEPRO_PLUGINKIT_MINIMUM_VERSION[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' "$CONFIG") echo "PluginKit floor=$FLOOR current=$CURRENT" python3 scripts/check-registry-readiness.py --floor "$FLOOR" --current "$CURRENT" diff --git a/Configs/Base.xcconfig b/Configs/Base.xcconfig index 3f39b27db6..323f91bffe 100644 --- a/Configs/Base.xcconfig +++ b/Configs/Base.xcconfig @@ -2,6 +2,8 @@ // Structure rule: project-wide and per-configuration settings live in Configs/*.xcconfig. // Per-target settings live in project.yml, where the target templates are. +#include "PluginKit.xcconfig" + ALWAYS_SEARCH_USER_PATHS = NO ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES CLANG_ANALYZER_NONNULL = YES @@ -60,10 +62,9 @@ STRINGS_FILE_OUTPUT_ENCODING = binary SWIFT_VERSION = 6.0 CODE_SIGN_STYLE = Automatic -// Signing knobs. Only the two app targets read them, so plugin bundles and the helper -// tool sign ad-hoc and a contributor without the official team can still build everything. -// A contributor overrides these in Configs/Secrets.xcconfig without touching a tracked file, -// and the release scripts pass their own values on the xcodebuild command line. +// Signing knobs. The app targets and the plugin bundles sign with this team; the helper tool +// signs ad hoc. A contributor overrides these in Configs/Secrets.xcconfig without touching a +// tracked file, and the release scripts pass their own values on the xcodebuild command line. TABLEPRO_DEVELOPMENT_TEAM = D7HJ5TFYCU TABLEPRO_APP_BUNDLE_IDENTIFIER = com.TablePro diff --git a/Configs/PluginKit.xcconfig b/Configs/PluginKit.xcconfig new file mode 100644 index 0000000000..9f5ac3b3d0 --- /dev/null +++ b/Configs/PluginKit.xcconfig @@ -0,0 +1,3 @@ +// When to raise each, and how the build stamps them: .claude/rules/plugin-system.md, ABI. +TABLEPRO_PLUGINKIT_VERSION = 39 +TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19 diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index 5eb0eeab09..8bb97777cc 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Beancount diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index 1f46e12387..2191e071b0 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/BigtableDriverPlugin/Info.plist b/Plugins/BigtableDriverPlugin/Info.plist index df5147fef9..cab40835a2 100644 --- a/Plugins/BigtableDriverPlugin/Info.plist +++ b/Plugins/BigtableDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.81.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Bigtable diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index bc844bd4ea..81a04239c9 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds csv diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist index c92042694f..a09c94dbca 100644 --- a/Plugins/CSVImportPlugin/Info.plist +++ b/Plugins/CSVImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesImportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 5189083c91..10fa22e650 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index 1f46e12387..2191e071b0 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist index edd28fb2d7..9625b89836 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Cloudflare R2 SQL diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist index 767f6b3e09..024e929450 100644 --- a/Plugins/DamengDriverPlugin/Info.plist +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Dameng diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index 1f46e12387..2191e071b0 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist index 302d83a253..87e95599df 100644 --- a/Plugins/ElasticsearchDriverPlugin/Info.plist +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.53.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index 1f46e12387..2191e071b0 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/FirebaseDriverPlugin/Info.plist b/Plugins/FirebaseDriverPlugin/Info.plist index 7862226b67..8ad5854eb9 100644 --- a/Plugins/FirebaseDriverPlugin/Info.plist +++ b/Plugins/FirebaseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.80.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Firestore diff --git a/Plugins/HanaDriverPlugin/Info.plist b/Plugins/HanaDriverPlugin/Info.plist index 2494774fd1..37b21f2e8d 100644 --- a/Plugins/HanaDriverPlugin/Info.plist +++ b/Plugins/HanaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds SAP HANA diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index ceea251c9b..4077dfde3a 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds json diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist index f8fb2ead03..d0beb83840 100644 --- a/Plugins/JSONImportPlugin/Info.plist +++ b/Plugins/JSONImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesImportFormatIds json diff --git a/Plugins/KafkaDriverPlugin/Info.plist b/Plugins/KafkaDriverPlugin/Info.plist index 06a6c6dba0..2f105003d2 100644 --- a/Plugins/KafkaDriverPlugin/Info.plist +++ b/Plugins/KafkaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Kafka diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index 1f46e12387..2191e071b0 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 29ea7006fe..7cc82fecc2 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/MarkdownExportPlugin/Info.plist b/Plugins/MarkdownExportPlugin/Info.plist index cb1c7d34de..9d9b33d127 100644 --- a/Plugins/MarkdownExportPlugin/Info.plist +++ b/Plugins/MarkdownExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds md diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 2cca4834c1..e736a2c92e 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/ParquetExportPlugin/Info.plist b/Plugins/ParquetExportPlugin/Info.plist index 0d5a128b54..758650d5c3 100644 --- a/Plugins/ParquetExportPlugin/Info.plist +++ b/Plugins/ParquetExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds parquet diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index b118efa2ba..27c87e63de 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index e2085f42ec..3bb3e5f4b7 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index 279c69fe5a..fd6dcc5ec1 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index 369a47df4b..262e0c28d1 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 56a956e9dd..5354fb9599 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist index ae2952b33f..3d429e5f55 100644 --- a/Plugins/SnowflakeDriverPlugin/Info.plist +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.48.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/SpannerDriverPlugin/Info.plist b/Plugins/SpannerDriverPlugin/Info.plist index 07dfe685ea..bf282d67ff 100644 --- a/Plugins/SpannerDriverPlugin/Info.plist +++ b/Plugins/SpannerDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Spanner diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist index acb61d7be8..34c7f92701 100644 --- a/Plugins/SurrealDBDriverPlugin/Info.plist +++ b/Plugins/SurrealDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds SurrealDB diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/TeradataDriverPlugin/Info.plist +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist index 88fdb3660c..ad014bef07 100644 --- a/Plugins/TrinoDriverPlugin/Info.plist +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION diff --git a/Plugins/TypesenseDriverPlugin/Info.plist b/Plugins/TypesenseDriverPlugin/Info.plist index dca18efec4..67c295cfaa 100644 --- a/Plugins/TypesenseDriverPlugin/Info.plist +++ b/Plugins/TypesenseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Typesense diff --git a/Plugins/WeaviateDriverPlugin/Info.plist b/Plugins/WeaviateDriverPlugin/Info.plist index 28579a91f5..2f8c78f6e0 100644 --- a/Plugins/WeaviateDriverPlugin/Info.plist +++ b/Plugins/WeaviateDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesDatabaseTypeIds Weaviate diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index 1f676a78e2..661aa2ce9e 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesExportFormatIds xlsx diff --git a/Plugins/XLSXImportPlugin/Info.plist b/Plugins/XLSXImportPlugin/Info.plist index 5407e53283..a87e8e8ac3 100644 --- a/Plugins/XLSXImportPlugin/Info.plist +++ b/Plugins/XLSXImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 39 + TABLEPRO_PLUGINKIT_VERSION TableProProvidesImportFormatIds xlsx diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 3341bb3898..7c83409601 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -9,9 +9,19 @@ import TableProPluginKit @MainActor final class PluginManager: ObservableObject { static let shared = PluginManager(userDefaults: AppStorageEnvironment.shared.defaults) - nonisolated static let currentPluginKitVersion = 39 + nonisolated static let currentPluginKitVersion = pluginKitVersion(infoKey: "TableProPluginKitVersion") - nonisolated static let minimumCompatiblePluginKitVersion = 19 + nonisolated static let minimumCompatiblePluginKitVersion = pluginKitVersion( + infoKey: "TableProPluginKitMinimumVersion" + ) + + nonisolated private static func pluginKitVersion(infoKey: String) -> Int { + guard let value = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String, + let version = Int(value) else { + preconditionFailure("Info.plist has no integer \(infoKey); Configs/PluginKit.xcconfig sets it") + } + return version + } private let builtInPluginsURL: URL? internal let userPluginsDir: URL diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 376238ace1..d34f4b71b4 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -20,6 +20,10 @@ 86400 SUAutomaticallyUpdate + TableProPluginKitVersion + $(TABLEPRO_PLUGINKIT_VERSION) + TableProPluginKitMinimumVersion + $(TABLEPRO_PLUGINKIT_MINIMUM_VERSION) CFBundleDocumentTypes diff --git a/TableProTests/Core/Plugins/BundledPluginStampTests.swift b/TableProTests/Core/Plugins/BundledPluginStampTests.swift new file mode 100644 index 0000000000..77dbc8b7cd --- /dev/null +++ b/TableProTests/Core/Plugins/BundledPluginStampTests.swift @@ -0,0 +1,41 @@ +import Foundation +@testable import TablePro +import Testing + +struct BundledPluginStampTests { + private struct StampedPlugin { + let name: String + let info: [String: Any] + } + + private static func bundledPlugins() throws -> [StampedPlugin] { + let plugIns = try #require(Bundle.main.builtInPlugInsURL) + let bundles = try FileManager.default.contentsOfDirectory(at: plugIns, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "tableplugin" } + return try bundles.map { bundle in + let data = try Data(contentsOf: bundle.appendingPathComponent("Contents/Info.plist")) + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) + let info = try #require(plist as? [String: Any], "\(bundle.lastPathComponent) has no Info.plist dictionary") + return StampedPlugin(name: bundle.lastPathComponent, info: info) + } + } + + @Test("Every bundled plugin declares the app's PluginKit version as an integer") + func bundledPluginsCarryTheAppKit() throws { + let plugins = try Self.bundledPlugins() + #expect(!plugins.isEmpty) + for plugin in plugins { + let declared = plugin.info["TableProPluginKitVersion"] + #expect( + declared as? Int == PluginManager.currentPluginKitVersion, + "\(plugin.name) declares \(String(describing: declared)), the app is \(PluginManager.currentPluginKitVersion)" + ) + } + } + + @Test("The app's PluginKit range is stamped from the build setting") + func appKitRangeIsOrdered() { + #expect(PluginManager.minimumCompatiblePluginKitVersion > 0) + #expect(PluginManager.minimumCompatiblePluginKitVersion <= PluginManager.currentPluginKitVersion) + } +} diff --git a/project.yml b/project.yml index fd3efe4dfc..bc75aa5a70 100644 --- a/project.yml +++ b/project.yml @@ -96,9 +96,14 @@ targetTemplates: base: COMBINE_HIDPI_IMAGES: YES CURRENT_PROJECT_VERSION: 1 + DEVELOPMENT_TEAM: $(TABLEPRO_DEVELOPMENT_TEAM) + ENABLE_HARDENED_RUNTIME: YES GENERATE_INFOPLIST_FILE: YES INFOPLIST_FILE: Plugins/${folder}/Info.plist INFOPLIST_KEY_NSPrincipalClass: $(PRODUCT_MODULE_NAME).${principalClass} + # Keeps TableProPluginKitVersion an : .claude/rules/plugin-system.md, ABI. + INFOPLIST_PREPROCESS: YES + INFOPLIST_PREPROCESSOR_DEFINITIONS: TABLEPRO_PLUGINKIT_VERSION=$(TABLEPRO_PLUGINKIT_VERSION) LD_RUNPATH_SEARCH_PATHS: - $(inherited) - "@executable_path/../Frameworks" @@ -115,6 +120,9 @@ targetTemplates: SWIFT_APPROACHABLE_CONCURRENCY: YES SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES WRAPPER_EXTENSION: tableplugin + configs: + Release: + DEPLOYMENT_POSTPROCESSING: YES # A bundled plugin's folder compiled again with its tests and no host app. It links the same # TableProPluginKit framework the plugin does: a package product that depended on the package's @@ -134,6 +142,8 @@ targetTemplates: settings: base: GENERATE_INFOPLIST_FILE: YES + # Set, or XcodeGen picks the plugin folder's Info.plist, which only the plugin preprocesses. + INFOPLIST_FILE: "" PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.${target_name} STRING_CATALOG_GENERATE_SYMBOLS: NO SWIFT_APPROACHABLE_CONCURRENCY: YES @@ -841,6 +851,8 @@ targets: settings: base: GENERATE_INFOPLIST_FILE: YES + # Set for the reason the PluginTests template gives. + INFOPLIST_FILE: "" PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.FormatPluginTests STRING_CATALOG_GENERATE_SYMBOLS: NO SWIFT_APPROACHABLE_CONCURRENCY: YES diff --git a/scripts/ci/check-pluginkit-bump-cadence.py b/scripts/ci/check-pluginkit-bump-cadence.py index 335a7d141b..c05a33d55c 100644 --- a/scripts/ci/check-pluginkit-bump-cadence.py +++ b/scripts/ci/check-pluginkit-bump-cadence.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail when currentPluginKitVersion has moved more than one past the newest release tag. +"""Fail when TABLEPRO_PLUGINKIT_VERSION has moved more than one past the newest release tag. Only the value at release time ever ships, so every PluginKit change in one release cycle reuses the number the first one took. The registry keeps binaries for the three newest kit versions, and @@ -10,7 +10,8 @@ The newest release is the highest vX.Y.Z tag by version number. With --remote the tag list comes from that remote and the one tag is fetched when it is missing, so a shallow CI checkout with no tags works. The fetch is at depth 1 only in a checkout that is already shallow: in a full clone it would -cut the history every worktree shares down to that one commit. +cut the history every worktree shares down to that one commit. A tag cut before +Configs/PluginKit.xcconfig existed declares the kit in PluginManager.swift. test_check_pluginkit_bump_cadence.py pins what it must and must not report. """ @@ -23,8 +24,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -PLUGIN_MANAGER = "TablePro/Core/Plugins/PluginManager.swift" -KIT_DECLARATION = re.compile(r"\bstatic\s+let\s+currentPluginKitVersion\s*=\s*(\d+)\b") +KIT_CONFIG = "Configs/PluginKit.xcconfig" +KIT_SETTING = re.compile(r"^TABLEPRO_PLUGINKIT_VERSION\s*=\s*(\d+)\s*$", re.MULTILINE) +LEGACY_PLUGIN_MANAGER = "TablePro/Core/Plugins/PluginManager.swift" +LEGACY_DECLARATION = re.compile(r"\bstatic\s+let\s+currentPluginKitVersion\s*=\s*(\d+)\b") RELEASE_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") @@ -35,13 +38,20 @@ def git(repo: Path, *args: str) -> str: return result.stdout -def kit_version(source: str, origin: str) -> int: - match = KIT_DECLARATION.search(source) +def kit_version(source: str, origin: str, pattern: re.Pattern[str] = KIT_SETTING) -> int: + match = pattern.search(source) if match is None: - raise SystemExit(f"ERROR: no currentPluginKitVersion declaration in {origin}") + raise SystemExit(f"ERROR: no PluginKit version in {origin}") return int(match.group(1)) +def released_kit_version(repo: Path, tag: str) -> int: + for path, pattern in ((KIT_CONFIG, KIT_SETTING), (LEGACY_PLUGIN_MANAGER, LEGACY_DECLARATION)): + if has_object(repo, f"{tag}:{path}"): + return kit_version(git(repo, "show", f"{tag}:{path}"), f"{tag}:{path}", pattern) + raise SystemExit(f"ERROR: {tag} has neither {KIT_CONFIG} nor {LEGACY_PLUGIN_MANAGER}") + + def newest_release_tag(names: list[str]) -> str | None: releases = [ (tuple(int(part) for part in match.groups()), name) @@ -60,16 +70,16 @@ def remote_tag_names(repo: Path, remote: str) -> list[str]: return [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line] -def has_commit(repo: Path, ref: str) -> bool: +def has_object(repo: Path, ref: str) -> bool: result = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "--quiet", "--verify", f"{ref}^{{commit}}"], + ["git", "-C", str(repo), "rev-parse", "--quiet", "--verify", ref], capture_output=True, ) return result.returncode == 0 def fetch_tag(repo: Path, remote: str, tag: str) -> None: - if has_commit(repo, f"refs/tags/{tag}"): + if has_object(repo, f"refs/tags/{tag}^{{commit}}"): return shallow = git(repo, "rev-parse", "--is-shallow-repository").strip() == "true" depth = ["--depth=1"] if shallow else [] @@ -80,7 +90,7 @@ def cadence_violation(tag: str, released: int, current: int) -> str | None: if current <= released + 1: return None return ( - f"currentPluginKitVersion is {current}, but {tag} shipped {released}. It moves at most once " + f"TABLEPRO_PLUGINKIT_VERSION is {current}, but {tag} shipped {released}. It moves at most once " f"per release cycle, so every PluginKit change until the next release reuses {released + 1}." ) @@ -94,19 +104,18 @@ def main(argv: list[str] | None = None) -> int: names = remote_tag_names(args.repo, args.remote) if args.remote else local_tag_names(args.repo) tag = newest_release_tag(names) if tag is None: - raise SystemExit("ERROR: no vX.Y.Z release tag to compare currentPluginKitVersion against") + raise SystemExit("ERROR: no vX.Y.Z release tag to compare TABLEPRO_PLUGINKIT_VERSION against") if args.remote: fetch_tag(args.repo, args.remote, tag) - released = kit_version(git(args.repo, "show", f"{tag}:{PLUGIN_MANAGER}"), f"{tag}:{PLUGIN_MANAGER}") - current_path = args.repo / PLUGIN_MANAGER - current = kit_version(current_path.read_text(encoding="utf-8"), PLUGIN_MANAGER) + released = released_kit_version(args.repo, tag) + current = kit_version((args.repo / KIT_CONFIG).read_text(encoding="utf-8"), KIT_CONFIG) violation = cadence_violation(tag, released, current) if violation: print(violation, file=sys.stderr) return 1 - print(f"currentPluginKitVersion {current}; {tag} shipped {released}.") + print(f"TABLEPRO_PLUGINKIT_VERSION {current}; {tag} shipped {released}.") return 0 diff --git a/scripts/ci/test_check_pluginkit_bump_cadence.py b/scripts/ci/test_check_pluginkit_bump_cadence.py index dc64c7f80b..add50f8b36 100644 --- a/scripts/ci/test_check_pluginkit_bump_cadence.py +++ b/scripts/ci/test_check_pluginkit_bump_cadence.py @@ -11,7 +11,8 @@ from pathlib import Path SCRIPT = Path(__file__).with_name("check-pluginkit-bump-cadence.py") -PLUGIN_MANAGER = Path("TablePro/Core/Plugins/PluginManager.swift") +KIT_CONFIG = Path("Configs/PluginKit.xcconfig") +LEGACY_PLUGIN_MANAGER = Path("TablePro/Core/Plugins/PluginManager.swift") GIT_ENV = { **os.environ, @@ -24,7 +25,11 @@ } -def plugin_manager_source(kit: int) -> str: +def kit_config_source(kit: int) -> str: + return f"TABLEPRO_PLUGINKIT_VERSION = {kit}\nTABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n" + + +def legacy_plugin_manager_source(kit: int) -> str: return ( "final class PluginManager {\n" f" nonisolated static let currentPluginKitVersion = {kit}\n" @@ -46,14 +51,21 @@ def git(self, *args: str) -> str: text=True, ).stdout - def write_kit(self, kit: int) -> None: - target = self.path / PLUGIN_MANAGER + def write(self, relative: Path, text: str) -> None: + target = self.path / relative target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(plugin_manager_source(kit), encoding="utf-8") + target.write_text(text, encoding="utf-8") - def commit_kit(self, kit: int, tag: str | None = None) -> None: - self.write_kit(kit) - self.git("add", str(PLUGIN_MANAGER)) + def write_kit(self, kit: int) -> None: + self.write(KIT_CONFIG, kit_config_source(kit)) + + def commit_kit(self, kit: int, tag: str | None = None, legacy: bool = False) -> None: + if legacy: + self.write(LEGACY_PLUGIN_MANAGER, legacy_plugin_manager_source(kit)) + self.git("add", str(LEGACY_PLUGIN_MANAGER)) + else: + self.write_kit(kit) + self.git("add", str(KIT_CONFIG)) self.git("commit", "--quiet", "--allow-empty", "-m", f"kit {kit}") if tag: self.git("tag", tag) @@ -126,8 +138,28 @@ def test_a_repository_without_a_release_tag_fails(self) -> None: def test_a_missing_declaration_fails(self) -> None: self.repo.commit_kit(30, tag="v0.74.0") - (self.repo.path / PLUGIN_MANAGER).write_text("final class PluginManager {}\n", encoding="utf-8") - self.assertFails(self.repo.run_check(), "currentPluginKitVersion") + self.repo.write(KIT_CONFIG, "TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n") + self.assertFails(self.repo.run_check(), str(KIT_CONFIG)) + + def test_a_release_before_the_xcconfig_is_read_from_swift(self) -> None: + self.repo.commit_kit(39, tag="v0.81.0", legacy=True) + self.repo.commit_kit(40) + self.assertPasses(self.repo.run_check()) + + self.repo.write_kit(41) + self.assertFails(self.repo.run_check(), "41", "v0.81.0", "39") + + def test_the_xcconfig_wins_over_a_leftover_swift_literal(self) -> None: + self.repo.commit_kit(30, legacy=True) + self.repo.commit_kit(39, tag="v0.82.0") + self.repo.write_kit(40) + self.assertPasses(self.repo.run_check()) + + def test_a_release_with_no_kit_fails(self) -> None: + self.repo.git("commit", "--quiet", "--allow-empty", "-m", "empty") + self.repo.git("tag", "v0.74.0") + self.repo.write_kit(30) + self.assertFails(self.repo.run_check(), "v0.74.0", str(KIT_CONFIG)) def test_a_shallow_clone_reads_the_release_from_its_remote(self) -> None: self.repo.commit_kit(25, tag="v0.73.0") diff --git a/scripts/release-plugin-for-shipped-app.sh b/scripts/release-plugin-for-shipped-app.sh index 7290e0d258..930b97c849 100755 --- a/scripts/release-plugin-for-shipped-app.sh +++ b/scripts/release-plugin-for-shipped-app.sh @@ -66,10 +66,16 @@ if ! git -C "$REPO_ROOT" rev-parse --verify "$APP_TAG^{commit}" >/dev/null 2>&1; exit 1 fi -KIT=$(git -C "$REPO_ROOT" show "$APP_TAG:TablePro/Core/Plugins/PluginManager.swift" \ - | sed -n 's/.*currentPluginKitVersion = \([0-9]*\).*/\1/p' | head -1) +if git -C "$REPO_ROOT" cat-file -e "$APP_TAG:Configs/PluginKit.xcconfig" 2>/dev/null; then + KIT=$(git -C "$REPO_ROOT" show "$APP_TAG:Configs/PluginKit.xcconfig" \ + | sed -n 's/^TABLEPRO_PLUGINKIT_VERSION *= *\([0-9][0-9]*\) *$/\1/p') +else + # A tag cut before Configs/PluginKit.xcconfig existed declares the kit in Swift. + KIT=$(git -C "$REPO_ROOT" show "$APP_TAG:TablePro/Core/Plugins/PluginManager.swift" \ + | sed -n 's/.*currentPluginKitVersion = \([0-9]*\).*/\1/p' | head -1) +fi if [ -z "$KIT" ]; then - echo "error: could not read currentPluginKitVersion out of $APP_TAG" >&2 + echo "error: could not read the PluginKit version out of $APP_TAG" >&2 exit 1 fi From ae54f22907d0ab305a076882ec4523e13679e857 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 04:31:24 +0700 Subject: [PATCH 2/4] build(release): add the feed, registry and GitHub release tooling --- .claude/rules/ci.md | 3 +- .github/actions/setup-sparkle/action.yml | 51 +++ .github/workflows/repo-hygiene.yml | 3 - project.yml | 3 +- scripts/ci/check-sparkle-version.py | 139 ------ scripts/release/feed.py | 521 ++++++++++++++++++++++ scripts/release/publish.py | 224 ++++++++++ scripts/release/registry.py | 337 ++++++++++++++ scripts/release/test_feed.py | 531 +++++++++++++++++++++++ scripts/release/test_publish.py | 299 +++++++++++++ scripts/release/test_registry.py | 327 ++++++++++++++ 11 files changed, 2294 insertions(+), 144 deletions(-) create mode 100644 .github/actions/setup-sparkle/action.yml delete mode 100755 scripts/ci/check-sparkle-version.py create mode 100755 scripts/release/feed.py create mode 100755 scripts/release/publish.py create mode 100755 scripts/release/registry.py create mode 100644 scripts/release/test_feed.py create mode 100644 scripts/release/test_publish.py create mode 100644 scripts/release/test_registry.py diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 0e588c7bdc..25290306ae 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -27,6 +27,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. `build-plugin.yml` picks its runner from the ref it builds (`RELEASING.md`). - The Xcode version is written once, in `.github/actions/setup-xcode/action.yml`, which runs `xcode-select --switch` and prints `xcodebuild -version`. A workflow never selects Xcode another way. - XcodeGen comes from `.github/actions/setup-xcodegen`, pinned by version and SHA-256. +- Sparkle's `generate_appcast` and `sign_update` come from `.github/actions/setup-sparkle`. It reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision. - The compilation cache is off on CI (`COMPILATION_CACHE_ENABLE_CACHING=NO`); `Configs/Base.xcconfig` turns it on locally. ## macOS Tests (`macos-tests.yml`) @@ -88,7 +89,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; - - `check-pluginkit-bump-cadence.py`, `check-sparkle-version.py`; + - `check-pluginkit-bump-cadence.py`; - `scripts/audit-refactor-health.sh --check`, `check-hub-ratchet.py`; - the Go checks on the HANA helper, when the `hana` detector is true. - `audit-refactor-health.sh --check` fails a file defining a production `DatabaseType` outside `Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift`, unless `.github/duplicate-contract-baseline.txt` lists its path. Moving a listed file moves its baseline path in the same commit. Never add an entry to let a new copy pass. diff --git a/.github/actions/setup-sparkle/action.yml b/.github/actions/setup-sparkle/action.yml new file mode 100644 index 0000000000..41c936027e --- /dev/null +++ b/.github/actions/setup-sparkle/action.yml @@ -0,0 +1,51 @@ +name: Setup Sparkle +description: Installs Sparkle's generate_appcast and sign_update at the version the app resolves, and exports SPARKLE_BIN. + +runs: + using: composite + steps: + - name: Install the Sparkle tools + shell: bash + # These tools hold the EdDSA key that signs every update, so they come from the archive SwiftPM + # verifies for the framework: Sparkle's Package.swift at the pinned revision names its checksum. + env: + RESOLVED: TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + run: | + set -euo pipefail + pin="$RUNNER_TEMP/sparkle-pin.txt" + python3 - "$RESOLVED" > "$pin" << 'PYTHON' + import json + import sys + + pins = [pin for pin in json.load(open(sys.argv[1]))["pins"] if pin["identity"] == "sparkle"] + if len(pins) != 1: + sys.exit(f"{sys.argv[1]} pins Sparkle {len(pins)} times") + print(pins[0]["state"]["version"], pins[0]["state"]["revision"]) + PYTHON + read -r version revision < "$pin" + case "$version" in *[!0-9.]* | '') echo "::error::Sparkle pin '$version' is not a version"; exit 1 ;; esac + case "$revision" in *[!0-9a-f]* | '') echo "::error::Sparkle pin '$revision' is not a commit"; exit 1 ;; esac + + manifest="$(curl -fsSL --retry 3 "https://raw.githubusercontent.com/sparkle-project/Sparkle/$revision/Package.swift")" + checksum="$(sed -n 's/^let checksum = "\([0-9a-f]\{64\}\)"$/\1/p' <<< "$manifest")" + tag="$(sed -n 's/^let tag = "\(.*\)"$/\1/p' <<< "$manifest")" + if [ -z "$checksum" ] || [ "$tag" != "$version" ]; then + echo "::error::Sparkle's Package.swift at $revision names tag '$tag' and checksum '$checksum', not $version" + exit 1 + fi + + archive="$RUNNER_TEMP/sparkle-$version.zip" + dir="$RUNNER_TEMP/sparkle-$version" + curl -fsSL --retry 3 -o "$archive" \ + "https://github.com/sparkle-project/Sparkle/releases/download/$version/Sparkle-for-Swift-Package-Manager.zip" + echo "$checksum $archive" | shasum -a 256 -c - + rm -rf "$dir" + unzip -q "$archive" -d "$dir" + for tool in generate_appcast sign_update; do + if [ ! -x "$dir/bin/$tool" ]; then + echo "::error::the Sparkle $version archive has no bin/$tool" + exit 1 + fi + done + echo "SPARKLE_BIN=$dir/bin" >> "$GITHUB_ENV" + echo "Sparkle $version tools in $dir/bin" diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 940ff29759..9cab3d2a8f 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -115,9 +115,6 @@ jobs: - name: Check currentPluginKitVersion moved at most once since the last release run: python3 scripts/ci/check-pluginkit-bump-cadence.py --remote origin - - name: Check the Sparkle version agrees everywhere - run: python3 scripts/ci/check-sparkle-version.py - - name: Check the shared-contract drift gates run: scripts/audit-refactor-health.sh --check diff --git a/project.yml b/project.yml index fd3efe4dfc..e4bf756589 100644 --- a/project.yml +++ b/project.yml @@ -48,9 +48,10 @@ packages: path: Packages/TableProMacCore TableProOracle: path: Packages/TableProOracle + # Exact, because .github/actions/setup-sparkle installs the feed signing tools from this pin. Sparkle: url: https://github.com/sparkle-project/Sparkle - majorVersion: 2.10.0 + exactVersion: 2.10.0 Yams: url: https://github.com/jpsim/Yams majorVersion: 5.1.0 diff --git a/scripts/ci/check-sparkle-version.py b/scripts/ci/check-sparkle-version.py deleted file mode 100755 index bfada43686..0000000000 --- a/scripts/ci/check-sparkle-version.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""Fails on the Sparkle configuration invariants nothing else checks. - -The framework the app links and the generate_appcast that signs its feed are the same project, and -a release that mixes them is a release nobody can measure. Nothing checked this before: project.yml -declares `majorVersion`, which XcodeGen writes as upToNextMajorVersion, so a resolve can move the -framework on its own while the signing tool stays where it was. - -The second invariant is the deployment target. TablePro publishes two items per release and tells -them apart by `sparkle:hardwareRequirements`, which `generate_appcast` emits only while -`LSMinimumSystemVersion` is below 27.0 (ArchiveItem.swift:255-262, unchanged in 2.10.0): at or above -it, `mayNeedHardwareRequirement` is forced false and the element is never written. merge-appcast.py -then refuses the release, about forty minutes in, with both architectures already notarized and the -tag already pushed. - -Runs on Repo Hygiene, which has no Xcode. Pure text. -""" - -import pathlib -import re -import sys - -ROOT = pathlib.Path(__file__).resolve().parents[2] - -PROJECT_YML = ROOT / "project.yml" -PACKAGE_RESOLVED = ROOT / "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" -SIGN_SCRIPT = ROOT / "scripts/ci/sign-and-appcast.sh" -LICENSES = ROOT / "TablePro/Resources/ThirdPartyLicenses/licenses.yml" - - -class VersionError(Exception): - """A disagreement. Every one of these fails the job that raised it.""" - - -def read(path): - try: - return path.read_text(encoding="utf-8") - except OSError as error: - raise VersionError(f"{path.relative_to(ROOT)}: {error}") from error - - -def sole_match(pattern, text, path, what): - matches = re.findall(pattern, text, re.MULTILINE) - if len(matches) != 1: - raise VersionError( - f"{path.relative_to(ROOT)}: expected exactly one {what}, found {len(matches)}" - ) - return matches[0] - - -HARDWARE_REQUIREMENTS_CEILING = (27, 0) - - -def deployment_target(): - project = read(PROJECT_YML) - block = re.search(r"^ deploymentTarget:\n(?:^ {4}.*\n)+", project, re.MULTILINE) - if block is None: - raise VersionError("project.yml: no deploymentTarget block") - return sole_match(r'^ {4}macOS:\s*"?([0-9.]+)"?\s*$', block.group(0), PROJECT_YML, "macOS deployment target") - - -def check_deployment_target(): - raw = deployment_target() - parts = tuple(int(part) for part in raw.split(".")[:2]) - padded = parts + (0,) * (2 - len(parts)) - if padded >= HARDWARE_REQUIREMENTS_CEILING: - raise VersionError( - f"the macOS deployment target is {raw}, at or above " - f"{'.'.join(str(n) for n in HARDWARE_REQUIREMENTS_CEILING)}. generate_appcast stops " - "emitting sparkle:hardwareRequirements there, so the two per-architecture items become " - "indistinguishable and merge-appcast.py refuses the release after both notarizations. " - "Move the release to one Universal archive before raising the target" - ) - return raw - - -def declared_versions(): - """Every place a Sparkle version is written down, as {label: version}.""" - project = read(PROJECT_YML) - sparkle_block = re.search( - r"^ Sparkle:\n(?:^ {4}.*\n)+", project, re.MULTILINE - ) - if sparkle_block is None: - raise VersionError("project.yml: no Sparkle package block") - - versions = { - "project.yml majorVersion": sole_match( - r"^ {4}majorVersion:\s*(\S+)\s*$", sparkle_block.group(0), PROJECT_YML, "majorVersion" - ), - "Package.resolved": sole_match( - r'"identity"\s*:\s*"sparkle".*?"version"\s*:\s*"([^"]+)"', - read(PACKAGE_RESOLVED).replace("\n", " "), - PACKAGE_RESOLVED, - "sparkle pin", - ), - "sign-and-appcast.sh SPARKLE_VERSION": sole_match( - r'^SPARKLE_VERSION="([^"]+)"\s*$', read(SIGN_SCRIPT), SIGN_SCRIPT, "SPARKLE_VERSION" - ), - } - - licenses = read(LICENSES) - entry = re.search(r"^- id: sparkle\n(?:^(?![-]).*\n)+", licenses, re.MULTILINE) - if entry is None: - raise VersionError("licenses.yml: no sparkle entry") - versions["licenses.yml version"] = sole_match( - r"^ version:\s*(\S+)\s*$", entry.group(0), LICENSES, "version" - ) - versions["licenses.yml licenseTextURL"] = sole_match( - r"licenseTextURL:\s*https://github\.com/sparkle-project/Sparkle/blob/([^/]+)/LICENSE", - entry.group(0), - LICENSES, - "licenseTextURL", - ) - return versions - - -def main(): - try: - target = check_deployment_target() - versions = declared_versions() - except VersionError as error: - print(f"::error::check-sparkle-version: {error}", file=sys.stderr) - return 1 - - distinct = set(versions.values()) - if len(distinct) != 1: - print("::error::check-sparkle-version: the Sparkle version is not the same everywhere", file=sys.stderr) - for label, version in versions.items(): - print(f" {version:<10} {label}", file=sys.stderr) - return 1 - - version = distinct.pop() - print(f"Sparkle {version} agrees across {len(versions)} declarations") - print(f"macOS deployment target {target} still emits sparkle:hardwareRequirements") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/release/feed.py b/scripts/release/feed.py new file mode 100755 index 0000000000..701c3a1dc0 --- /dev/null +++ b/scripts/release/feed.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +import lzma +import os +import plistlib +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import xml.etree.ElementTree as ET +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Iterator, List, Optional, Tuple + +import registry +import version + +ROOT = Path(__file__).resolve().parent.parent.parent +SPARKLE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" +BRANCH = "feeds" +REMOTE_BRANCH = f"refs/remotes/origin/{BRANCH}" +APPCAST = "appcast.xml" +MANIFEST = "plugins/v3.json" +METADATA = ".github/plugin-registry.json" +PROJECT = "project.yml" +PLUGINKIT_CONFIG = "Configs/PluginKit.xcconfig" +PHASED_ROLLOUT_SECONDS = 21600 +PUSH_ATTEMPTS = 3 +BOT_NAME = "github-actions[bot]" +BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" +ITEM_TEXT = re.compile(r"[ \t]*.*?[ \t]*\n?", re.DOTALL) +INTEGER = re.compile(r"[0-9]+") + + +class FeedError(Exception): + pass + + +@dataclass(frozen=True) +class Item: + build: Optional[str] + short_version: Optional[str] + channel: Optional[str] + is_critical: bool + critical_version: Optional[str] + url: Optional[str] + signature: Optional[str] + length: Optional[str] + + @property + def label(self) -> str: + return self.short_version or self.build or "an item" + + +@dataclass(frozen=True) +class Expected: + release: version.Version + url: str + + +def _sparkle_value(element: ET.Element, name: str) -> Optional[str]: + child = element.find(SPARKLE + name) + if child is not None: + return (child.text or "").strip() + enclosure = element.find("enclosure") + return enclosure.get(SPARKLE + name) if enclosure is not None else None + + +def parse_items(data: bytes) -> List[Item]: + try: + root = ET.fromstring(data) + except ET.ParseError as error: + raise FeedError(f"{APPCAST} is not well-formed XML: {error}") from error + channel = root.find("channel") + if root.tag != "rss" or channel is None: + raise FeedError(f"{APPCAST} has no rss channel") + items = [] + for element in channel.findall("item"): + enclosure = element.find("enclosure") + critical = element.find(f".//{SPARKLE}criticalUpdate") + name = element.find(SPARKLE + "channel") + items.append(Item( + build=_sparkle_value(element, "version"), + short_version=_sparkle_value(element, "shortVersionString"), + channel=(name.text or "").strip() if name is not None else None, + is_critical=critical is not None, + critical_version=critical.get(SPARKLE + "version") if critical is not None else None, + url=enclosure.get("url") if enclosure is not None else None, + signature=enclosure.get(SPARKLE + "edSignature") if enclosure is not None else None, + length=enclosure.get("length") if enclosure is not None else None, + )) + return items + + +def carried_critical(items: List[Item]) -> Optional[int]: + builds = [] + for item in items: + if item.channel is not None or not item.is_critical: + continue + # A critical update with no version is critical below its own build. + text = item.critical_version or item.build + if text is None or not INTEGER.fullmatch(text): + raise FeedError(f"{item.label}: the critical update version {text!r} is not an integer") + builds.append(int(text)) + return max(builds) if builds else None + + +def feed_problems(data: bytes, previous: Optional[bytes] = None, expected: Optional[Expected] = None) -> List[str]: + items = parse_items(data) + problems = [] + if previous is not None and any(item.channel is None for item in parse_items(previous)) \ + and not any(item.channel is None for item in items): + problems.append("the feed no longer has a default-channel item") + + versions: Dict[int, set] = {} + for item in items: + if item.url is None: + problems.append(f"{item.label} has no enclosure") + if not item.signature: + problems.append(f"{item.label}'s enclosure has no sparkle:edSignature") + if item.length is None or not INTEGER.fullmatch(item.length): + problems.append(f"{item.label}'s enclosure has no length") + if item.channel is not None and item.channel != "beta": + problems.append(f"{item.label} is on channel {item.channel!r}; the only channel is beta") + if item.channel == "beta" and item.is_critical: + problems.append(f"{item.label} is a beta marked critical") + if item.build is None or not INTEGER.fullmatch(item.build): + problems.append(f"{item.label}: sparkle:version {item.build!r} is not an integer") + continue + try: + parsed = version.parse_version(item.short_version or "") + except ValueError: + problems.append(f"build {item.build}: shortVersionString {item.short_version!r} is not a release version") + continue + versions.setdefault(int(item.build), set()).add(parsed) + + ordered = [] + for build, found in sorted(versions.items()): + if len(found) > 1: + problems.append(f"build {build} carries {', '.join(sorted(str(each) for each in found))}") + else: + ordered.append((build, found.pop())) + for (low_build, low), (high_build, high) in zip(ordered, ordered[1:]): + if not low.build < high.build: + problems.append(f"{high} has the higher build ({high_build} over {low_build}) but not the higher version") + + if expected is not None: + release = expected.release + channel = "beta" if release.is_beta else None + if not any(item.build == str(release.build) and item.short_version == str(release) + and item.channel == channel and item.url == expected.url for item in items): + problems.append(f"no {release.channel} item for {release} ({release.build}) at {expected.url}") + return problems + + +def remove_items(text: str, short_version: str) -> Tuple[str, int]: + items = parse_items(text.encode("utf-8")) + spans = list(ITEM_TEXT.finditer(text)) + if len(spans) != len(items): + raise FeedError(f"{APPCAST} has {len(items)} items but {len(spans)} blocks in its text; edit it by hand") + doomed = [span for span, item in zip(spans, items) if item.short_version == short_version] + for span in reversed(doomed): + text = text[:span.start()] + text[span.end():] + left = [item for item in items if item.short_version != short_version] + if parse_items(text.encode("utf-8")) != left: + raise FeedError(f"removing {short_version} from the text of {APPCAST} changed other items; edit it by hand") + return text, len(doomed) + + +def appcast_has(items: List[Item], release: version.Version) -> bool: + matches = [item for item in items if item.short_version == str(release) or item.build == str(release.build)] + for item in matches: + if item.short_version != str(release) or item.build != str(release.build): + raise FeedError(f"{APPCAST} has {item.short_version} at build {item.build}, " + f"which clashes with {release} at {release.build}") + return bool(matches) + + +def appcast_arguments(stage: Path, tag: str, release: version.Version, critical_build: Optional[int], + repository: str) -> List[str]: + command = [ + "--ed-key-file", "-", "--embed-release-notes", "--maximum-deltas", "0", + "--versions", str(release.build), + "--download-url-prefix", f"https://github.com/{repository}/releases/download/{tag}/", + "--full-release-notes-url", f"https://github.com/{repository}/releases/tag/{tag}", + ] + if release.is_beta: + command += ["--channel", "beta"] + else: + command += ["--phased-rollout-interval", str(PHASED_ROLLOUT_SECONDS)] + if critical_build is not None: + command += ["--critical-update-version", str(critical_build)] + return command + [str(stage)] + + +def pluginkit_version(root: Path) -> int: + path = root / PLUGINKIT_CONFIG + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise FeedError(f"{PLUGINKIT_CONFIG}: {error}") from error + found = re.search(r"^TABLEPRO_PLUGINKIT_VERSION\s*=\s*([0-9]+)\s*$", text, re.MULTILINE) + if found is None: + raise FeedError(f"{PLUGINKIT_CONFIG} sets no TABLEPRO_PLUGINKIT_VERSION") + return int(found.group(1)) + + +def check_archive(path: Path, release: version.Version) -> None: + name = "TablePro.app/Contents/Info.plist" + try: + with tarfile.open(path, "r:xz") as archive: + member = archive.extractfile(name) + info = plistlib.loads(member.read()) if member is not None else None + except KeyError: + info = None + except (tarfile.TarError, lzma.LZMAError, plistlib.InvalidFileException, ValueError, OSError) as error: + raise FeedError(f"{path.name}: {error}") from error + if info is None: + raise FeedError(f"{path.name} has no {name}") + found = (info.get("CFBundleShortVersionString"), info.get("CFBundleVersion")) + if found != (str(release), str(release.build)): + raise FeedError(f"{path.name} holds {found[0]} ({found[1]}), not {release} ({release.build})") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +class Releases: + def __init__(self, repository: str, run: Callable = subprocess.run): + self.repository = repository + self._run = run + + def _gh(self, *args: str) -> str: + result = self._run(["gh", *args, "--repo", self.repository], capture_output=True, text=True) + if result.returncode != 0: + raise FeedError(f"gh {' '.join(args[:3])}: {result.stderr.strip()}") + return result.stdout + + def digests(self, tag: str) -> Dict[str, str]: + release = json.loads(self._gh("release", "view", tag, "--json", "isDraft,assets")) + if release.get("isDraft"): + raise FeedError(f"{tag} is a draft; the feed only points at a published release") + return {asset["name"]: asset.get("digest") or "" for asset in release.get("assets", [])} + + def download(self, tag: str, names: List[str], directory: Path) -> None: + patterns = [argument for name in names for argument in ("--pattern", name)] + self._gh("release", "download", tag, "--dir", str(directory), "--clobber", *patterns) + + +def fetch_assets(releases: Releases, tag: str, names: List[str], directory: Path) -> None: + wanted = [name for name in names if not (directory / name).is_file()] + if not wanted: + return + digests = releases.digests(tag) + missing = [name for name in wanted if name not in digests] + if missing: + raise FeedError(f"{tag} has no {', '.join(missing)}") + directory.mkdir(parents=True, exist_ok=True) + releases.download(tag, wanted, directory) + for name in wanted: + path = directory / name + if not path.is_file(): + raise FeedError(f"gh did not download {name} from {tag}") + found = f"sha256:{_sha256(path)}" + if digests[name] != found: + path.unlink() + raise FeedError(f"{name} downloaded as {found}, but {tag} lists {digests[name] or 'no digest'}") + + +def _git(root: Path, *args: str, run: Callable = subprocess.run, check: bool = True, + env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess: + result = run(["git", "-C", str(root), *args], capture_output=True, text=True, + env={**os.environ, **env} if env else None) + if check and result.returncode != 0: + raise FeedError(f"git {' '.join(args)}: {result.stderr.strip()}") + return result + + +@contextmanager +def feeds_worktree(root: Path, run: Callable = subprocess.run) -> Iterator[Path]: + _git(root, "fetch", "--quiet", "--no-tags", "origin", f"+refs/heads/{BRANCH}:{REMOTE_BRANCH}", run=run) + parent = Path(tempfile.mkdtemp(prefix="feeds-")) + tree = parent / BRANCH + _git(root, "worktree", "add", "--quiet", "--detach", str(tree), REMOTE_BRANCH, run=run) + try: + yield tree + finally: + _git(root, "worktree", "remove", "--force", str(tree), run=run, check=False) + shutil.rmtree(parent, ignore_errors=True) + + +def commit(tree: Path, message: str, run: Callable = subprocess.run) -> bool: + paths = [path for path in (APPCAST, MANIFEST) if (tree / path).exists()] + _git(tree, "add", "--", *paths, run=run) + if _git(tree, "diff", "--cached", "--quiet", run=run, check=False).returncode == 0: + return False + identity = {f"GIT_{role}_{field}": value for role in ("AUTHOR", "COMMITTER") + for field, value in (("NAME", BOT_NAME), ("EMAIL", BOT_EMAIL))} + _git(tree, "commit", "--quiet", "-m", message, run=run, env=identity) + return True + + +def push(tree: Path, run: Callable = subprocess.run) -> bool: + result = _git(tree, "push", "--quiet", "origin", f"HEAD:refs/heads/{BRANCH}", run=run, check=False) + if result.returncode == 0: + return True + # A rejected fast-forward or a lost ref lock means another run pushed first. + if "[rejected]" in result.stderr or ("[remote rejected]" in result.stderr and "cannot lock ref" in result.stderr): + return False + raise FeedError(f"git push origin {BRANCH}: {result.stderr.strip()}") + + +def _tool(tools: Path, name: str, key: str, *args: str, run: Callable = subprocess.run) -> str: + result = run([str(tools / name), *args], input=key, capture_output=True, text=True) + output = (result.stdout + result.stderr).strip() + if result.returncode != 0: + raise FeedError(f"{name} failed: {output}") + return output + + +def sign(path: Path, key: str, tools: Path, run: Callable = subprocess.run) -> str: + return _tool(tools, "sign_update", key, "--ed-key-file", "-", str(path), run=run) + + +def verify_signature(path: Path, key: str, tools: Path, run: Callable = subprocess.run) -> List[str]: + try: + _tool(tools, "sign_update", key, "--verify", "--ed-key-file", "-", str(path), run=run) + except FeedError as error: + return [str(error)] + return [] + + +def _refuse(problems: List[str]) -> None: + if problems: + raise FeedError("the feed fails its checks, so nothing was pushed:\n " + "\n ".join(problems)) + + +def _publish(tree: Path, message: str, run: Callable, log: Callable[[str], None]) -> Optional[bool]: + if not commit(tree, message, run): + log(f"origin/{BRANCH} already has everything from '{message}'") + return False + if push(tree, run): + log(f"pushed '{message}' to origin/{BRANCH}") + return True + return None + + +def add(root: Path, tag: str, build: int, channel: str, notes: Path, critical: bool, tools: Path, key: str, + releases: Releases, repository: str, run: Callable = subprocess.run, + log: Callable[[str], None] = print) -> bool: + release = version.parse_tag(tag) + if build != release.build or channel != release.channel: + raise FeedError(f"{tag} is build {release.build} on {release.channel}, not build {build} on {channel}") + if critical and release.is_beta: + raise FeedError(f"{tag} is a beta, and a beta is never critical") + if not notes.is_file(): + raise FeedError(f"{notes} does not exist") + metadata = registry.load_metadata(root / METADATA) + bundled = registry.bundled_targets((root / PROJECT).read_text(encoding="utf-8")) + plugins = [f"{target}-{release}.zip" for target in registry.publish_set(metadata, bundled)] + kit = pluginkit_version(root) + archive = f"TablePro-{release}.tar.xz" + url_base = f"https://github.com/{repository}/releases/download/{tag}/" + expected = Expected(release, url_base + archive) + + with tempfile.TemporaryDirectory(prefix="feed-") as scratch: + downloads = Path(scratch) / "downloads" + entries = None + for attempt in range(1, PUSH_ATTEMPTS + 1): + wanted: Optional[Expected] = expected + with feeds_worktree(root, run) as tree: + appcast = tree / APPCAST + if not appcast.is_file(): + raise FeedError(f"origin/{BRANCH} has no {APPCAST}; seed the branch first (RELEASING.md)") + previous = appcast.read_bytes() + items = parse_items(previous) + if appcast_has(items, release): + log(f"{APPCAST} already has {release} ({release.build})") + else: + fetch_assets(releases, tag, [archive], downloads) + check_archive(downloads / archive, release) + stage = Path(scratch) / f"stage-{attempt}" + stage.mkdir() + shutil.copy2(downloads / archive, stage / archive) + shutil.copy2(notes, stage / f"TablePro-{release}.md") + shutil.copy2(appcast, stage / APPCAST) + critical_build = release.build if critical else None + if critical_build is None and not release.is_beta: + critical_build = carried_critical(items) + arguments = appcast_arguments(stage, tag, release, critical_build, repository) + log(_tool(tools, "generate_appcast", key, *arguments, run=run)) + if appcast_has(parse_items((stage / APPCAST).read_bytes()), release): + shutil.copy2(stage / APPCAST, appcast) + # generate_appcast signs the feed only once an archive sets SURequireSignedFeed. + log(sign(appcast, key, tools, run)) + else: + # The archive matched the tag, so only Sparkle's per-branch limit left it out. + log(f"generate_appcast keeps newer items on {release}'s branch, so {APPCAST} stays as it is") + wanted = None + if entries is None: + fetch_assets(releases, tag, plugins, downloads) + entries = registry.describe(downloads, str(release), release.build, kit, metadata, bundled) + registry.add(tree / MANIFEST, entries, release.channel, url_base, metadata["retainFromPluginKit"], log) + _refuse(feed_problems(appcast.read_bytes(), previous, wanted) + + verify_signature(appcast, key, tools, run)) + pushed = _publish(tree, f"release: {tag}", run, log) + if pushed is not None: + return pushed + log(f"origin/{BRANCH} moved (attempt {attempt} of {PUSH_ATTEMPTS}); starting again from a fresh fetch") + raise FeedError(f"origin/{BRANCH} moved during each of {PUSH_ATTEMPTS} attempts") + + +def withdraw(root: Path, text: str, tools: Path, key: str, run: Callable = subprocess.run, + log: Callable[[str], None] = print) -> bool: + release = version.parse_version(text) + for attempt in range(1, PUSH_ATTEMPTS + 1): + with feeds_worktree(root, run) as tree: + appcast = tree / APPCAST + previous = appcast.read_bytes() + edited, removed = remove_items(previous.decode("utf-8"), str(release)) + if removed: + if any(item.channel is None for item in parse_items(previous)) \ + and not any(item.channel is None for item in parse_items(edited.encode("utf-8"))): + raise FeedError(f"withdrawing {release} would leave the feed with no default-channel item") + appcast.write_text(edited, encoding="utf-8") + log(sign(appcast, key, tools, run)) + log(f"removed {removed} {release} item(s) from {APPCAST}") + else: + log(f"{APPCAST} has no {release} item") + if (tree / MANIFEST).exists(): + registry.withdraw(tree / MANIFEST, str(release), log) + _refuse(feed_problems(appcast.read_bytes(), previous) + verify_signature(appcast, key, tools, run)) + pushed = _publish(tree, f"withdraw: v{release}", run, log) + if pushed is not None: + return pushed + log(f"origin/{BRANCH} moved (attempt {attempt} of {PUSH_ATTEMPTS}); starting again from a fresh fetch") + raise FeedError(f"origin/{BRANCH} moved during each of {PUSH_ATTEMPTS} attempts") + + +def check(root: Path, tools: Path, key: str, run: Callable = subprocess.run) -> List[str]: + with feeds_worktree(root, run) as tree: + appcast = tree / APPCAST + if not appcast.is_file(): + return [f"origin/{BRANCH} has no {APPCAST}"] + problems = feed_problems(appcast.read_bytes()) + verify_signature(appcast, key, tools, run) + try: + if registry.load_manifest(tree / MANIFEST) is None: + problems.append(f"origin/{BRANCH} has no {MANIFEST}") + except registry.RegistryError as error: + problems.append(str(error)) + return problems + + +def published_items(root: Path, run: Callable = subprocess.run) -> List[Item]: + _git(root, "fetch", "--quiet", "--no-tags", "origin", f"+refs/heads/{BRANCH}:{REMOTE_BRANCH}", run=run) + result = run(["git", "-C", str(root), "show", f"{REMOTE_BRANCH}:{APPCAST}"], capture_output=True) + if result.returncode != 0: + raise FeedError(f"origin/{BRANCH} has no {APPCAST}: {result.stderr.decode(errors='replace').strip()}") + return parse_items(result.stdout) + + +def _environment() -> Tuple[Path, str]: + tools = os.environ.get("SPARKLE_BIN") + key = os.environ.get("SPARKLE_PRIVATE_KEY") + if not tools: + raise FeedError("SPARKLE_BIN is not set; run the setup-sparkle action first") + if not key: + raise FeedError("SPARKLE_PRIVATE_KEY is not set") + for name in ("generate_appcast", "sign_update"): + if not os.access(Path(tools) / name, os.X_OK): + raise FeedError(f"{tools} has no {name}") + return Path(tools), key + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Writes the appcast and the plugin manifest on the feeds branch.") + commands = parser.add_subparsers(dest="command", required=True) + command = commands.add_parser("add", help="add a published release to the feed and the manifest") + command.add_argument("tag") + command.add_argument("--build", type=int, required=True) + command.add_argument("--channel", choices=registry.CHANNELS, required=True) + command.add_argument("--notes", type=Path, required=True) + command.add_argument("--critical", action="store_true") + commands.add_parser("withdraw", help="remove a version from the feed and the manifest").add_argument("version") + commands.add_parser("check", help="check the published feed") + args = parser.parse_args(argv) + + repository = os.environ.get("GITHUB_REPOSITORY", version.REPOSITORY) + try: + tools, key = _environment() + if args.command == "add": + add(ROOT, args.tag, args.build, args.channel, args.notes, args.critical, tools, key, + Releases(repository), repository) + elif args.command == "withdraw": + withdraw(ROOT, args.version, tools, key) + else: + problems = check(ROOT, tools, key) + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + if problems: + return 1 + print(f"origin/{BRANCH} passes its checks") + except (FeedError, registry.RegistryError, ValueError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/publish.py b/scripts/release/publish.py new file mode 100755 index 0000000000..43902cc940 --- /dev/null +++ b/scripts/release/publish.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, Dict, List, Optional + +import feed +import version + +ROOT = Path(__file__).resolve().parent.parent.parent +FIND_ATTEMPTS = 5 +FIND_DELAY_SECONDS = 2 + + +class PublishError(Exception): + pass + + +def draft_action(is_published: bool, has_draft: bool) -> str: + if is_published: + return "published" + return "reuse" if has_draft else "create" + + +def should_be_latest(release: version.Version, latest_tag: Optional[str]) -> bool: + if release.is_beta: + return False + latest = version.try_parse_tag(latest_tag) if latest_tag else None + return latest is None or release.build > latest.build + + +def release_flags(release: version.Version, latest_tag: Optional[str]) -> List[str]: + flags = ["--draft=false"] + if release.is_beta: + return flags + ["--prerelease", "--latest=false"] + return flags + [f"--latest={'true' if should_be_latest(release, latest_tag) else 'false'}"] + + +def highest_stable(items: List[feed.Item]) -> Optional[version.Version]: + stables = [] + for item in items: + if item.channel is not None: + continue + parsed = version.try_parse_tag(f"v{item.short_version}") if item.short_version else None + if parsed is not None and not parsed.is_beta: + stables.append(parsed) + return max(stables, key=lambda candidate: candidate.build) if stables else None + + +def asset_problems(files: Dict[str, str], assets: List[dict]) -> List[str]: + found = {asset.get("name"): asset for asset in assets} + problems = [] + for name, digest in sorted(files.items()): + asset = found.get(name) + if asset is None: + problems.append(f"the release has no {name}") + elif asset.get("state") != "uploaded": + problems.append(f"{name} is {asset.get('state')}, not uploaded") + elif asset.get("digest") != f"sha256:{digest}": + found_digest = asset.get("digest") or "missing its digest" + problems.append(f"{name} is {found_digest} on GitHub but sha256:{digest} here") + return problems + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +class GitHub: + def __init__(self, repository: str, run: Callable = subprocess.run): + self.repository = repository + self._run = run + + def _gh(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + result = self._run(["gh", *args], capture_output=True, text=True) + if check and result.returncode != 0: + raise PublishError(f"gh {' '.join(args[:3])}: {result.stderr.strip()}") + return result + + def _release(self, *args: str) -> List[str]: + return ["release", *args, "--repo", self.repository] + + def is_published(self, tag: str) -> bool: + result = self._gh("api", f"repos/{self.repository}/releases/tags/{tag}", "--jq", ".draft", check=False) + if result.returncode == 0: + return result.stdout.strip() == "false" + if "HTTP 404" in result.stderr: + return False + raise PublishError(f"gh api releases/tags/{tag}: {result.stderr.strip()}") + + # gh races its published and draft lookups, so this runs only once no published release holds the tag. + def draft(self, tag: str) -> Optional[dict]: + result = self._gh(*self._release("view", tag, "--json", "isDraft,assets"), check=False) + if result.returncode != 0: + if "release not found" in result.stderr: + return None + raise PublishError(f"gh release view {tag}: {result.stderr.strip()}") + release = json.loads(result.stdout) + if not release.get("isDraft"): + raise PublishError(f"{tag} was published while this ran") + return release + + def create_draft(self, tag: str, notes: Path, files: List[Path], prerelease: bool) -> None: + self._gh(*self._release("create", tag, "--draft", "--verify-tag", "--title", tag, "--notes-file", str(notes), + f"--prerelease={'true' if prerelease else 'false'}", *[str(path) for path in files])) + + def update_draft(self, tag: str, notes: Path, files: List[Path], prerelease: bool) -> None: + self._gh(*self._release("edit", tag, "--title", tag, "--notes-file", str(notes), + f"--prerelease={'true' if prerelease else 'false'}")) + self._gh(*self._release("upload", tag, "--clobber", *[str(path) for path in files])) + + def edit(self, tag: str, flags: List[str]) -> None: + self._gh(*self._release("edit", tag, *flags)) + + def latest_tag(self) -> Optional[str]: + result = self._gh("api", f"repos/{self.repository}/releases/latest", "--jq", ".tag_name", check=False) + if result.returncode == 0: + return result.stdout.strip() or None + if "HTTP 404" in result.stderr: + return None + raise PublishError(f"gh api releases/latest: {result.stderr.strip()}") + + +def draft(github: GitHub, tag: str, notes: Path, files: List[Path], sleep: Callable[[float], None] = time.sleep, + log: Callable[[str], None] = print) -> str: + parsed = version.parse_tag(tag) + missing = [str(path) for path in [notes, *files] if not path.is_file()] + if missing: + raise PublishError(f"{', '.join(missing)} does not exist") + names = [path.name for path in files] + if len(set(names)) != len(names): + raise PublishError("two assets share a file name") + + published = github.is_published(tag) + existing = None if published else github.draft(tag) + action = draft_action(published, existing is not None) + if action == "published": + log(f"{tag} is already published") + return "published" + if action == "create": + github.create_draft(tag, notes, files, parsed.is_beta) + log(f"created the draft for {tag}") + else: + github.update_draft(tag, notes, files, parsed.is_beta) + log(f"reused the draft an earlier attempt left for {tag}") + + current = None + for _ in range(FIND_ATTEMPTS): + current = github.draft(tag) + if current is not None: + break + sleep(FIND_DELAY_SECONDS) + if current is None: + raise PublishError(f"the draft for {tag} was created but cannot be found") + problems = asset_problems({path.name: _sha256(path) for path in files}, current.get("assets", [])) + if problems: + raise PublishError(f"the draft for {tag} does not hold this build, so it stays unpublished:\n " + + "\n ".join(problems)) + log(f"the draft for {tag} holds all {len(files)} assets") + return "draft" + + +def release(github: GitHub, tag: str, log: Callable[[str], None] = print) -> None: + parsed = version.parse_tag(tag) + if github.is_published(tag): + log(f"{tag} is already published") + return + if github.draft(tag) is None: + raise PublishError(f"{tag} has no draft; run publish.py draft first") + flags = release_flags(parsed, github.latest_tag()) + github.edit(tag, flags) + log(f"published {tag} ({' '.join(flags[1:])})") + + +def latest(github: GitHub, items: List[feed.Item], log: Callable[[str], None] = print) -> str: + highest = highest_stable(items) + if highest is None: + raise PublishError("the feed has no stable item") + github.edit(highest.tag, ["--latest=true"]) + log(f"{highest.tag} is Latest") + return highest.tag + + +def _stderr(message: str) -> None: + print(message, file=sys.stderr) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Drafts, publishes and marks GitHub releases.") + commands = parser.add_subparsers(dest="command", required=True) + command = commands.add_parser("draft", help="put every asset on a draft and check its digests") + command.add_argument("tag") + command.add_argument("--notes", type=Path, required=True) + command.add_argument("files", type=Path, nargs="+") + commands.add_parser("release", help="publish the draft").add_argument("tag") + commands.add_parser("latest", help="mark the highest stable left in the feed as Latest") + args = parser.parse_args(argv) + + github = GitHub(os.environ.get("GITHUB_REPOSITORY", version.REPOSITORY)) + try: + if args.command == "draft": + print(f"state={draft(github, args.tag, args.notes, args.files, log=_stderr)}") + elif args.command == "release": + release(github, args.tag, _stderr) + else: + latest(github, feed.published_items(ROOT), _stderr) + except (PublishError, feed.FeedError, ValueError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/registry.py b/scripts/release/registry.py new file mode 100755 index 0000000000..2bd77268ed --- /dev/null +++ b/scripts/release/registry.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +import plistlib +import re +import sys +import zipfile +from pathlib import Path +from typing import Callable, Dict, List, Optional, Set + +import version + +ROOT = Path(__file__).resolve().parent.parent.parent +METADATA = ROOT / ".github/plugin-registry.json" +PROJECT = ROOT / "project.yml" +SCHEMA_VERSION = 3 +CHANNELS = ("stable", "beta") +RELEASES_PER_KEY = 2 +PROVIDES = ("TableProProvidesDatabaseTypeIds", "TableProProvidesExportFormatIds", "TableProProvidesImportFormatIds") +SYSTEM_VERSION = re.compile(r"[0-9]+(\.[0-9]+){0,2}") +SHA256 = re.compile(r"[0-9a-f]{64}") + + +class RegistryError(Exception): + pass + + +def load_metadata(path: Path) -> dict: + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise RegistryError(f"{path}: {error}") from error + shape = "{retainFromPluginKit, plugins: {: {name, iconName}}}" + plugins = metadata.get("plugins") if isinstance(metadata, dict) else None + if not isinstance(plugins, dict) or not _is_int(metadata.get("retainFromPluginKit")): + raise RegistryError(f"{path} is not in the {shape} shape") + for target, entry in plugins.items(): + if not isinstance(entry, dict) or not all(isinstance(entry.get(key), str) for key in ("name", "iconName")): + raise RegistryError(f"{path}: {target} needs a name and an iconName, as in {shape}") + return metadata + + +def bundled_targets(project: str) -> Set[str]: + block = re.search(r"^aggregateTargets:\n(?:^(?: .*)?\n)*?^ BundledPlugins:\n^ targets:\n((?:^ - .*\n)+)", + project, re.MULTILINE) + if block is None: + raise RegistryError("project.yml has no BundledPlugins aggregate with a targets list") + return {line.strip()[2:].strip() for line in block.group(1).splitlines()} + + +def publish_set(metadata: dict, bundled: Set[str]) -> List[str]: + targets = sorted(metadata["plugins"]) + inside = [target for target in targets if target in bundled] + if inside: + raise RegistryError(f"the app bundles {', '.join(inside)}, so the registry must not publish them") + if not targets: + raise RegistryError("the registry metadata publishes no plugin") + return targets + + +def _is_int(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_info(archive: Path, target: str) -> dict: + name = f"{target}.tableplugin/Contents/Info.plist" + try: + with zipfile.ZipFile(archive) as bundle: + return plistlib.loads(bundle.read(name)) + except KeyError as error: + raise RegistryError(f"{archive.name} has no {name}") from error + except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException, ValueError) as error: + raise RegistryError(f"{archive.name}: {error}") from error + + +def describe_one(archive: Path, target: str, entry: dict, release: str, build: int, kit: int) -> dict: + info = read_info(archive, target) + problems = [] + if info.get("CFBundleShortVersionString") != release: + problems.append(f"CFBundleShortVersionString is {info.get('CFBundleShortVersionString')!r}, not {release!r}") + if info.get("CFBundleVersion") != str(build): + problems.append(f"CFBundleVersion is {info.get('CFBundleVersion')!r}, not '{build}'") + found_kit = info.get("TableProPluginKitVersion") + if not _is_int(found_kit) or found_kit != kit: + problems.append(f"TableProPluginKitVersion is {found_kit!r}, not the integer {kit}") + identifier = info.get("CFBundleIdentifier") + if not isinstance(identifier, str) or not identifier: + problems.append("CFBundleIdentifier is missing") + system = info.get("LSMinimumSystemVersion") + if not isinstance(system, str) or not SYSTEM_VERSION.fullmatch(system): + problems.append(f"LSMinimumSystemVersion is {system!r}") + type_ids = info.get(PROVIDES[0], []) + if not isinstance(type_ids, list) or not all(isinstance(type_id, str) for type_id in type_ids): + problems.append(f"{PROVIDES[0]} is not a list of strings") + # The app finds the plugin that serves a database type or a file format through these keys. + if not any(info.get(key) for key in PROVIDES): + problems.append(f"it declares none of {', '.join(PROVIDES)}") + if problems: + raise RegistryError(f"{archive.name}: " + "; ".join(problems)) + return { + "id": identifier, + "name": entry["name"], + "iconName": entry["iconName"], + "databaseTypeIds": type_ids, + "version": release, + "build": build, + "pluginKit": kit, + "minimumSystemVersion": system, + "file": archive.name, + "sha256": _sha256(archive), + } + + +def describe(zips: Path, release: str, build: int, kit: int, metadata: dict, bundled: Set[str]) -> List[dict]: + entries = [] + for target in publish_set(metadata, bundled): + archive = zips / f"{target}-{release}.zip" + if not archive.is_file(): + raise RegistryError(f"{archive} does not exist") + entries.append(describe_one(archive, target, metadata["plugins"][target], release, build, kit)) + seen: Dict[str, str] = {} + for entry in entries: + if entry["id"] in seen: + raise RegistryError(f"{entry['file']} and {seen[entry['id']]} share the bundle id {entry['id']}") + seen[entry["id"]] = entry["file"] + return entries + + +def empty_manifest(retain_from: int) -> dict: + return {"schemaVersion": SCHEMA_VERSION, "retainFromPluginKit": retain_from, "plugins": []} + + +def load_manifest(path: Path) -> Optional[dict]: + if not path.exists(): + return None + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise RegistryError(f"{path}: {error}") from error + if not isinstance(manifest, dict) or manifest.get("schemaVersion") != SCHEMA_VERSION: + found = manifest.get("schemaVersion") if isinstance(manifest, dict) else None + raise RegistryError(f"{path} has schemaVersion {found!r}; this tool writes only {SCHEMA_VERSION}") + if not _is_int(manifest.get("retainFromPluginKit")) or not isinstance(manifest.get("plugins"), list): + raise RegistryError(f"{path} needs an integer retainFromPluginKit and a plugins list") + for plugin in manifest["plugins"]: + if not isinstance(plugin, dict) or not isinstance(plugin.get("id"), str) \ + or not isinstance(plugin.get("releases"), list): + raise RegistryError(f"{path}: every plugin needs an id and a releases list") + for release in plugin["releases"]: + if not isinstance(release, dict) or not all(_is_int(release.get(key)) for key in ("build", "pluginKit")) \ + or release.get("channel") not in CHANNELS \ + or not all(isinstance(release.get(key), str) for key in ("version", "minimumSystemVersion")): + raise RegistryError(f"{path}: a release of {plugin['id']} lacks an integer build or pluginKit, " + "a channel, a version or a minimumSystemVersion") + return manifest + + +def write_manifest(path: Path, manifest: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def retain(releases: List[dict], floor: int) -> List[dict]: + kept = [release for release in releases if release["pluginKit"] >= floor] + kept = [ + release for release in kept + if release["channel"] != "beta" or not any( + other["channel"] == "stable" and other["pluginKit"] == release["pluginKit"] + and other["minimumSystemVersion"] == release["minimumSystemVersion"] and other["build"] > release["build"] + for other in kept) + ] + groups: Dict[tuple, List[dict]] = {} + for release in sorted(kept, key=lambda candidate: candidate["build"], reverse=True): + key = (release["channel"], release["pluginKit"], release["minimumSystemVersion"]) + groups.setdefault(key, []).append(release) + survivors = [release for group in groups.values() for release in group[:RELEASES_PER_KEY]] + return sorted(survivors, key=lambda candidate: candidate["build"], reverse=True) + + +def _release(entry: dict, channel: str, url_base: str) -> dict: + return { + "version": entry["version"], + "build": entry["build"], + "channel": channel, + "pluginKit": entry["pluginKit"], + "minimumSystemVersion": entry["minimumSystemVersion"], + "url": url_base.rstrip("/") + "/" + entry["file"], + "sha256": entry["sha256"], + } + + +def _check_entry(entry: dict, channel: str) -> None: + missing = [key for key in ("id", "name", "iconName", "databaseTypeIds", "version", "build", "pluginKit", + "minimumSystemVersion", "file", "sha256") if key not in entry] + if missing: + raise RegistryError(f"an entry has no {', '.join(missing)}") + if not SHA256.fullmatch(str(entry["sha256"])): + raise RegistryError(f"{entry['id']}: sha256 {entry['sha256']!r} is not 64 lowercase hex digits") + parsed = version.parse_version(entry["version"]) + if parsed.build != entry["build"]: + raise RegistryError(f"{entry['id']}: build {entry['build']} is not {parsed.build}, the build of {parsed}") + if parsed.channel != channel: + raise RegistryError(f"{entry['id']}: {parsed} belongs to the {parsed.channel} channel, not {channel}") + + +def add(path: Path, entries: List[dict], channel: str, url_base: str, retain_from: int, + log: Callable[[str], None] = print) -> bool: + if channel not in CHANNELS: + raise RegistryError(f"channel {channel!r} is not one of {', '.join(CHANNELS)}") + if not url_base.startswith("https://"): + raise RegistryError(f"the URL base {url_base!r} is not https") + for entry in entries: + _check_entry(entry, channel) + loaded = load_manifest(path) + before = json.dumps(loaded, sort_keys=True) if loaded is not None else None + manifest = loaded if loaded is not None else empty_manifest(retain_from) + floor = max(manifest["retainFromPluginKit"], retain_from) + plugins = {plugin["id"]: plugin for plugin in manifest["plugins"]} + + arrived = [] + for entry in entries: + release = _release(entry, channel, url_base) + plugin = plugins.setdefault(entry["id"], {"id": entry["id"], "releases": []}) + same = [existing for existing in plugin["releases"] if existing["build"] == release["build"]] + if same and same[0].get("sha256") != release["sha256"]: + raise RegistryError(f"{entry['id']} already has build {release['build']} with sha256 " + f"{same[0].get('sha256')}, not {release['sha256']}") + if same: + log(f"{entry['id']} {release['version']}: already in the manifest") + continue + plugin["releases"].append(release) + arrived.append((entry, release)) + + for plugin in plugins.values(): + plugin["releases"] = retain(plugin["releases"], floor) + for entry, release in arrived: + plugin = plugins[entry["id"]] + if not any(kept is release for kept in plugin["releases"]): + log(f"{entry['id']} {release['version']}: pruned on arrival by retention, not added") + elif plugin["releases"][0] is release: + plugin.update({key: entry[key] for key in ("name", "iconName", "databaseTypeIds")}) + + manifest = { + "schemaVersion": SCHEMA_VERSION, + "retainFromPluginKit": floor, + "plugins": [_ordered(plugins[key]) for key in sorted(plugins) if plugins[key]["releases"]], + } + if json.dumps(manifest, sort_keys=True) == before: + return False + write_manifest(path, manifest) + return True + + +def _ordered(plugin: dict) -> dict: + keys = ("id", "name", "iconName", "databaseTypeIds") + return {**{key: plugin[key] for key in keys if key in plugin}, "releases": plugin["releases"]} + + +def withdraw(path: Path, release: str, log: Callable[[str], None] = print) -> bool: + version.parse_version(release) + manifest = load_manifest(path) + if manifest is None: + raise RegistryError(f"{path} does not exist") + removed = 0 + for plugin in manifest["plugins"]: + kept = [candidate for candidate in plugin["releases"] if candidate.get("version") != release] + removed += len(plugin["releases"]) - len(kept) + plugin["releases"] = kept + if not removed: + log(f"{path.name} has no {release} release") + return False + manifest["plugins"] = [plugin for plugin in manifest["plugins"] if plugin["releases"]] + write_manifest(path, manifest) + log(f"withdrew {removed} {release} release(s) from {path.name}") + return True + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="The registry plugins and the v3 plugin manifest.") + commands = parser.add_subparsers(dest="command", required=True) + command = commands.add_parser("targets", help="print the targets the registry publishes") + command.add_argument("--metadata", type=Path, default=METADATA) + command.add_argument("--project", type=Path, default=PROJECT) + command = commands.add_parser("describe", help="read the built plugin zips into manifest entries") + command.add_argument("--zips", type=Path, required=True) + command.add_argument("--version", required=True) + command.add_argument("--build", type=int, required=True) + command.add_argument("--kit", type=int, required=True) + command.add_argument("--metadata", type=Path, required=True) + command.add_argument("--project", type=Path, default=PROJECT) + command.add_argument("--out", type=Path, required=True) + command = commands.add_parser("add", help="add described releases to the manifest") + command.add_argument("--manifest", type=Path, required=True) + command.add_argument("--entries", type=Path, required=True) + command.add_argument("--channel", choices=CHANNELS, required=True) + command.add_argument("--url-base", required=True) + command.add_argument("--retain-from", type=int, required=True) + command = commands.add_parser("withdraw", help="remove one version's releases from the manifest") + command.add_argument("--manifest", type=Path, required=True) + command.add_argument("--version", required=True) + args = parser.parse_args(argv) + + try: + if args.command == "targets": + bundled = bundled_targets(args.project.read_text(encoding="utf-8")) + print("\n".join(publish_set(load_metadata(args.metadata), bundled))) + elif args.command == "describe": + bundled = bundled_targets(args.project.read_text(encoding="utf-8")) + entries = describe(args.zips, args.version, args.build, args.kit, load_metadata(args.metadata), bundled) + args.out.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") + print(f"described {len(entries)} plugins in {args.out}") + elif args.command == "add": + entries = json.loads(args.entries.read_text(encoding="utf-8")) + if not isinstance(entries, list): + raise RegistryError(f"{args.entries} is not a list of entries") + changed = add(args.manifest, entries, args.channel, args.url_base, args.retain_from) + print(f"{args.manifest} {'updated' if changed else 'unchanged'}") + elif args.command == "withdraw": + withdraw(args.manifest, args.version) + except (RegistryError, ValueError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/test_feed.py b/scripts/release/test_feed.py new file mode 100644 index 0000000000..2b5abc4584 --- /dev/null +++ b/scripts/release/test_feed.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 + +import io +import json +import os +import plistlib +import subprocess +import sys +import tarfile +import tempfile +import unittest +import zipfile +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + +import feed +import registry +import version + +KEY = "cHJvYmUta2V5LW5vdC1hLXJlYWwtb25l" +REPOSITORY = "TableProApp/TablePro" +GIT_ENV = { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.com", + "GIT_COMMITTER_NAME": "Fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.com", +} + +SIGNATURE = r''' +import hashlib, re +TRAILER = re.compile(r"\s*$", re.S) +def content(text): + return TRAILER.sub("", text) +def signed(text, key): + body = content(text) + signature = hashlib.sha256((key.strip() + body).encode()).hexdigest() + return body + "\n" +''' + +FAKE_GH = SIGNATURE + r''' +import json, os, shutil, sys +from pathlib import Path +args = sys.argv[1:] +with open(os.environ["FAKE_LOG"], "a") as log: + log.write(json.dumps(["gh", *args]) + "\n") +releases = Path(os.environ["FAKE_RELEASES"]) +if args[:2] == ["release", "view"]: + folder = releases / args[2] + if not folder.is_dir(): + sys.exit("release not found") + assets = [] + for path in sorted(folder.iterdir()): + digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if path.name == os.environ.get("FAKE_BAD_DIGEST"): + digest = "sha256:" + "0" * 64 + assets.append({"name": path.name, "digest": digest, "state": "uploaded"}) + print(json.dumps({"isDraft": os.environ.get("FAKE_DRAFT") == "1", "assets": assets})) +elif args[:2] == ["release", "download"]: + out = Path(args[args.index("--dir") + 1]) + for index, arg in enumerate(args): + if arg == "--pattern": + shutil.copy(releases / args[2] / args[index + 1], out / args[index + 1]) +else: + sys.exit("fake gh cannot answer " + " ".join(args)) +''' + +FAKE_GENERATE_APPCAST = SIGNATURE + r''' +import json, os, subprocess, sys +from pathlib import Path +args = sys.argv[1:] +key = sys.stdin.read() +with open(os.environ["FAKE_LOG"], "a") as log: + log.write(json.dumps(["generate_appcast", *args, "stdin=" + key.strip()]) + "\n") +race = os.environ.get("FAKE_RACE_REPO") +if race and not os.path.exists(race + ".done"): + open(race + ".done", "w").close() + subprocess.run(["git", "-C", race, "push", "--quiet", "origin", "HEAD:refs/heads/feeds"], check=True) +def option(name): + return args[args.index(name) + 1] if name in args else None +stage = Path(args[-1]) +appcast = stage / "appcast.xml" +if os.environ.get("FAKE_PRUNE"): + appcast.write_text(content(appcast.read_text())) + print("Wrote 0 new updates") + sys.exit(0) +archive = next(stage.glob("*.tar.xz")) +short = archive.name[len("TablePro-"):-len(".tar.xz")] +lines = ["", "" + short + ""] +if option("--channel"): + lines.append("" + option("--channel") + "") +if option("--phased-rollout-interval"): + interval = option("--phased-rollout-interval") + lines.append("" + interval + "") +if option("--critical-update-version") is not None: + lines.append('') +lines.append("" + option("--versions") + "") +lines.append("" + short + "") +notes = (stage / ("TablePro-" + short + ".md")).read_text() +lines.append('') +signature = "" if os.environ.get("FAKE_UNSIGNED_ENCLOSURE") else ' sparkle:edSignature="c2ln"' +lines.append('") +lines.append("") +text = content(appcast.read_text()) +index = text.index("") if "" in text else text.index("") +appcast.write_text(text[:index] + "\n".join(lines) + "\n" + text[index:]) +print("Wrote 1 new update") +''' + +FAKE_SIGN_UPDATE = SIGNATURE + r''' +import json, os, sys +from pathlib import Path +args = sys.argv[1:] +key = sys.stdin.read() +with open(os.environ["FAKE_LOG"], "a") as log: + log.write(json.dumps(["sign_update", *args, "stdin=" + key.strip()]) + "\n") +path = Path(args[-1]) +text = path.read_text() +if "--verify" in args: + if os.environ.get("FAKE_VERIFY_FAIL") or signed(text, key) != text: + print("Error: failed to pass signing verification.") + sys.exit(1) + sys.exit(0) +path.write_text(signed(text, key)) +print("") +''' + +SEED = """ + + + TablePro + + 0.81.0 + + 140 + 0.81.0 + + + + + 0.80.0 + 21600 + 139 + 0.80.0 + + + + +""" +METADATA = { + "retainFromPluginKit": 39, + "plugins": { + "OracleDriver": {"name": "Oracle Driver", "iconName": "oracle-icon"}, + "ParquetExport": {"name": "Parquet Export", "iconName": "parquet-icon"}, + }, +} +PROJECT = """aggregateTargets: + BundledPlugins: + targets: + - MySQLDriver +""" + + +def item(build, short, channel=None, critical=None, signature="c2ln", length="10"): + lines = [""] + if channel is not None: + lines.append(f"{channel}") + if critical is not None: + lines.append(f'' if critical else + "") + lines += [f"{build}", + f"{short}"] + signed = f' sparkle:edSignature="{signature}"' if signature else "" + lines += [f'', ""] + return "\n".join(lines) + + +def appcast(*items): + return (f'' + f'TablePro{"".join(items)}').encode() + + +class FeedRulesTests(unittest.TestCase): + def test_a_clean_feed_passes(self): + self.assertEqual(feed.feed_problems(appcast(item(820001, "0.82.0-beta.1", "beta"), item(140, "0.81.0"))), []) + + def test_each_rule(self): + cases = [ + (appcast(item(140, "0.81.0", signature="")), "has no sparkle:edSignature"), + (appcast(item(140, "0.81.0", length="")), "has no length"), + (appcast(item("1.4", "0.81.0")), "sparkle:version '1.4' is not an integer"), + (appcast(item(140, "0.81")), "shortVersionString '0.81' is not a release version"), + (appcast(item(820001, "0.82.0-beta.1", "alpha")), "the only channel is beta"), + (appcast(item(820001, "0.82.0-beta.1", "beta", critical="")), "a beta marked critical"), + (appcast(item(141, "0.81.0"), item(140, "0.82.0")), "0.81.0 has the higher build (141 over 140)"), + (appcast(item(140, "0.81.0"), item(140, "0.80.0")), "build 140 carries 0.80.0, 0.81.0"), + ] + for data, message in cases: + with self.subTest(message=message): + self.assertTrue(any(message in problem for problem in feed.feed_problems(data)), + feed.feed_problems(data)) + + def test_the_last_default_channel_item_cannot_go(self): + before = appcast(item(140, "0.81.0")) + after = appcast(item(820001, "0.82.0-beta.1", "beta")) + self.assertIn("the feed no longer has a default-channel item", feed.feed_problems(after, before)) + + def test_the_new_item_must_be_present(self): + release = version.parse_tag("v0.82.0-beta.1") + url = "https://example.com/TablePro-0.82.0-beta.1.tar.xz" + self.assertEqual(feed.feed_problems(appcast(item(820001, "0.82.0-beta.1", "beta")), None, + feed.Expected(release, url)), []) + problems = feed.feed_problems(appcast(item(820001, "0.82.0-beta.1")), None, feed.Expected(release, url)) + self.assertEqual(problems, [f"no beta item for 0.82.0-beta.1 (820001) at {url}"]) + + def test_the_critical_build_carries_forward(self): + items = feed.parse_items(appcast(item(820099, "0.82.0", critical="140"), item(140, "0.81.0", critical=""), + item(830001, "0.83.0-beta.1", "beta"))) + self.assertEqual(feed.carried_critical(items), 140) + self.assertEqual(feed.carried_critical(feed.parse_items(SEED.encode())), 140) + self.assertIsNone(feed.carried_critical(feed.parse_items(appcast(item(139, "0.80.0"))))) + + def test_the_generate_appcast_arguments(self): + stage = Path("/stage") + prefix = ["--ed-key-file", "-", "--embed-release-notes", "--maximum-deltas", "0"] + beta = feed.appcast_arguments(stage, "v0.82.0-beta.1", version.parse_tag("v0.82.0-beta.1"), None, REPOSITORY) + self.assertEqual(beta, prefix + [ + "--versions", "820001", + "--download-url-prefix", "https://github.com/TableProApp/TablePro/releases/download/v0.82.0-beta.1/", + "--full-release-notes-url", "https://github.com/TableProApp/TablePro/releases/tag/v0.82.0-beta.1", + "--channel", "beta", "/stage"]) + stable = feed.appcast_arguments(stage, "v0.82.0", version.parse_tag("v0.82.0"), 140, REPOSITORY) + self.assertEqual(stable[stable.index("--phased-rollout-interval"):], + ["--phased-rollout-interval", "21600", "--critical-update-version", "140", "/stage"]) + plain = feed.appcast_arguments(stage, "v0.82.1", version.parse_tag("v0.82.1"), None, REPOSITORY) + self.assertNotIn("--critical-update-version", plain) + self.assertNotIn("--channel", plain) + + def test_removes_items_by_version_and_keeps_the_rest_of_the_text(self): + text = SEED + edited, removed = feed.remove_items(text, "0.80.0") + self.assertEqual(removed, 1) + self.assertEqual([each.short_version for each in feed.parse_items(edited.encode())], ["0.81.0"]) + self.assertEqual(edited, text[:text.index(" \n 0.80.0")] + + text[text.index(" </channel>"):]) + + def test_refuses_to_edit_text_it_cannot_match(self): + text = SEED.replace("# 0.81.0", "# 0.81.0 </item> in the notes") + with self.assertRaises(feed.FeedError): + feed.remove_items(text, "0.81.0") + + +def git(path, *args): + return subprocess.run(["git", "-C", str(path), *args], env={**os.environ, **GIT_ENV}, check=True, + capture_output=True, text=True).stdout.strip() + + +def fake_signed(text, key=KEY): + namespace = {} + exec(SIGNATURE, namespace) + return namespace["signed"](text, key) + + +def app_archive(path, text, build): + info = plistlib.dumps({"CFBundleShortVersionString": text, "CFBundleVersion": str(build)}) + with tarfile.open(path, "w:xz") as archive: + member = tarfile.TarInfo("TablePro.app/Contents/Info.plist") + member.size = len(info) + archive.addfile(member, io.BytesIO(info)) + + +def plugin_zip(path, target, text, build, kit=39, **extra): + info = {"CFBundleIdentifier": f"com.TablePro.{target}", "CFBundleShortVersionString": text, + "CFBundleVersion": str(build), "TableProPluginKitVersion": kit, "LSMinimumSystemVersion": "13.0", + **extra} + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"{target}.tableplugin/Contents/Info.plist", plistlib.dumps(info)) + + +class FeedBranchTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.base = Path(directory.name) + self.tools = self.base / "bin" + self.tools.mkdir() + for name, source in (("gh", FAKE_GH), ("generate_appcast", FAKE_GENERATE_APPCAST), + ("sign_update", FAKE_SIGN_UPDATE)): + path = self.tools / name + path.write_text(f"#!{sys.executable}\n{source}") + path.chmod(0o755) + self.log_path = self.base / "calls.log" + self.releases = self.base / "releases" + environment = mock.patch.dict(os.environ, { + **GIT_ENV, + "PATH": f"{self.tools}{os.pathsep}{os.environ['PATH']}", + "FAKE_LOG": str(self.log_path), + "FAKE_RELEASES": str(self.releases), + }) + environment.start() + self.addCleanup(environment.stop) + + self.origin = self.base / "origin.git" + git(self.base, "init", "--quiet", "--bare", "--initial-branch=main", str(self.origin)) + seed = self.base / "seed" + git(self.base, "init", "--quiet", "--initial-branch=feeds", str(seed)) + (seed / "appcast.xml").write_text(SEED) + (seed / "plugins").mkdir() + (seed / "plugins/v3.json").write_text(json.dumps(registry.empty_manifest(39))) + git(seed, "add", ".") + git(seed, "commit", "--quiet", "-m", "seed") + git(seed, "push", "--quiet", str(self.origin), "feeds") + + self.root = self.base / "tag" + git(self.base, "init", "--quiet", "--initial-branch=main", str(self.root)) + (self.root / ".github").mkdir() + (self.root / ".github/plugin-registry.json").write_text(json.dumps(METADATA)) + (self.root / "Configs").mkdir() + (self.root / "Configs/PluginKit.xcconfig").write_text( + "TABLEPRO_PLUGINKIT_VERSION = 39\nTABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n") + (self.root / "project.yml").write_text(PROJECT) + git(self.root, "add", ".") + git(self.root, "commit", "--quiet", "-m", "tag") + git(self.root, "remote", "add", "origin", str(self.origin)) + self.notes = self.base / "feed.md" + self.notes.write_text("- A fix. (#1)\n") + + def publish(self, tag, kit=39, archive=None): + release = version.parse_tag(tag) + folder = self.releases / tag + folder.mkdir(parents=True) + app_archive(folder / f"TablePro-{release}.tar.xz", *(archive or (str(release), release.build))) + plugin_zip(folder / f"OracleDriver-{release}.zip", "OracleDriver", str(release), release.build, kit, + TableProProvidesDatabaseTypeIds=["Oracle"]) + plugin_zip(folder / f"ParquetExport-{release}.zip", "ParquetExport", str(release), release.build, kit, + TableProProvidesExportFormatIds=["parquet"]) + + def add(self, tag, critical=False): + release = version.parse_tag(tag) + messages = [] + changed = feed.add(self.root, tag, release.build, release.channel, self.notes, critical, self.tools, KEY, + feed.Releases(REPOSITORY), REPOSITORY, log=messages.append) + return changed, messages + + def replace_appcast(self, text): + clone = self.base / "replace" + git(self.base, "clone", "--quiet", "--branch", "feeds", str(self.origin), str(clone)) + (clone / "appcast.xml").write_text(text) + git(clone, "commit", "--quiet", "-am", "replace the appcast") + git(clone, "push", "--quiet", "origin", "feeds") + + def calls(self, tool): + if not self.log_path.exists(): + return [] + return [call[1:] for call in map(json.loads, self.log_path.read_text().splitlines()) if call[0] == tool] + + def published(self, path): + return subprocess.run(["git", "-C", str(self.origin), "show", f"feeds:{path}"], check=True, + capture_output=True, text=True).stdout + + def history(self): + return git(self.origin, "log", "--format=%an <%ae> %s", "feeds").splitlines() + + def test_adds_a_beta_and_its_plugins(self): + self.publish("v0.82.0-beta.1") + changed, _ = self.add("v0.82.0-beta.1") + self.assertTrue(changed) + self.assertEqual(self.history(), [ + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> release: v0.82.0-beta.1", + "Fixture <fixture@example.com> seed", + ]) + (generate,) = self.calls("generate_appcast") + self.assertEqual(generate[-1], f"stdin={KEY}") + signing = [call for call in self.calls("sign_update") if "--verify" not in call] + self.assertEqual([call[:2] + call[-1:] for call in signing], [["--ed-key-file", "-", f"stdin={KEY}"]]) + self.assertIn("--channel", generate) + self.assertNotIn("--critical-update-version", generate) + self.assertNotIn("--phased-rollout-interval", generate) + items = feed.parse_items(self.published("appcast.xml").encode()) + self.assertEqual([(each.short_version, each.channel) for each in items], + [("0.82.0-beta.1", "beta"), ("0.81.0", None), ("0.80.0", None)]) + manifest = json.loads(self.published("plugins/v3.json")) + self.assertEqual([plugin["id"] for plugin in manifest["plugins"]], + ["com.TablePro.OracleDriver", "com.TablePro.ParquetExport"]) + oracle = manifest["plugins"][0] + self.assertEqual(oracle["databaseTypeIds"], ["Oracle"]) + self.assertEqual(oracle["releases"][0]["url"], + "https://github.com/TableProApp/TablePro/releases/download/v0.82.0-beta.1/" + "OracleDriver-0.82.0-beta.1.zip") + self.assertEqual(oracle["releases"][0]["channel"], "beta") + + def test_a_stable_carries_the_critical_build_and_phases(self): + self.publish("v0.82.0") + self.add("v0.82.0") + (generate,) = self.calls("generate_appcast") + self.assertEqual(generate[generate.index("--phased-rollout-interval") + 1], "21600") + self.assertEqual(generate[generate.index("--critical-update-version") + 1], "140") + + def test_a_critical_stable_is_critical_below_its_own_build(self): + self.publish("v0.82.0") + self.add("v0.82.0", critical=True) + (generate,) = self.calls("generate_appcast") + self.assertEqual(generate[generate.index("--critical-update-version") + 1], "820099") + + def test_a_critical_beta_is_refused(self): + with self.assertRaisesRegex(feed.FeedError, "a beta is never critical"): + self.add("v0.82.0-beta.1", critical=True) + + def test_a_build_or_channel_that_disagrees_with_the_tag_is_refused(self): + with self.assertRaisesRegex(feed.FeedError, "is build 820001 on beta"): + feed.add(self.root, "v0.82.0-beta.1", 820099, "stable", self.notes, False, self.tools, KEY, + feed.Releases(REPOSITORY), REPOSITORY, log=lambda message: None) + + def test_a_rerun_changes_nothing(self): + self.publish("v0.82.0-beta.1") + self.add("v0.82.0-beta.1") + head = git(self.origin, "rev-parse", "feeds") + changed, messages = self.add("v0.82.0-beta.1") + self.assertFalse(changed) + self.assertEqual(git(self.origin, "rev-parse", "feeds"), head) + self.assertEqual(len(self.calls("generate_appcast")), 1) + self.assertIn("appcast.xml already has 0.82.0-beta.1 (820001)", messages) + + def test_starts_again_when_another_run_pushed_first(self): + self.publish("v0.82.0-beta.1") + race = self.base / "race" + git(self.base, "clone", "--quiet", "--branch", "feeds", str(self.origin), str(race)) + (race / "other.txt").write_text("another run\n") + git(race, "add", "other.txt") + git(race, "commit", "--quiet", "-m", "release: v0.81.1") + with mock.patch.dict(os.environ, {"FAKE_RACE_REPO": str(race)}): + changed, messages = self.add("v0.82.0-beta.1") + self.assertTrue(changed) + self.assertEqual([line.split("> ")[1] for line in self.history()], + ["release: v0.82.0-beta.1", "release: v0.81.1", "seed"]) + self.assertEqual(len(self.calls("generate_appcast")), 2) + self.assertTrue(any("attempt 1 of 3" in message for message in messages)) + self.assertEqual(self.published("other.txt"), "another run\n") + + def test_an_archive_of_another_build_is_refused(self): + self.publish("v0.82.0-beta.1", archive=("0.82.0-beta.1", 140)) + with self.assertRaisesRegex(feed.FeedError, r"holds 0.82.0-beta.1 \(140\), not 0.82.0-beta.1 \(820001\)"): + self.add("v0.82.0-beta.1") + self.assertEqual(self.calls("generate_appcast"), []) + + def test_an_item_sparkle_prunes_on_arrival_leaves_the_appcast_alone(self): + self.replace_appcast(fake_signed(SEED)) + self.publish("v0.79.1") + with mock.patch.dict(os.environ, {"FAKE_PRUNE": "1"}): + changed, messages = self.add("v0.79.1") + self.assertTrue(changed) + self.assertEqual(self.published("appcast.xml"), fake_signed(SEED)) + self.assertIn("generate_appcast keeps newer items on 0.79.1's branch, so appcast.xml stays as it is", messages) + self.assertEqual(len(json.loads(self.published("plugins/v3.json"))["plugins"]), 2) + + def test_a_failed_check_pushes_nothing(self): + self.publish("v0.82.0-beta.1") + with mock.patch.dict(os.environ, {"FAKE_UNSIGNED_ENCLOSURE": "1"}): + with self.assertRaisesRegex(feed.FeedError, "0.82.0-beta.1's enclosure has no sparkle:edSignature"): + self.add("v0.82.0-beta.1") + self.assertEqual(len(self.history()), 1) + + def test_a_rejected_signature_pushes_nothing(self): + self.publish("v0.82.0-beta.1") + with mock.patch.dict(os.environ, {"FAKE_VERIFY_FAIL": "1"}): + with self.assertRaisesRegex(feed.FeedError, "sign_update failed"): + self.add("v0.82.0-beta.1") + self.assertEqual(len(self.history()), 1) + + def test_a_wrong_digest_is_refused(self): + self.publish("v0.82.0-beta.1") + with mock.patch.dict(os.environ, {"FAKE_BAD_DIGEST": "TablePro-0.82.0-beta.1.tar.xz"}): + with self.assertRaisesRegex(feed.FeedError, "but v0.82.0-beta.1 lists sha256:0000"): + self.add("v0.82.0-beta.1") + self.assertEqual(self.calls("generate_appcast"), []) + + def test_a_draft_is_refused(self): + self.publish("v0.82.0-beta.1") + with mock.patch.dict(os.environ, {"FAKE_DRAFT": "1"}): + with self.assertRaisesRegex(feed.FeedError, "is a draft"): + self.add("v0.82.0-beta.1") + + def test_a_plugin_built_against_another_kit_is_refused(self): + self.publish("v0.82.0-beta.1", kit=38) + with self.assertRaisesRegex(registry.RegistryError, "TableProPluginKitVersion is 38"): + self.add("v0.82.0-beta.1") + self.assertEqual(len(self.history()), 1) + + def test_withdraw_removes_the_version_and_re_signs(self): + self.publish("v0.82.0-beta.1") + self.publish("v0.82.0") + self.add("v0.82.0-beta.1") + self.add("v0.82.0") + signed_before = sum("--verify" not in call for call in self.calls("sign_update")) + self.assertTrue(feed.withdraw(self.root, "0.82.0", self.tools, KEY, log=lambda message: None)) + self.assertEqual(self.history()[0].split("> ")[1], "withdraw: v0.82.0") + published = self.published("appcast.xml") + self.assertEqual([each.short_version for each in feed.parse_items(published.encode())], + ["0.82.0-beta.1", "0.81.0", "0.80.0"]) + self.assertEqual(fake_signed(published), published) + self.assertEqual(sum("--verify" not in call for call in self.calls("sign_update")), signed_before + 1) + self.assertEqual(json.loads(self.published("plugins/v3.json"))["plugins"], []) + self.assertFalse(feed.withdraw(self.root, "0.82.0", self.tools, KEY, log=lambda message: None)) + + def test_withdraw_keeps_a_default_channel_item(self): + self.replace_appcast(fake_signed(feed.remove_items(SEED, "0.80.0")[0])) + with self.assertRaisesRegex(feed.FeedError, "no default-channel item"): + feed.withdraw(self.root, "0.81.0", self.tools, KEY, log=lambda message: None) + + def test_check_reads_the_published_branch(self): + self.assertTrue(any("sign_update failed" in problem for problem in feed.check(self.root, self.tools, KEY))) + self.publish("v0.82.0-beta.1") + self.add("v0.82.0-beta.1") + self.assertEqual(feed.check(self.root, self.tools, KEY), []) + + def test_main_needs_the_sparkle_tools_and_key(self): + with mock.patch.dict(os.environ, {"SPARKLE_BIN": "", "SPARKLE_PRIVATE_KEY": KEY}), \ + redirect_stderr(io.StringIO()) as errors: + self.assertEqual(feed.main(["check"]), 1) + self.assertIn("SPARKLE_BIN is not set", errors.getvalue()) + with mock.patch.dict(os.environ, {"SPARKLE_BIN": str(self.tools), "SPARKLE_PRIVATE_KEY": ""}), \ + redirect_stderr(io.StringIO()) as errors: + self.assertEqual(feed.main(["check"]), 1) + self.assertIn("SPARKLE_PRIVATE_KEY is not set", errors.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/test_publish.py b/scripts/release/test_publish.py new file mode 100644 index 0000000000..cdcb6cb161 --- /dev/null +++ b/scripts/release/test_publish.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 + +import hashlib +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +import feed +import publish +import version + +REPOSITORY = "TableProApp/TablePro" + +FAKE_GH = r''' +import hashlib, json, os, sys +from pathlib import Path +args = sys.argv[1:] +state_path = Path(os.environ["FAKE_STATE"]) +state = json.loads(state_path.read_text()) +state["calls"].append(args) +releases = state["releases"] + +def save(): + state_path.write_text(json.dumps(state)) + +def fail(message): + save() + sys.exit(message) + +def option(name): + return args[args.index(name) + 1] if name in args else None + +def files(): + return [arg for arg in args[3:] if os.path.isfile(arg) and arg != option("--notes-file")] + +def upload(release, paths): + for path in paths: + digest = "sha256:" + hashlib.sha256(Path(path).read_bytes()).hexdigest() + if Path(path).name == os.environ.get("FAKE_CORRUPT"): + digest = "sha256:" + "0" * 64 + release["assets"][Path(path).name] = digest + +if args[0] == "api": + path = args[1] + if path.endswith("/releases/latest"): + if state["latest"] is None: + fail("gh: Not Found (HTTP 404)") + print(state["latest"]) + else: + tag = path.rsplit("/", 1)[1] + if tag not in releases or releases[tag]["draft"]: + fail("gh: Not Found (HTTP 404)") + print("false") +elif args[:2] == ["release", "view"]: + release = releases.get(args[2]) + if release is None: + fail("release not found") + if state.get("hide_drafts", 0) > 0: + state["hide_drafts"] -= 1 + fail("release not found") + assets = [{"name": name, "digest": digest, "state": "uploaded"} for name, digest in release["assets"].items()] + print(json.dumps({"isDraft": release["draft"], "assets": assets})) +elif args[:2] == ["release", "create"]: + if args[2] not in state["tags"]: + fail("tag " + args[2] + " doesn't exist in the repo") + release = {"draft": "--draft" in args, "prerelease": "--prerelease=true" in args, "latest": None, "assets": {}} + releases[args[2]] = release + upload(release, files()) +elif args[:2] == ["release", "upload"]: + upload(releases[args[2]], files()) +elif args[:2] == ["release", "edit"]: + release = releases[args[2]] + for arg in args[3:]: + if arg == "--draft=false": + release["draft"] = False + elif arg in ("--prerelease", "--prerelease=true"): + release["prerelease"] = True + elif arg == "--prerelease=false": + release["prerelease"] = False + elif arg == "--latest=true": + state["latest"] = args[2] + elif arg == "--latest=false": + release["latest"] = False +else: + fail("fake gh cannot answer " + " ".join(args)) +save() +''' + + +def items(*pairs): + return [feed.Item(build=str(build), short_version=short, channel=channel, is_critical=False, + critical_version=None, url="https://example.com", signature="c2ln", length="1") + for build, short, channel in pairs] + + +class DecisionTests(unittest.TestCase): + def test_the_draft_action(self): + self.assertEqual(publish.draft_action(True, False), "published") + self.assertEqual(publish.draft_action(True, True), "published") + self.assertEqual(publish.draft_action(False, True), "reuse") + self.assertEqual(publish.draft_action(False, False), "create") + + def test_latest_goes_only_to_the_highest_stable(self): + cases = [ + ("v0.82.0", "v0.81.0", True), + ("v0.82.0", None, True), + ("v0.82.0", "plugin-oracle-v1.0.0", True), + ("v0.81.1", "v0.82.0", False), + ("v0.82.0", "v0.82.0", False), + ("v0.83.0-beta.1", "v0.82.0", False), + ] + for tag, latest, expected in cases: + with self.subTest(tag=tag, latest=latest): + self.assertEqual(publish.should_be_latest(version.parse_tag(tag), latest), expected) + + def test_the_release_flags(self): + self.assertEqual(publish.release_flags(version.parse_tag("v0.82.0-beta.1"), "v0.81.0"), + ["--draft=false", "--prerelease", "--latest=false"]) + self.assertEqual(publish.release_flags(version.parse_tag("v0.82.0"), "v0.81.0"), + ["--draft=false", "--latest=true"]) + self.assertEqual(publish.release_flags(version.parse_tag("v0.81.1"), "v0.82.0"), + ["--draft=false", "--latest=false"]) + + def test_the_highest_stable_in_the_feed(self): + feed_items = items((830001, "0.83.0-beta.1", "beta"), (820199, "0.82.1", None), (820099, "0.82.0", None), + (140, "0.81.0", None)) + self.assertEqual(str(publish.highest_stable(feed_items)), "0.82.1") + self.assertEqual(str(publish.highest_stable(items((140, "0.81.0", None), (139, "0.80.0", None)))), "0.81.0") + self.assertIsNone(publish.highest_stable(items((830001, "0.83.0-beta.1", "beta")))) + + def test_asset_problems(self): + assets = [{"name": "a.zip", "digest": "sha256:" + "1" * 64, "state": "uploaded"}, + {"name": "b.zip", "digest": "sha256:" + "2" * 64, "state": "starter"}] + self.assertEqual(publish.asset_problems({"a.zip": "1" * 64}, assets), []) + self.assertEqual(publish.asset_problems({"a.zip": "3" * 64, "b.zip": "2" * 64, "c.zip": "4" * 64}, assets), [ + f"a.zip is sha256:{'1' * 64} on GitHub but sha256:{'3' * 64} here", + "b.zip is starter, not uploaded", + "the release has no c.zip", + ]) + + +class GitHubTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.base = Path(directory.name) + tools = self.base / "bin" + tools.mkdir() + (tools / "gh").write_text(f"#!{sys.executable}\n{FAKE_GH}") + (tools / "gh").chmod(0o755) + self.state_path = self.base / "state.json" + self.state({"releases": {"v0.81.0": {"draft": False, "prerelease": False, "assets": {}}}, + "latest": "v0.81.0", "tags": ["v0.81.0", "v0.82.0-beta.1", "v0.82.0", "v0.81.1"], "calls": []}) + environment = mock.patch.dict(os.environ, {"PATH": f"{tools}{os.pathsep}{os.environ['PATH']}", + "FAKE_STATE": str(self.state_path), + "GITHUB_REPOSITORY": REPOSITORY}) + environment.start() + self.addCleanup(environment.stop) + self.github = publish.GitHub(REPOSITORY) + self.notes = self.base / "body.md" + self.notes.write_text("Notes\n") + self.files = [] + for name in ("TablePro-0.82.0.dmg", "TablePro-0.82.0.tar.xz", "OracleDriver-0.82.0.zip"): + path = self.base / name + path.write_bytes(name.encode()) + self.files.append(path) + self.log = [] + + def state(self, value=None): + if value is not None: + self.state_path.write_text(json.dumps(value)) + return json.loads(self.state_path.read_text()) + + def verbs(self): + return [call[:2] if call[0] == "release" else call[:1] for call in self.state()["calls"]] + + def draft(self, tag="v0.82.0"): + return publish.draft(self.github, tag, self.notes, self.files, sleep=lambda seconds: None, + log=self.log.append) + + def digests(self): + return {path.name: "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() for path in self.files} + + def test_creates_a_draft_and_checks_its_digests(self): + self.assertEqual(self.draft(), "draft") + release = self.state()["releases"]["v0.82.0"] + self.assertEqual((release["draft"], release["prerelease"], release["assets"]), (True, False, self.digests())) + create = next(call for call in self.state()["calls"] if call[:2] == ["release", "create"]) + self.assertEqual(create[:9], ["release", "create", "v0.82.0", "--draft", "--verify-tag", "--title", "v0.82.0", + "--notes-file", str(self.notes)]) + + def test_a_beta_draft_is_a_prerelease(self): + self.files = [self.base / "TablePro-0.82.0.dmg"] + self.draft("v0.82.0-beta.1") + self.assertTrue(self.state()["releases"]["v0.82.0-beta.1"]["prerelease"]) + + def test_a_published_release_is_left_alone(self): + state = self.state() + state["releases"]["v0.82.0"] = {"draft": False, "prerelease": False, "assets": {}} + self.state(state) + self.assertEqual(self.draft(), "published") + self.assertEqual(self.verbs(), [["api"]]) + + def test_an_earlier_draft_is_reused(self): + self.draft() + state = self.state() + state["calls"] = [] + self.state(state) + self.files[0].write_bytes(b"rebuilt") + self.assertEqual(self.draft(), "draft") + self.assertNotIn(["release", "create"], self.verbs()) + self.assertIn(["release", "upload"], self.verbs()) + self.assertEqual(self.state()["releases"]["v0.82.0"]["assets"], self.digests()) + + def test_waits_for_a_new_draft_to_appear(self): + state = self.state() + state["hide_drafts"] = 2 + self.state(state) + self.assertEqual(self.draft(), "draft") + self.assertEqual(self.verbs().count(["release", "view"]), 4) + + def test_a_draft_that_never_appears_is_an_error(self): + state = self.state() + state["hide_drafts"] = publish.FIND_ATTEMPTS + self.state(state) + with self.assertRaisesRegex(publish.PublishError, "cannot be found"): + self.draft() + + def test_a_wrong_digest_keeps_the_draft_unpublished(self): + with mock.patch.dict(os.environ, {"FAKE_CORRUPT": "OracleDriver-0.82.0.zip"}): + with self.assertRaisesRegex(publish.PublishError, "OracleDriver-0.82.0.zip is sha256:0000"): + self.draft() + self.assertTrue(self.state()["releases"]["v0.82.0"]["draft"]) + + def test_a_missing_file_is_refused_before_any_call(self): + self.files.append(self.base / "missing.zip") + with self.assertRaisesRegex(publish.PublishError, "missing.zip does not exist"): + self.draft() + self.assertEqual(self.state()["calls"], []) + + def test_publishes_a_beta_as_a_prerelease_that_is_never_latest(self): + self.files = [self.base / "TablePro-0.82.0.dmg"] + self.draft("v0.82.0-beta.1") + publish.release(self.github, "v0.82.0-beta.1", self.log.append) + state = self.state() + self.assertEqual(state["releases"]["v0.82.0-beta.1"]["draft"], False) + self.assertEqual(state["latest"], "v0.81.0") + self.assertEqual(state["calls"][-1], ["release", "edit", "v0.82.0-beta.1", "--draft=false", "--prerelease", + "--latest=false", "--repo", REPOSITORY]) + + def test_a_stable_above_latest_becomes_latest(self): + self.draft() + publish.release(self.github, "v0.82.0", self.log.append) + self.assertEqual(self.state()["latest"], "v0.82.0") + + def test_a_patch_on_an_older_line_is_not_latest(self): + state = self.state() + state["latest"] = "v0.82.0" + self.state(state) + self.draft("v0.81.1") + publish.release(self.github, "v0.81.1", self.log.append) + self.assertEqual(self.state()["latest"], "v0.82.0") + self.assertIn("--latest=false", self.state()["calls"][-1]) + + def test_releasing_twice_edits_once(self): + self.draft() + publish.release(self.github, "v0.82.0", self.log.append) + publish.release(self.github, "v0.82.0", self.log.append) + self.assertEqual(sum(call[:2] == ["release", "edit"] for call in self.state()["calls"]), 1) + + def test_releasing_without_a_draft_is_refused(self): + with self.assertRaisesRegex(publish.PublishError, "has no draft"): + publish.release(self.github, "v0.82.0", self.log.append) + + def test_latest_marks_the_highest_stable_left_in_the_feed(self): + feed_items = items((830001, "0.83.0-beta.1", "beta"), (140, "0.81.0", None)) + self.assertEqual(publish.latest(self.github, feed_items, self.log.append), "v0.81.0") + self.assertEqual(self.state()["calls"][-1], ["release", "edit", "v0.81.0", "--latest=true", + "--repo", REPOSITORY]) + + def test_draft_prints_only_the_state_on_stdout(self): + arguments = ["draft", "v0.82.0", "--notes", str(self.notes), *[str(path) for path in self.files]] + with redirect_stdout(io.StringIO()) as output, redirect_stderr(io.StringIO()): + self.assertEqual(publish.main(arguments), 0) + self.assertEqual(output.getvalue(), "state=draft\n") + with redirect_stdout(io.StringIO()) as output, redirect_stderr(io.StringIO()): + self.assertEqual(publish.main(["release", "v0.82.0"]), 0) + self.assertEqual(publish.main(arguments), 0) + self.assertEqual(output.getvalue(), "state=published\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/test_registry.py b/scripts/release/test_registry.py new file mode 100644 index 0000000000..53eec9d538 --- /dev/null +++ b/scripts/release/test_registry.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + +import io +import json +import plistlib +import tempfile +import unittest +import zipfile +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import registry +import version + +URL_BASE = "https://github.com/TableProApp/TablePro/releases/download/v0.82.0/" +PROJECT = """\ +targets: + OracleDriver: + templates: [DriverPlugin] + +aggregateTargets: + BundledPlugins: + targets: + - MySQLDriver + - SQLiteDriver + + RegistryPlugins: + targets: + - OracleDriver + - ParquetExport + scheme: {} +""" +METADATA = { + "retainFromPluginKit": 39, + "plugins": { + "OracleDriver": {"name": "Oracle Driver", "iconName": "oracle-icon"}, + "ParquetExport": {"name": "Parquet Export", "iconName": "parquet-icon"}, + }, +} + + +def entry(text="0.82.0", kit=39, system="13.0", sha="a", plugin="com.TablePro.OracleDriver", name="Oracle Driver"): + return { + "id": plugin, "name": name, "iconName": "oracle-icon", "databaseTypeIds": ["Oracle"], + "version": text, "build": version.parse_version(text).build, "pluginKit": kit, + "minimumSystemVersion": system, "file": f"OracleDriver-{text}.zip", "sha256": sha * 64, + } + + +def select(manifest, plugin, channels, kit, system): + def system_key(text): + return tuple(int(part) for part in text.split(".")) + + releases = next(entry["releases"] for entry in manifest["plugins"] if entry["id"] == plugin) + eligible = [release for release in releases if release["channel"] in channels and release["pluginKit"] == kit + and system_key(release["minimumSystemVersion"]) <= system_key(system)] + return max(eligible, key=lambda release: release["build"])["version"] if eligible else None + + +class ManifestCase(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.path = Path(directory.name) / "plugins" / "v3.json" + self.log = [] + + def add(self, entries, channel="stable", retain_from=39): + return registry.add(self.path, entries, channel, URL_BASE, retain_from, self.log.append) + + def manifest(self): + return json.loads(self.path.read_text(encoding="utf-8")) + + def builds(self, plugin="com.TablePro.OracleDriver"): + return [release["build"] for entry in self.manifest()["plugins"] if entry["id"] == plugin + for release in entry["releases"]] + + +class AddTests(ManifestCase): + def test_creates_the_manifest_and_an_entry(self): + self.assertTrue(self.add([entry()])) + self.assertEqual(self.manifest(), { + "schemaVersion": 3, + "retainFromPluginKit": 39, + "plugins": [{ + "id": "com.TablePro.OracleDriver", "name": "Oracle Driver", "iconName": "oracle-icon", + "databaseTypeIds": ["Oracle"], + "releases": [{ + "version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": 39, + "minimumSystemVersion": "13.0", "url": URL_BASE + "OracleDriver-0.82.0.zip", "sha256": "a" * 64, + }], + }], + }) + + def test_a_rerun_is_a_no_op(self): + self.add([entry()]) + written = self.path.read_bytes() + self.assertFalse(self.add([entry()])) + self.assertEqual(self.path.read_bytes(), written) + + def test_a_changed_sha256_for_the_same_build_is_an_error(self): + self.add([entry()]) + with self.assertRaisesRegex(registry.RegistryError, "already has build 820099"): + self.add([entry(sha="b")]) + + def test_keeps_two_builds_per_channel_kit_and_system(self): + for text in ("0.82.0", "0.82.1", "0.82.2"): + self.add([entry(text)]) + self.assertEqual(self.builds(), [820299, 820199]) + + def test_a_beta_goes_once_a_higher_stable_shares_its_kit_and_system(self): + self.add([entry("0.82.0-beta.1")], channel="beta") + self.add([entry("0.82.0-beta.2")], channel="beta") + self.assertEqual(self.builds(), [820002, 820001]) + self.add([entry("0.82.0")]) + self.assertEqual(self.builds(), [820099]) + + def test_a_beta_on_another_kit_survives_a_stable(self): + self.add([entry("0.83.0-beta.1", kit=40)], channel="beta") + self.add([entry("0.82.1")]) + self.assertEqual(self.builds(), [830001, 820199]) + + def test_a_higher_minimum_system_keeps_the_older_build(self): + self.add([entry("0.82.0")]) + self.add([entry("0.82.1", system="14.0")]) + self.add([entry("0.82.2", system="14.0")]) + self.assertEqual(self.builds(), [820299, 820199, 820099]) + manifest = self.manifest() + self.assertEqual(select(manifest, "com.TablePro.OracleDriver", {"stable"}, 39, "13.5"), "0.82.0") + self.assertEqual(select(manifest, "com.TablePro.OracleDriver", {"stable"}, 39, "14.1"), "0.82.2") + + def test_releases_below_the_floor_are_pruned(self): + self.add([entry("0.82.0", kit=38)], retain_from=38) + self.add([entry("0.83.0", kit=39)], retain_from=39) + self.assertEqual(self.builds(), [830099]) + self.assertEqual(self.manifest()["retainFromPluginKit"], 39) + + def test_an_older_line_never_lowers_the_floor(self): + self.add([entry("0.83.0", kit=40)], retain_from=40) + self.add([entry("0.82.1", kit=40)], retain_from=39) + self.assertEqual(self.manifest()["retainFromPluginKit"], 40) + + def test_a_release_pruned_on_arrival_is_a_logged_no_op(self): + self.add([entry("0.83.0", kit=40)], retain_from=40) + written = self.path.read_bytes() + self.assertFalse(self.add([entry("0.82.1", kit=39)], retain_from=39)) + self.assertEqual(self.path.read_bytes(), written) + self.assertIn("com.TablePro.OracleDriver 0.82.1: pruned on arrival by retention, not added", self.log) + + def test_metadata_comes_only_from_the_highest_build(self): + newer = entry("0.83.0-beta.1", kit=40) + newer["name"] = "Oracle" + self.add([newer], channel="beta") + older = entry("0.82.1") + older["name"] = "Oracle Driver (old)" + self.add([older]) + self.assertEqual(self.manifest()["plugins"][0]["name"], "Oracle") + newest = entry("0.83.0", kit=40) + newest["name"] = "Oracle Database" + self.add([newest]) + self.assertEqual(self.manifest()["plugins"][0]["name"], "Oracle Database") + + def test_plugins_sort_by_id_and_releases_by_build(self): + self.add([entry("0.82.0", plugin="com.TablePro.ZDriver"), entry("0.82.0", plugin="com.TablePro.ADriver")]) + self.add([entry("0.82.1", plugin="com.TablePro.ADriver")]) + manifest = self.manifest() + self.assertEqual([plugin["id"] for plugin in manifest["plugins"]], + ["com.TablePro.ADriver", "com.TablePro.ZDriver"]) + self.assertEqual([release["build"] for release in manifest["plugins"][0]["releases"]], [820199, 820099]) + + def test_another_schema_is_refused(self): + self.path.parent.mkdir(parents=True) + self.path.write_text(json.dumps({"schemaVersion": 4, "retainFromPluginKit": 39, "plugins": []})) + with self.assertRaisesRegex(registry.RegistryError, "schemaVersion 4"): + self.add([entry()]) + + def test_a_build_that_is_not_the_versions_build_is_refused(self): + wrong = entry() + wrong["build"] = 140 + with self.assertRaisesRegex(registry.RegistryError, "build 140 is not 820099"): + self.add([wrong]) + + def test_a_version_on_the_wrong_channel_is_refused(self): + with self.assertRaisesRegex(registry.RegistryError, "belongs to the beta channel"): + self.add([entry("0.82.0-beta.1")], channel="stable") + + +class WithdrawTests(ManifestCase): + def test_a_withdraw_brings_the_previous_build_back_into_selection(self): + self.add([entry("0.82.0")]) + self.add([entry("0.82.1")]) + self.assertEqual(select(self.manifest(), "com.TablePro.OracleDriver", {"stable"}, 39, "26.0"), "0.82.1") + self.assertTrue(registry.withdraw(self.path, "0.82.1", self.log.append)) + self.assertEqual(select(self.manifest(), "com.TablePro.OracleDriver", {"stable"}, 39, "26.0"), "0.82.0") + + def test_withdrawing_a_missing_version_changes_nothing(self): + self.add([entry("0.82.0")]) + written = self.path.read_bytes() + self.assertFalse(registry.withdraw(self.path, "0.82.5", self.log.append)) + self.assertEqual(self.path.read_bytes(), written) + + def test_a_plugin_left_with_no_release_leaves_the_manifest(self): + self.add([entry("0.82.0")]) + registry.withdraw(self.path, "0.82.0", self.log.append) + self.assertEqual(self.manifest()["plugins"], []) + + +class TargetsTests(unittest.TestCase): + def test_reads_the_bundled_aggregate(self): + self.assertEqual(registry.bundled_targets(PROJECT), {"MySQLDriver", "SQLiteDriver"}) + + def test_reads_the_bundled_aggregate_of_this_repository(self): + bundled = registry.bundled_targets(registry.PROJECT.read_text(encoding="utf-8")) + self.assertIn("MySQLDriver", bundled) + self.assertNotIn("OracleDriver", bundled) + + def test_prints_the_publish_set(self): + with tempfile.TemporaryDirectory() as directory: + metadata, project = Path(directory) / "registry.json", Path(directory) / "project.yml" + metadata.write_text(json.dumps(METADATA)) + project.write_text(PROJECT) + with redirect_stdout(io.StringIO()) as output: + self.assertEqual(registry.main(["targets", "--metadata", str(metadata), "--project", str(project)]), 0) + self.assertEqual(output.getvalue(), "OracleDriver\nParquetExport\n") + + def test_refuses_a_bundled_target(self): + metadata = {"retainFromPluginKit": 39, "plugins": {**METADATA["plugins"], + "MySQLDriver": {"name": "MySQL", "iconName": "mysql"}}} + with self.assertRaisesRegex(registry.RegistryError, "the app bundles MySQLDriver"): + registry.publish_set(metadata, registry.bundled_targets(PROJECT)) + + def test_refuses_metadata_in_another_shape(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "registry.json" + path.write_text(json.dumps({"plugins": {"oracle": {"target": "OracleDriver"}}})) + with self.assertRaisesRegex(registry.RegistryError, "shape"): + registry.load_metadata(path) + + +class DescribeTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.zips = Path(directory.name) + + def plugin(self, target, **overrides): + info = { + "CFBundleIdentifier": f"com.TablePro.{target}", "CFBundleShortVersionString": "0.82.0", + "CFBundleVersion": "820099", "TableProPluginKitVersion": 39, "LSMinimumSystemVersion": "13.0", + "TableProProvidesDatabaseTypeIds": ["Oracle"], + } + info.update(overrides) + info = {key: value for key, value in info.items() if value is not None} + path = self.zips / f"{target}-0.82.0.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"{target}.tableplugin/Contents/Info.plist", plistlib.dumps(info)) + archive.writestr(f"{target}.tableplugin/Contents/MacOS/{target}", b"binary") + return path + + def describe(self, metadata=None): + return registry.describe(self.zips, "0.82.0", 820099, 39, metadata or METADATA, + registry.bundled_targets(PROJECT)) + + def test_reads_each_zip(self): + self.plugin("OracleDriver") + self.plugin("ParquetExport", CFBundleIdentifier="com.TablePro.ParquetExportPlugin", + TableProProvidesDatabaseTypeIds=None, TableProProvidesExportFormatIds=["parquet"]) + oracle, parquet = self.describe() + self.assertEqual(oracle["id"], "com.TablePro.OracleDriver") + self.assertEqual(oracle["databaseTypeIds"], ["Oracle"]) + self.assertEqual((oracle["version"], oracle["build"], oracle["pluginKit"], oracle["minimumSystemVersion"]), + ("0.82.0", 820099, 39, "13.0")) + self.assertEqual(oracle["file"], "OracleDriver-0.82.0.zip") + self.assertRegex(oracle["sha256"], "^[0-9a-f]{64}$") + self.assertEqual((parquet["id"], parquet["databaseTypeIds"], parquet["name"]), + ("com.TablePro.ParquetExportPlugin", [], "Parquet Export")) + + def test_rejects_a_wrong_version_build_or_kit(self): + cases = [ + ({"CFBundleShortVersionString": "0.81.0"}, "CFBundleShortVersionString is '0.81.0'"), + ({"CFBundleVersion": "140"}, "CFBundleVersion is '140'"), + ({"TableProPluginKitVersion": 38}, "TableProPluginKitVersion is 38"), + ({"TableProPluginKitVersion": "39"}, "TableProPluginKitVersion is '39'"), + ] + self.plugin("ParquetExport", TableProProvidesExportFormatIds=["parquet"]) + for overrides, message in cases: + with self.subTest(overrides=overrides): + self.plugin("OracleDriver", **overrides) + with self.assertRaisesRegex(registry.RegistryError, message): + self.describe() + + def test_rejects_a_plugin_that_provides_nothing(self): + self.plugin("OracleDriver", TableProProvidesDatabaseTypeIds=None) + self.plugin("ParquetExport", TableProProvidesExportFormatIds=["parquet"]) + with self.assertRaisesRegex(registry.RegistryError, "OracleDriver-0.82.0.zip: it declares none of"): + self.describe() + + def test_rejects_a_missing_zip(self): + self.plugin("OracleDriver") + with self.assertRaisesRegex(registry.RegistryError, "ParquetExport-0.82.0.zip does not exist"): + self.describe() + + def test_rejects_two_plugins_with_one_bundle_id(self): + self.plugin("OracleDriver") + self.plugin("ParquetExport", CFBundleIdentifier="com.TablePro.OracleDriver") + with self.assertRaisesRegex(registry.RegistryError, "share the bundle id"): + self.describe() + + def test_the_cli_writes_entries_that_add_reads(self): + self.plugin("OracleDriver") + self.plugin("ParquetExport", TableProProvidesExportFormatIds=["parquet"]) + metadata, project = self.zips / "registry.json", self.zips / "project.yml" + metadata.write_text(json.dumps(METADATA)) + project.write_text(PROJECT) + entries, manifest = self.zips / "entries.json", self.zips / "v3.json" + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + self.assertEqual(registry.main([ + "describe", "--zips", str(self.zips), "--version", "0.82.0", "--build", "820099", "--kit", "39", + "--metadata", str(metadata), "--project", str(project), "--out", str(entries)]), 0) + self.assertEqual(registry.main([ + "add", "--manifest", str(manifest), "--entries", str(entries), "--channel", "stable", + "--url-base", URL_BASE, "--retain-from", "39"]), 0) + self.assertEqual(registry.main(["withdraw", "--manifest", str(manifest), "--version", "0.82.0"]), 0) + self.assertEqual(json.loads(manifest.read_text())["plugins"], []) + + +if __name__ == "__main__": + unittest.main() From 79d553cb3391407c4a285a2de101c1d98c8d7049 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat <datlechin@gmail.com> Date: Sun, 18 Oct 2026 06:14:58 +0700 Subject: [PATCH 3/4] build(release): add the feed, registry and GitHub release tooling --- .claude/rules/ci.md | 3 ++- scripts/release/test_feed.py | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 25290306ae..0d3d405eb5 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -27,7 +27,8 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. `build-plugin.yml` picks its runner from the ref it builds (`RELEASING.md`). - The Xcode version is written once, in `.github/actions/setup-xcode/action.yml`, which runs `xcode-select --switch` and prints `xcodebuild -version`. A workflow never selects Xcode another way. - XcodeGen comes from `.github/actions/setup-xcodegen`, pinned by version and SHA-256. -- Sparkle's `generate_appcast` and `sign_update` come from `.github/actions/setup-sparkle`. It reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision. +- `scripts/release/feed.py` runs Sparkle's `generate_appcast` and `sign_update` from `SPARKLE_BIN`, which `.github/actions/setup-sparkle` sets. The action reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision. +- The tag release (`build.yml`) signs the feed with the copy `scripts/ci/sign-and-appcast.sh` downloads, pinned by its own `SPARKLE_VERSION` and `SPARKLE_SHA256`. No check compares them with `project.yml`: a Sparkle bump changes all three. - The compilation cache is off on CI (`COMPILATION_CACHE_ENABLE_CACHING=NO`); `Configs/Base.xcconfig` turns it on locally. ## macOS Tests (`macos-tests.yml`) diff --git a/scripts/release/test_feed.py b/scripts/release/test_feed.py index 2b5abc4584..d4eb227211 100644 --- a/scripts/release/test_feed.py +++ b/scripts/release/test_feed.py @@ -15,6 +15,7 @@ from unittest import mock import feed +import publish import registry import version @@ -63,7 +64,7 @@ def signed(text, key): for index, arg in enumerate(args): if arg == "--pattern": shutil.copy(releases / args[2] / args[index + 1], out / args[index + 1]) -else: +elif args[:2] != ["release", "edit"]: sys.exit("fake gh cannot answer " + " ".join(args)) ''' @@ -510,6 +511,24 @@ def test_withdraw_keeps_a_default_channel_item(self): with self.assertRaisesRegex(feed.FeedError, "no default-channel item"): feed.withdraw(self.root, "0.81.0", self.tools, KEY, log=lambda message: None) + def test_latest_after_a_withdraw_reads_the_pushed_branch(self): + self.publish("v0.82.0-beta.1") + self.publish("v0.82.0") + self.add("v0.82.0-beta.1") + self.add("v0.82.0") + before = git(self.origin, "rev-parse", "feeds") + feed.withdraw(self.root, "0.82.0", self.tools, KEY, log=lambda message: None) + git(self.root, "branch", "feeds", before) + git(self.root, "update-ref", feed.REMOTE_BRANCH, before) + self.assertEqual([each.short_version for each in feed.published_items(self.root)], + ["0.82.0-beta.1", "0.81.0", "0.80.0"]) + + git(self.root, "update-ref", feed.REMOTE_BRANCH, before) + with mock.patch.object(publish, "ROOT", self.root), \ + mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": REPOSITORY}), redirect_stderr(io.StringIO()): + self.assertEqual(publish.main(["latest"]), 0) + self.assertEqual(self.calls("gh")[-1], ["release", "edit", "v0.81.0", "--latest=true", "--repo", REPOSITORY]) + def test_check_reads_the_published_branch(self): self.assertTrue(any("sign_update failed" in problem for problem in feed.check(self.root, self.tools, KEY))) self.publish("v0.82.0-beta.1") From 7e10474abcb3a5893844faaa6cc587e2a8dbe75e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat <datlechin@gmail.com> Date: Sun, 18 Oct 2026 09:22:39 +0700 Subject: [PATCH 4/4] feat(plugins): read the v3 plugin manifest from updates.tablepro.app --- TablePro/Core/Plugins/PluginError.swift | 8 +- TablePro/Core/Plugins/PluginInstaller.swift | 28 +- .../Plugins/PluginManager+AutoUpdate.swift | 44 ++- .../Core/Plugins/PluginManager+Install.swift | 28 +- .../Plugins/PluginManager+Registration.swift | 7 +- TablePro/Core/Plugins/PluginManager.swift | 3 + .../Plugins/Registry/RegistryClient.swift | 249 ++++------------ .../Plugins/Registry/RegistryModels.swift | 269 ++++-------------- .../Services/Export/ExportFormatCatalog.swift | 28 +- .../Infrastructure/PostLaunchWork.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 6 + .../Plugins/InstalledPluginsView.swift | 16 +- .../Export/ExportFormatCatalogTests.swift | 39 ++- .../Plugins/DriverUnavailabilityTests.swift | 42 +-- .../PluginManagerReconciliationTests.swift | 64 ++--- .../RegistryBinarySelectionTests.swift | 216 -------------- .../RegistryCategoryDecodingTests.swift | 25 -- .../RegistryClientFreshnessTests.swift | 145 ++++++---- .../Core/Plugins/RegistryClientURLTests.swift | 59 ---- .../RegistryReleaseSelectionTests.swift | 145 ++++++++++ .../StandardDomainIsolationTests.swift | 19 -- TableProTests/Helpers/OfflineRegistry.swift | 29 +- .../SettingsPluginsPaneRegistryTests.swift | 7 +- 23 files changed, 509 insertions(+), 969 deletions(-) delete mode 100644 TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift delete mode 100644 TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift delete mode 100644 TableProTests/Core/Plugins/RegistryClientURLTests.swift create mode 100644 TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index df2a1f7c93..7c0557da89 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -9,7 +9,7 @@ enum PluginError: LocalizedError { case cannotUninstallBuiltIn case notFound case registryUnreachable - case noCompatibleBinary + case noCompatibleRelease case appTooOldForPlugin(oldestPublishedKit: Int, appKit: Int) case installFailed(String) case pluginConflict(existingName: String) @@ -40,8 +40,8 @@ enum PluginError: LocalizedError { return String(localized: "Plugin not found") case .registryUnreachable: return String(localized: "Couldn't reach the plugin registry. Check your connection and try again.") - case .noCompatibleBinary: - return String(localized: "Plugin does not contain a compatible binary for this architecture") + case .noCompatibleRelease: + return String(localized: "No build of this plugin works with this version of TablePro.") case .appTooOldForPlugin: return String(localized: "This plugin needs a newer version of TablePro. Update TablePro, then install it again.") case .installFailed(let reason): @@ -94,7 +94,7 @@ enum PluginError: LocalizedError { var isPermanentReconciliationFailure: Bool { switch self { - case .noCompatibleBinary, .appTooOldForPlugin, .incompatibleVersion: + case .noCompatibleRelease, .appTooOldForPlugin, .incompatibleVersion: return true default: return false diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index b01632bf0e..075facbe79 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -14,14 +14,14 @@ actor PluginInstaller { func install( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndCommit( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -30,7 +30,7 @@ actor PluginInstaller { func update( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, hasLiveConnections: Bool, progress: @escaping @Sendable (Double) async -> Void @@ -39,7 +39,7 @@ actor PluginInstaller { let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndStage( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -51,7 +51,7 @@ actor PluginInstaller { let finalURL = try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndCommit( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -113,13 +113,13 @@ actor PluginInstaller { private func performDownloadAndCommit( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -136,13 +136,13 @@ actor PluginInstaller { private func performDownloadAndStage( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -158,7 +158,7 @@ actor PluginInstaller { private func downloadExtractVerify( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> ExtractedBundle { @@ -169,10 +169,8 @@ actor PluginInstaller { let session = await MainActor.run { RegistryClient.shared.session } - // The registry manifest is fetched over the network, so the URL it names is untrusted - // input. The code-signature check is the real gate, but nothing should be fetched over - // cleartext on the way to it. - guard let downloadURL = URL(string: binary.downloadURL), + // The manifest is network input: the signature check is the real gate, but nothing travels over cleartext. + guard let downloadURL = URL(string: release.url), downloadURL.scheme?.lowercased() == "https" else { throw PluginError.downloadFailed("Invalid download URL") } @@ -183,7 +181,7 @@ actor PluginInstaller { throw PluginError.downloadFailed("HTTP \(code)") } - guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == binary.sha256.lowercased() else { + guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == release.sha256.lowercased() else { throw PluginError.checksumMismatch } diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 1b92832dcd..a54c2caba0 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -34,7 +34,7 @@ extension PluginManager { return } - await RegistryClient.shared.fetchManifest(forceRefresh: true) + await RegistryClient.shared.fetch(policy: .mustBeCurrent) refreshRegistryUpdateSet() guard let manifest = RegistryClient.shared.manifest else { reconciliationManifestAttempts += 1 @@ -94,11 +94,7 @@ extension PluginManager { reconciliationAttempts[lookupId] = attempts + 1 do { - let outcome = try await updateFromRegistry( - registryPlugin, - refreshManifest: false, - progress: { _ in } - ) + let outcome = try await updateFromRegistry(registryPlugin, progress: { _ in }) switch outcome { case .installed: refreshRegistryUpdateSet() @@ -111,8 +107,8 @@ extension PluginManager { return .resolved } catch let error as PluginError where error.isPermanentReconciliationFailure { let action = Self.rejectedAction(registryPlugin: registryPlugin, manifestLoaded: true, kits: Self.compatibleKits) - updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) - if case .noCompatibleBinary = error, case .awaitingCompatibleBuild = action { + updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: action)) + if case .noCompatibleRelease = error, case .awaitingCompatibleBuild = action { Self.logger.warning("Reconciliation: no compatible build published yet for '\(rejected.name)', will retry") return .transient(id: lookupId) } @@ -161,11 +157,8 @@ extension PluginManager { ) } - private func incompatibleBuildReason(for registryPlugin: RegistryPlugin) -> String { - let availableKits = registryPlugin.binaries - .filter { $0.architecture == .current } - .compactMap(\.pluginKitVersion) - if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) { + private func incompatibleBuildReason(for action: RejectedPluginAction) -> String { + if case .requiresAppUpdate = action { return String(localized: "A newer version of TablePro is required for this plugin. Update TablePro to keep using it.") } return String(localized: "No compatible build is available yet. This plugin will update automatically once one is published.") @@ -202,13 +195,14 @@ extension PluginManager { rejectedPlugins.removeAll { $0.url == url } } - func registryUpdate(for pluginId: String) -> RegistryPlugin? { + func registryUpdate(for pluginId: String) -> (plugin: RegistryPlugin, release: RegistryRelease)? { guard let manifest = RegistryClient.shared.manifest else { return nil } guard let installed = plugins.first(where: { $0.id == pluginId }) else { return nil } guard installed.source == .userInstalled else { return nil } - guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }) else { return nil } - return registryPlugin.version.compare(installed.version, options: .numeric) == .orderedDescending - ? registryPlugin : nil + guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }), + let release = try? Self.newestRelease(of: registryPlugin), + release.build > installed.build else { return nil } + return (registryPlugin, release) } func refreshRegistryUpdateSet() { @@ -242,16 +236,14 @@ extension PluginManager { ) -> RejectedPluginAction { guard manifestLoaded else { return .awaitingCompatibleBuild } guard let registryPlugin else { return .notInRegistry } - let availableKits = registryPlugin.binaries - .filter { $0.architecture == .current } - .compactMap(\.pluginKitVersion) - if availableKits.contains(where: { kits.contains($0) }) { + do { + _ = try newestRelease(of: registryPlugin, kits: kits) return .updateAvailable(registryPlugin) - } - if availableKits.contains(where: { $0 > kits.upperBound }) { + } catch PluginError.appTooOldForPlugin { return .requiresAppUpdate + } catch { + return .awaitingCompatibleBuild } - return .awaitingCompatibleBuild } func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool { @@ -270,7 +262,7 @@ extension PluginManager { } guard driverPlugin(for: type) == nil else { return } if PluginMetadataRegistry.shared.snapshot(for: type) == nil { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) } let unavailability = driverUnavailability(for: type, registryManifest: registryClient.manifest) guard unavailability == .notInstalled else { @@ -304,7 +296,7 @@ extension PluginManager { guard !targets.isEmpty else { return } reconciliationActive = true defer { reconciliationActive = false } - await RegistryClient.shared.fetchManifest(forceRefresh: true) + await RegistryClient.shared.fetch(policy: .mustBeCurrent) guard let manifest = RegistryClient.shared.manifest else { return } for target in targets { if let lookupId = resolveRegistryId(for: target, manifest: manifest) { diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 8540eb1842..48acda58ac 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -4,7 +4,6 @@ import os extension PluginManager { func installFromRegistry( _ registryPlugin: RegistryPlugin, - registryClient: RegistryClient = .shared, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginEntry { if plugins.contains(where: { $0.id == registryPlugin.id }) { @@ -18,12 +17,11 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } - let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin) - let binary = try validateRegistryCompatibility(registryPlugin) + let release = try Self.newestRelease(of: registryPlugin) let finalURL = try await PluginInstaller.shared.install( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: { fraction in await progress(fraction) } ) @@ -36,7 +34,6 @@ extension PluginManager { func updateFromRegistry( _ registryPlugin: RegistryPlugin, - refreshManifest: Bool = true, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { if let existing = plugins.first(where: { $0.id == registryPlugin.id }), @@ -52,14 +49,11 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } - let registryPlugin = refreshManifest - ? await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) - : registryPlugin - let binary = try validateRegistryCompatibility(registryPlugin) + let release = try Self.newestRelease(of: registryPlugin) let result = try await PluginInstaller.shared.update( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, hasLiveConnections: pluginHasLiveConnections(registryPlugin), progress: { fraction in await progress(fraction) } @@ -83,7 +77,7 @@ extension PluginManager { ) PluginInstallTracker.shared.markStaged( pluginId: registryPlugin.id, - newVersion: registryPlugin.version + newVersion: release.version ) return .staged(pluginId: registryPlugin.id) } @@ -136,9 +130,15 @@ extension PluginManager { PluginInstallTracker.shared.clearInstall(pluginId: pluginId) } - @discardableResult - func validateRegistryCompatibility(_ registryPlugin: RegistryPlugin) throws -> RegistryBinary { - try registryPlugin.resolvedBinary(for: .current, kits: Self.compatibleKits) + nonisolated static func newestRelease( + of registryPlugin: RegistryPlugin, + kits: ClosedRange<Int> = compatibleKits + ) throws -> RegistryRelease { + try registryPlugin.newestRelease( + channel: registryChannel, + kits: kits, + systemVersion: ProcessInfo.processInfo.operatingSystemVersion + ) } func pluginHasLiveConnections(_ registryPlugin: RegistryPlugin) -> Bool { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index fedfc28013..db9da5632e 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -758,16 +758,16 @@ extension PluginManager { progress: @escaping @MainActor @Sendable (Double) -> Void, lookup: (RegistryManifest?) -> RegistryPlugin? ) async throws -> PluginEntry { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) var registryPlugin = lookup(registryClient.manifest) if registryPlugin == nil { - await registryClient.ensureManifest(.mustBeCurrent) + await registryClient.fetch(policy: .mustBeCurrent) registryPlugin = lookup(registryClient.manifest) } guard let registryPlugin else { - guard registryClient.fetchState == .loaded else { + guard registryClient.isManifestCurrent else { throw PluginError.registryUnreachable } throw PluginError.notFound @@ -779,7 +779,6 @@ extension PluginManager { do { let entry = try await installFromRegistry( registryPlugin, - registryClient: registryClient, progress: { fraction in tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) progress(fraction) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 4f8586d891..cf32a5bba8 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -459,6 +459,9 @@ final class PluginManager: ObservableObject { nonisolated internal static let compatibleKits = minimumCompatiblePluginKitVersion...currentPluginKitVersion + // A plugin matches the build it ships with, so a beta app takes beta plugins whatever the update setting. + nonisolated internal static let registryChannel: UpdateChannel = Bundle.main.isPrerelease ? .beta : .stable + nonisolated internal static func isCompatibleKit(_ kit: Int) -> Bool { compatibleKits.contains(kit) } diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index acb1489f17..16b68416bf 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -1,238 +1,111 @@ -// -// RegistryClient.swift -// TablePro -// - import Combine import Foundation import os @MainActor -final class RegistryClient: ObservableObject { - static let shared = RegistryClient() +internal final class RegistryClient: ObservableObject { + internal static let shared = RegistryClient() - @Published private(set) var manifest: RegistryManifest? - @Published private(set) var fetchState: RegistryFetchState = .idle - @Published private(set) var lastFetchDate: Date? + nonisolated internal static let defaultManifestURL: URL = { + guard let url = URL(string: "https://updates.tablepro.app/plugins/v3.json") else { + preconditionFailure("The plugin manifest URL does not parse") + } + return url + }() - let session: URLSession - static let supportedSchemaVersion = 2 nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") - private static let manifestFreshnessWindow: TimeInterval = 300 - - private static let defaultRegistryURL = URL(string: - "https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")! - static let customRegistryURLKey = "com.TablePro.customRegistryURL" - private static let lastFetchKey = "com.TablePro.registryLastFetch" - private static let legacyManifestCacheKey = "registryManifestCache" - private static let legacyETagKeys = ["registryETag", "com.TablePro.registryETag"] - private static let legacyLastFetchKey = "registryLastFetch" - private static let legacyLastRegistryURLKey = "com.TablePro.lastRegistryURL" + @Published internal private(set) var manifest: RegistryManifest? + internal private(set) var isManifestCurrent = false - private let defaults: UserDefaults - private let manifestCacheURL: URL + internal let session: URLSession + internal let manifestURL: URL - private var inFlightFetch: Task<Void, Never>? - private var lastFetchedURL: URL? + private var inFlight: InFlightFetch? - var isUsingCustomRegistry: Bool { - registryURL != Self.defaultRegistryURL - } - - private var registryURL: URL { - if let raw = defaults.string(forKey: Self.customRegistryURLKey), - let custom = URL(string: raw) { - return custom - } - return Self.defaultRegistryURL + private struct InFlightFetch { + let policy: RegistryFetchPolicy + let task: Task<Void, Never> } - nonisolated private static let manifestCacheFileName = "registry-manifest.json" - - init( - userDefaults: UserDefaults = AppStorageEnvironment.shared.defaults, - session: URLSession = RegistryClient.makeDefaultSession(), - manifestCacheURL: URL = RegistryClient.defaultManifestCacheURL() + internal init( + manifestURL: URL = RegistryClient.defaultManifestURL, + session: URLSession = RegistryClient.makeDefaultSession() ) { - self.defaults = userDefaults + self.manifestURL = manifestURL self.session = session - self.manifestCacheURL = manifestCacheURL - Self.migrateLegacyKeys(in: userDefaults) - migrateManifestCacheLocationIfNeeded() - loadCachedManifest() + manifest = Self.cachedManifest(for: manifestURL, in: session.configuration.urlCache) } - nonisolated static func makeDefaultSession() -> URLSession { + nonisolated internal static func makeDefaultSession() -> URLSession { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 config.waitsForConnectivity = true + // URLCache stores nothing larger than a twentieth of its capacity. config.urlCache = URLCache( - memoryCapacity: 1_000_000, - diskCapacity: 5_000_000, + memoryCapacity: 2_000_000, + diskCapacity: 20_000_000, directory: AppStorageEnvironment.shared.applicationSupportRoot .appendingPathComponent("TablePro/Registry/URLCache", isDirectory: true) ) return URLSession(configuration: config) } - nonisolated static func defaultManifestCacheURL() -> URL { - AppStorageEnvironment.shared.applicationSupportRoot - .appendingPathComponent("TablePro/Registry", isDirectory: true) - .appendingPathComponent(manifestCacheFileName) - } - - private static func migrateLegacyKeys(in defaults: UserDefaults) { - let obsoleteKeys = legacyETagKeys + [legacyLastFetchKey, legacyManifestCacheKey, legacyLastRegistryURLKey] - for key in obsoleteKeys where defaults.object(forKey: key) != nil { - defaults.removeObject(forKey: key) - } - } - - private func migrateManifestCacheLocationIfNeeded() { - guard manifestCacheURL == Self.defaultManifestCacheURL() else { return } - let fm = FileManager.default - guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } - let bundleId = Bundle.main.bundleIdentifier ?? "com.TablePro" - let legacyURL = cachesDir.appendingPathComponent(bundleId, isDirectory: true) - .appendingPathComponent(Self.manifestCacheFileName) - guard fm.fileExists(atPath: legacyURL.path) else { return } - if !fm.fileExists(atPath: manifestCacheURL.path) { - do { - try fm.createDirectory( - at: manifestCacheURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try fm.copyItem(at: legacyURL, to: manifestCacheURL) - } catch { - Self.logger.warning("Failed to migrate registry manifest cache: \(error.localizedDescription)") - return - } - } - try? fm.removeItem(at: legacyURL) - } - - private func loadCachedManifest() { - guard let data = try? Data(contentsOf: manifestCacheURL), - let cached = try? JSONDecoder().decode(RegistryManifest.self, from: data) - else { return } - manifest = cached - lastFetchDate = defaults.object(forKey: Self.lastFetchKey) as? Date - } - - private func writeCachedManifest(_ data: Data) { - let dir = manifestCacheURL.deletingLastPathComponent() + nonisolated private static func cachedManifest(for url: URL, in cache: URLCache?) -> RegistryManifest? { + guard let cached = cache?.cachedResponse(for: URLRequest(url: url)) else { return nil } do { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try data.write(to: manifestCacheURL, options: .atomic) + return try JSONDecoder().decode(RegistryManifest.self, from: cached.data) } catch { - Self.logger.warning("Failed to write registry cache: \(error.localizedDescription)") + logger.warning("Ignoring the cached plugin manifest: \(error.publicLogShape, privacy: .public)") + return nil } } - // MARK: - Fetching - - func ensureManifest(_ intent: RegistryFetchIntent) async { - if let inFlightFetch { - await inFlightFetch.value - return + internal func fetch(policy: RegistryFetchPolicy) async { + while let current = inFlight { + await current.task.value + if policy == .ifStale || current.policy == .mustBeCurrent { return } } - if intent == .ifStale, !needsRefresh { return } - let task = Task { await fetchManifest() } - inFlightFetch = task + let task = Task { await self.load(policy) } + inFlight = InFlightFetch(policy: policy, task: task) await task.value - inFlightFetch = nil - } - - private var needsRefresh: Bool { - guard lastFetchedURL == registryURL, let lastFetchDate else { return true } - return Date().timeIntervalSince(lastFetchDate) > Self.manifestFreshnessWindow } - func fetchManifest(forceRefresh: Bool = false) async { - fetchState = .loading - - let request = makeManifestRequest(forceRefresh: forceRefresh) - if isUsingCustomRegistry { - Self.logger.warning("Using custom plugin registry URL: \(self.registryURL.absoluteString)") + internal func makeRequest(policy: RegistryFetchPolicy) -> URLRequest { + var request = URLRequest(url: manifestURL) + switch policy { + case .ifStale: + request.cachePolicy = .useProtocolCachePolicy + case .mustBeCurrent: + request.cachePolicy = .reloadRevalidatingCacheData } + return request + } + private func load(_ policy: RegistryFetchPolicy) async { + defer { inFlight = nil } do { - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - switch httpResponse.statusCode { - case 200...299: - let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) - - if decoded.schemaVersion > Self.supportedSchemaVersion { - Self.logger.error( - "Registry schemaVersion \(decoded.schemaVersion) is newer than supported \(Self.supportedSchemaVersion); falling back to cached manifest" - ) - fallbackToCacheOrFail( - message: String(localized: "Plugin registry requires a newer app version") - ) - return - } - - manifest = decoded - - writeCachedManifest(data) - lastFetchDate = Date() - lastFetchedURL = request.url - defaults.set(lastFetchDate, forKey: Self.lastFetchKey) - - fetchState = .loaded - Self.logger.info("Fetched registry manifest with \(decoded.plugins.count) plugin(s)") - - default: - let message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) - Self.logger.error("Registry fetch failed: HTTP \(httpResponse.statusCode) \(message)") - fallbackToCacheOrFail(message: "Server returned HTTP \(httpResponse.statusCode)") + let (data, response) = try await session.data(for: makeRequest(policy: policy)) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(status) else { + Self.logger.error("Plugin manifest fetch returned HTTP \(status)") + isManifestCurrent = false + return } - } catch is DecodingError { - Self.logger.error("Failed to decode registry manifest") - fallbackToCacheOrFail(message: String(localized: "Failed to parse plugin registry")) + let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) + manifest = decoded + isManifestCurrent = true + Self.logger.info("Fetched the plugin manifest with \(decoded.plugins.count) plugin(s)") } catch { - Self.logger.error("Registry fetch failed: \(error.localizedDescription)") - fallbackToCacheOrFail(message: error.localizedDescription) - } - } - - func makeManifestRequest(forceRefresh: Bool) -> URLRequest { - var request = URLRequest(url: registryURL) - request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .reloadRevalidatingCacheData - return request - } - - func refreshedPlugin(matching plugin: RegistryPlugin) async -> RegistryPlugin { - await fetchManifest(forceRefresh: true) - return manifest?.plugins.first { $0.id == plugin.id } ?? plugin - } - - private func fallbackToCacheOrFail(message: String) { - if manifest != nil { - fetchState = .loadedFromCache(message) - Self.logger.warning("Using cached registry manifest after fetch failure") - } else { - fetchState = .failed(message) + isManifestCurrent = false + Self.logger.error("Plugin manifest fetch failed: \(error.publicLogShape, privacy: .public)") } } } -enum RegistryFetchIntent: Equatable, Sendable { +internal enum RegistryFetchPolicy: Sendable { + /// Lets the HTTP cache answer while the response is fresh. case ifStale case mustBeCurrent } - -enum RegistryFetchState: Equatable, Sendable { - case idle - case loading - case loaded - case loadedFromCache(String) - case failed(String) -} diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index e7884b09fa..763696bf35 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -1,234 +1,77 @@ import Foundation -enum PluginArchitecture: String, Codable, Sendable { - case arm64 - case x86_64 +internal struct RegistryManifest: Sendable { + internal static let supportedSchemaVersion = 3 - static var current: PluginArchitecture { - #if arch(arm64) - .arm64 - #else - .x86_64 - #endif - } -} - -struct RegistryBinary: Codable, Sendable { - let architecture: PluginArchitecture - let downloadURL: String - let sha256: String - let pluginKitVersion: Int? -} - -struct RegistryManifest: Codable, Sendable { - let schemaVersion: Int - let plugins: [RegistryPlugin] + internal let schemaVersion: Int + internal let plugins: [RegistryPlugin] } -struct RegistryPlugin: Codable, Sendable, Identifiable { - let id: String - let name: String - let version: String - let summary: String - let author: RegistryAuthor - let homepage: String? - let category: RegistryCategory - let databaseTypeIds: [String]? - let binaries: [RegistryBinary] - let minAppVersion: String? - let iconName: String? - let isVerified: Bool - let metadata: RegistryPluginMetadata? - - private let legacyDownloadURL: String? - private let legacySha256: String? - private let legacyMinPluginKitVersion: Int? - +extension RegistryManifest: Decodable { private enum CodingKeys: String, CodingKey { - case id, name, version, summary, author, homepage, category - case databaseTypeIds, binaries, minAppVersion, iconName, isVerified, metadata - case legacyDownloadURL = "downloadURL" - case legacySha256 = "sha256" - case legacyMinPluginKitVersion = "minPluginKitVersion" + case schemaVersion + case plugins } - init(from decoder: Decoder) throws { + internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - version = try container.decode(String.self, forKey: .version) - summary = try container.decode(String.self, forKey: .summary) - author = try container.decode(RegistryAuthor.self, forKey: .author) - homepage = try container.decodeIfPresent(String.self, forKey: .homepage) - category = try container.decode(RegistryCategory.self, forKey: .category) - databaseTypeIds = try container.decodeIfPresent([String].self, forKey: .databaseTypeIds) - minAppVersion = try container.decodeIfPresent(String.self, forKey: .minAppVersion) - iconName = try container.decodeIfPresent(String.self, forKey: .iconName) - isVerified = try container.decodeIfPresent(Bool.self, forKey: .isVerified) ?? false - metadata = try container.decodeIfPresent(RegistryPluginMetadata.self, forKey: .metadata) - legacyDownloadURL = try container.decodeIfPresent(String.self, forKey: .legacyDownloadURL) - legacySha256 = try container.decodeIfPresent(String.self, forKey: .legacySha256) - legacyMinPluginKitVersion = try container.decodeIfPresent(Int.self, forKey: .legacyMinPluginKitVersion) - - if let decodedBinaries = try container.decodeIfPresent([RegistryBinary].self, forKey: .binaries) { - binaries = decodedBinaries - } else if let url = legacyDownloadURL, let hash = legacySha256 { - // A v1 entry has one universal ZIP and no architecture. - binaries = [ - RegistryBinary( - architecture: .arm64, - downloadURL: url, - sha256: hash, - pluginKitVersion: legacyMinPluginKitVersion - ), - RegistryBinary( - architecture: .x86_64, - downloadURL: url, - sha256: hash, - pluginKitVersion: legacyMinPluginKitVersion - ) - ] - } else { - binaries = [] + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + guard schemaVersion == Self.supportedSchemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Schema \(schemaVersion) is not \(Self.supportedSchemaVersion)" + ) } + plugins = try container.decode([RegistryPlugin].self, forKey: .plugins) } } -extension RegistryPlugin { - func resolvedBinary(for arch: PluginArchitecture = .current, kits: ClosedRange<Int>) throws -> RegistryBinary { - let forThisArchitecture = binaries.filter { $0.architecture == arch } - let highestInRange = forThisArchitecture - .compactMap { binary -> (binary: RegistryBinary, kit: Int)? in - guard let kit = binary.pluginKitVersion, kits.contains(kit) else { - return nil - } - return (binary, kit) - } - .max { $0.kit < $1.kit } - - if let highestInRange { - return highestInRange.binary - } - - let publishedKits = forThisArchitecture.compactMap(\.pluginKitVersion) - if let oldest = publishedKits.min(), oldest > kits.upperBound { - throw PluginError.appTooOldForPlugin(oldestPublishedKit: oldest, appKit: kits.upperBound) - } - - throw PluginError.noCompatibleBinary - } +internal struct RegistryPlugin: Decodable, Sendable, Identifiable { + internal let id: String + internal let name: String + internal let iconName: String? + internal let databaseTypeIds: [String]? + internal let exportFormatIds: [String]? + internal let releases: [RegistryRelease] } -struct RegistryAuthor: Codable, Sendable { - let name: String - let url: String? +internal struct RegistryRelease: Decodable, Sendable, Equatable { + internal let version: String + internal let build: Int + internal let channel: String + internal let pluginKit: Int + internal let minimumSystemVersion: String + internal let url: String + internal let sha256: String } -enum RegistryCategory: String, Codable, Sendable { - case databaseDriver = "database-driver" - case exportFormat = "export-format" - case importFormat = "import-format" - case other = "other" - - // One entry with a category this app does not know must not fail the whole manifest. - init(from decoder: any Decoder) throws { - let rawValue = try decoder.singleValueContainer().decode(String.self) - self = RegistryCategory(rawValue: rawValue) ?? .other +internal extension RegistryPlugin { + func newestRelease( + channel: UpdateChannel, + kits: ClosedRange<Int>, + systemVersion: OperatingSystemVersion + ) throws -> RegistryRelease { + let eligible = releases.filter { release in + channel.accepts(release.channel) && release.runs(on: systemVersion) + } + if let newest = eligible.filter({ kits.contains($0.pluginKit) }).max(by: { $0.build < $1.build }) { + return newest + } + if let oldestNewerKit = eligible.map(\.pluginKit).filter({ $0 > kits.upperBound }).min() { + throw PluginError.appTooOldForPlugin(oldestPublishedKit: oldestNewerKit, appKit: kits.upperBound) + } + throw PluginError.noCompatibleRelease } } -// MARK: - Plugin Metadata (self-describing registry plugins) - -struct RegistryPluginMetadata: Codable, Sendable { - let displayName: String? - let iconName: String? - let defaultPort: Int? - let brandColorHex: String? - let connectionMode: String? - let editorLanguage: String? - let queryLanguageName: String? - let primaryUrlScheme: String? - let parameterStyle: String? - - let requiresAuthentication: Bool? - let supportsForeignKeys: Bool? - let supportsSchemaEditing: Bool? - let supportsDatabaseSwitching: Bool? - let supportsSchemaSwitching: Bool? - let supportsSSH: Bool? - let supportsSSL: Bool? - let supportsImport: Bool? - let supportsExport: Bool? - let supportsHealthMonitor: Bool? - let supportsCascadeDrop: Bool? - let supportsForeignKeyDisable: Bool? - let supportsReadOnlyMode: Bool? - let supportsQueryProgress: Bool? - let requiresReconnectForDatabaseSwitch: Bool? - - let urlSchemes: [String]? - let fileExtensions: [String]? - let systemDatabaseNames: [String]? - let systemSchemaNames: [String]? - let defaultSchemaName: String? - let defaultGroupName: String? - let tableEntityName: String? - let containerEntityName: String? - let defaultPrimaryKeyColumn: String? - let immutableColumns: [String]? - - let navigationModel: String? - let pathFieldRole: String? - let databaseGroupingStrategy: String? - let structureColumnFields: [String]? - let postConnectActions: [RegistryPostConnectAction]? - let additionalConnectionFields: [RegistryConnectionField]? - let explainVariants: [RegistryExplainVariant]? - let sqlDialect: RegistrySqlDialect? - let statementCompletions: [RegistryCompletionEntry]? - let columnTypesByCategory: [String: [String]]? -} - -struct RegistryConnectionField: Codable, Sendable { - let id: String - let label: String - let placeholder: String? - let defaultValue: String? - let fieldType: String? - let section: String? - let options: [RegistryDropdownOption]? -} - -struct RegistryDropdownOption: Codable, Sendable { - let value: String - let label: String -} - -struct RegistryPostConnectAction: Codable, Sendable { - let type: String - let fieldId: String? -} - -struct RegistryExplainVariant: Codable, Sendable { - let name: String - let prefix: String -} - -struct RegistrySqlDialect: Codable, Sendable { - let identifierQuote: String? - let keywords: [String]? - let functions: [String]? - let dataTypes: [String]? - let tableOptions: [String]? - let regexSyntax: String? - let booleanLiteralStyle: String? - let likeEscapeStyle: String? - let paginationStyle: String? - let offsetFetchOrderBy: String? - let requiresBackslashEscaping: Bool? -} - -struct RegistryCompletionEntry: Codable, Sendable { - let label: String - let insertText: String +internal extension RegistryRelease { + func runs(on systemVersion: OperatingSystemVersion) -> Bool { + let parts = minimumSystemVersion.split(separator: ".", omittingEmptySubsequences: false) + let numbers = parts.compactMap { Int($0) } + guard (1...3).contains(parts.count), numbers.count == parts.count else { return false } + let minimum = numbers + Array(repeating: 0, count: 3 - numbers.count) + let running = [systemVersion.majorVersion, systemVersion.minorVersion, systemVersion.patchVersion] + return !running.lexicographicallyPrecedes(minimum) + } } diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift index 03f5386b0b..afb640bcf6 100644 --- a/TablePro/Core/Services/Export/ExportFormatCatalog.swift +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -1,8 +1,3 @@ -// -// ExportFormatCatalog.swift -// TablePro -// - import Foundation import TableProPluginKit @@ -17,17 +12,20 @@ internal enum ExportFormatCatalog { "csv", "json", "sql", "xlsx", "md", "parquet", "mql" ] - // The registry manifest names no format id, so the app keeps the one mapping it offers. - internal static let installable = [ - InstallableExportFormat( - formatId: "parquet", - displayName: "Parquet", - registryPluginId: "com.TablePro.ParquetExportPlugin" - ) - ] + internal static func installable(in manifest: RegistryManifest?) -> [InstallableExportFormat] { + (manifest?.plugins ?? []).flatMap { plugin in + (plugin.exportFormatIds ?? []).map { formatId in + InstallableExportFormat(formatId: formatId, displayName: plugin.name, registryPluginId: plugin.id) + } + } + } - internal static func notInstalled(installedFormatIds: Set<String>) -> [InstallableExportFormat] { - installable.filter { !installedFormatIds.contains($0.formatId) } + @MainActor + internal static func notInstalled( + installedFormatIds: Set<String>, + manifest: RegistryManifest? = RegistryClient.shared.manifest + ) -> [InstallableExportFormat] { + installable(in: manifest).filter { !installedFormatIds.contains($0.formatId) } } internal static func available( diff --git a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index 68e3bead5f..ab012e9bb8 100644 --- a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -35,7 +35,7 @@ internal enum PostLaunchWork { guard !AppStorageEnvironment.shared.isIsolated else { return } - Task { await RegistryClient.shared.ensureManifest(.ifStale) } + Task { await RegistryClient.shared.fetch(policy: .ifStale) } /// The retired per-tab split name left one permanent defaults record per query tab ever /// opened, and AppKit offers no way to forget one. Here rather than in the delegate because diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6fc653e63e..676f987645 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -78319,6 +78319,7 @@ } }, "Failed to parse plugin registry" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -113026,6 +113027,9 @@ } } } + }, + "No build of this plugin works with this version of TablePro." : { + }, "No cells changed." : { "localizations" : { @@ -131476,6 +131480,7 @@ } }, "Plugin does not contain a compatible binary for this architecture" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -131796,6 +131801,7 @@ } }, "Plugin registry requires a newer app version" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 46192efe26..f64a83a6e2 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -1,7 +1,3 @@ -// -// InstalledPluginsView.swift -// TablePro - import AppKit import SwiftUI import TableProPluginKit @@ -48,7 +44,7 @@ struct InstalledPluginsView: View { } } .task { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) } .onDrop(of: [.fileURL], isTargeted: nil) { providers in guard let provider = providers.first, @@ -376,8 +372,8 @@ struct InstalledPluginsView: View { .font(.subheadline) .foregroundStyle(.secondary) - if let registryPlugin = pluginManager.registryUpdate(for: selected.id) { - updateActionView(for: selected, registryPlugin: registryPlugin) + if let update = pluginManager.registryUpdate(for: selected.id) { + updateActionView(for: selected, registryPlugin: update.plugin, version: update.release.version) } if !selected.pluginDescription.isEmpty { @@ -464,7 +460,7 @@ struct InstalledPluginsView: View { // MARK: - Update @ViewBuilder - private func updateActionView(for plugin: PluginEntry, registryPlugin: RegistryPlugin) -> some View { + private func updateActionView(for plugin: PluginEntry, registryPlugin: RegistryPlugin, version: String) -> some View { if let progress = installTracker.state(for: plugin.id) { switch progress.phase { case .downloading(let fraction): @@ -509,7 +505,7 @@ struct InstalledPluginsView: View { } case .completed: Label( - String(format: String(localized: "Updated to v%@"), registryPlugin.version), + String(format: String(localized: "Updated to v%@"), version), systemImage: "checkmark.circle.fill" ) .foregroundStyle(.green) @@ -521,7 +517,7 @@ struct InstalledPluginsView: View { } } else { HStack(spacing: 8) { - Text(String(format: String(localized: "v%@ available"), registryPlugin.version)) + Text(String(format: String(localized: "v%@ available"), version)) .font(.callout) .foregroundStyle(.blue) Button(String(localized: "Update")) { updatePlugin(registryPlugin) } diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index e536e73280..9335d7627b 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -1,8 +1,3 @@ -// -// ExportFormatCatalogTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @@ -117,4 +112,38 @@ struct ExportFormatCatalogTests { #expect(forwards == backwards) #expect(forwards == ["csv", "sql", "parquet", "zebra"]) } + + private func manifest(_ plugins: [RegistryPlugin]) -> RegistryManifest { + RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) + } + + private func registryPlugin(id: String, name: String, exportFormatIds: [String]?) -> RegistryPlugin { + RegistryPlugin( + id: id, + name: name, + iconName: nil, + databaseTypeIds: [], + exportFormatIds: exportFormatIds, + releases: [] + ) + } + + @Test("The manifest's export-format ids are what the catalog offers to install") + @MainActor + func installableComesFromTheManifest() { + let listed = manifest([ + registryPlugin(id: "com.TablePro.OracleDriver", name: "Oracle Driver", exportFormatIds: nil), + registryPlugin(id: "com.TablePro.ParquetExportPlugin", name: "Parquet Export", exportFormatIds: ["parquet"]) + ]) + + #expect(ExportFormatCatalog.installable(in: listed) == [ + InstallableExportFormat( + formatId: "parquet", + displayName: "Parquet Export", + registryPluginId: "com.TablePro.ParquetExportPlugin" + ) + ]) + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["parquet"], manifest: listed).isEmpty) + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: [], manifest: nil).isEmpty) + } } diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index ae3ac98117..a52ccda553 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -1,12 +1,3 @@ -// -// DriverUnavailabilityTests.swift -// TableProTests -// -// An imported connection whose database type nothing answers, a plugin the user turned off and a -// plugin that failed to load all used to surface as one message that blamed a missing plugin, so -// the reporter installed the plugin they already had and saw nothing change. -// - import Foundation @testable import TablePro import TableProPluginKit @@ -47,11 +38,9 @@ private final class UnreachableRegistryProtocol: URLProtocol, @unchecked Sendabl @MainActor struct DriverUnavailabilityTests { private let managerSuite: EphemeralDefaults - private let registrySuite: EphemeralDefaults init() throws { managerSuite = try EphemeralDefaults() - registrySuite = try EphemeralDefaults() } private func makeManager() throws -> (manager: PluginManager, root: URL) { @@ -98,15 +87,12 @@ struct DriverUnavailabilityTests { ) } - private func makeUnreachableRegistry(root: URL) throws -> RegistryClient { + private func makeUnreachableRegistry() throws -> RegistryClient { UnreachableRegistryProtocol.reset() let config = URLSessionConfiguration.ephemeral config.protocolClasses = [UnreachableRegistryProtocol.self] - return RegistryClient( - userDefaults: registrySuite.defaults, - session: URLSession(configuration: config), - manifestCacheURL: root.appendingPathComponent("registry-manifest.json") - ) + let manifestURL = try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) + return RegistryClient(manifestURL: manifestURL, session: URLSession(configuration: config)) } @Test("A type no plugin, curated entry or registry entry answers is unrecognized") @@ -127,16 +113,13 @@ struct DriverUnavailabilityTests { let typeId = "FutureDB-\(UUID().uuidString)" let json = """ { - "schemaVersion": 2, + "schemaVersion": 3, + "retainFromPluginKit": 39, "plugins": [{ "id": "com.example.future", "name": "Future Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["\(typeId)"], - "binaries": [] + "releases": [] }] } """ @@ -227,7 +210,7 @@ struct DriverUnavailabilityTests { defer { try? FileManager.default.removeItem(at: root) } manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server")] manager.rejectedPlugins = [makeRejection(typeId: "SQL Server", isOutdated: false, reason: "bad signature")] - let registry = try makeUnreachableRegistry(root: root) + let registry = try makeUnreachableRegistry() try await manager.prepareForConnecting(to: .mssql, registryClient: registry) @@ -240,16 +223,13 @@ struct DriverUnavailabilityTests { func rejectionIdentityComesFromTheRegistry() throws { let json = """ { - "schemaVersion": 2, + "schemaVersion": 3, + "retainFromPluginKit": 39, "plugins": [{ "id": "com.example.eager", "name": "Eager Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["EagerDB"], - "binaries": [] + "releases": [] }] } """ @@ -264,7 +244,7 @@ struct DriverUnavailabilityTests { func failedInstallSurfacesItsReason() async throws { let (manager, root) = try makeManager() defer { try? FileManager.default.removeItem(at: root) } - let registry = try makeUnreachableRegistry(root: root) + let registry = try makeUnreachableRegistry() do { try await manager.prepareForConnecting(to: .mssql, registryClient: registry) diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index e9eb0b44f5..3dbfc2af2f 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -7,23 +7,29 @@ import Testing struct PluginManagerReconciliationTests { private func makeManifest(pluginIds: [String]) -> RegistryManifest { - let plugins = pluginIds.map { id -> RegistryPlugin in - let json = """ - { - "id": "\(id)", - "name": "Test Plugin", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", - "binaries": [ - {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": 13} - ] - } - """ - return try! JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + let plugins = pluginIds.map { id in + RegistryPlugin( + id: id, + name: "Test Plugin", + iconName: nil, + databaseTypeIds: nil, + exportFormatIds: nil, + releases: [makeRelease(kit: 13)] + ) } - return RegistryManifest(schemaVersion: 2, plugins: plugins) + return RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) + } + + private func makeRelease(kit: Int) -> RegistryRelease { + RegistryRelease( + version: "0.82.0", + build: 820_000 + kit, + channel: "stable", + pluginKit: kit, + minimumSystemVersion: "13.0", + url: "https://x", + sha256: "deadbeef" + ) } private func makeRejected( @@ -45,22 +51,14 @@ struct PluginManagerReconciliationTests { } private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) throws -> RegistryPlugin { - let arch = PluginArchitecture.current.rawValue - let binaries = kitVersions - .map { "{\"architecture\": \"\(arch)\", \"downloadURL\": \"https://x\", \"sha256\": \"deadbeef\", \"pluginKitVersion\": \($0)}" } - .joined(separator: ",") - let json = """ - { - "id": "\(id)", - "name": "Test Plugin", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", - "binaries": [\(binaries)] - } - """ - return try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + RegistryPlugin( + id: id, + name: "Test Plugin", + iconName: nil, + databaseTypeIds: nil, + exportFormatIds: nil, + releases: kitVersions.map { makeRelease(kit: $0) } + ) } private func kind(_ action: RejectedPluginAction) -> String { @@ -213,7 +211,7 @@ struct PluginManagerReconciliationTests { @Test("incompatible-build errors are permanent reconciliation failures") func permanentFailuresClassified() { - #expect(PluginError.noCompatibleBinary.isPermanentReconciliationFailure) + #expect(PluginError.noCompatibleRelease.isPermanentReconciliationFailure) #expect(PluginError.incompatibleVersion(required: 15, current: 14).isPermanentReconciliationFailure) #expect(PluginError.appTooOldForPlugin(oldestPublishedKit: 15, appKit: 14).isPermanentReconciliationFailure) } diff --git a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift deleted file mode 100644 index b63b849135..0000000000 --- a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -import Foundation -import Testing -@testable import TablePro - -struct RegistryBinarySelectionTests { - - private func makePlugin(binaries: [RegistryBinary]) -> RegistryPlugin { - let payload: [String: Any] = [ - "id": "com.example.driver", - "name": "Example", - "version": "1.0.0", - "summary": "test", - "author": ["name": "Tester"], - "category": "database-driver", - "binaries": binaries.map { binary -> [String: Any] in - var dict: [String: Any] = [ - "architecture": binary.architecture.rawValue, - "downloadURL": binary.downloadURL, - "sha256": binary.sha256 - ] - if let kit = binary.pluginKitVersion { dict["pluginKitVersion"] = kit } - return dict - } - ] - let data = try! JSONSerialization.data(withJSONObject: payload) - return try! JSONDecoder().decode(RegistryPlugin.self, from: data) - } - - @Test("exact pluginKitVersion + arch match wins") - func exactMatchSelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/13", sha256: "aaa", pluginKitVersion: 13), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - #expect(resolved.downloadURL == "https://a/13") - } - - @Test("nil pluginKitVersion is no longer a universal fallback") - func nilPluginKitVersionNotMatched() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("valid binary is still selected when a nil-kit binary is also present (#1322 DynamoDB)") - func validBinaryFoundAlongsideNilKit() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/14", sha256: "def", pluginKitVersion: 14) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 14...14) - #expect(resolved.downloadURL == "https://a/14") - } - - @Test("resilient older-kit binary is served to a newer app without re-publish") - func forwardCompatibleBinarySelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 18...19) - #expect(resolved.downloadURL == "https://a/18") - } - - @Test("highest binary within the compatible range wins") - func highestInRangeSelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "bbb", pluginKitVersion: 19), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/20", sha256: "ccc", pluginKitVersion: 20) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 18...20) - #expect(resolved.downloadURL == "https://a/20") - } - - @Test("binary below the floor is rejected") - func belowFloorRejected() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/17", sha256: "aaa", pluginKitVersion: 17) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 18...18) - } - } - - @Test("binary built for a newer app is rejected") - func aboveCurrentRejected() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "aaa", pluginKitVersion: 19) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 18...18) - } - } - - @Test("throws noCompatibleBinary when no arch match") - func noArchitectureMatch() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .x86_64, downloadURL: "https://intel", sha256: "x", pluginKitVersion: 13) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("throws noCompatibleBinary when arch matches but no kit version match and no legacy") - func noKitVersionMatchNoLegacy() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("v1 manifest with flat downloadURL/sha256 decodes to arm64 + x86_64 binaries") - func v1FlatFieldsBackwardCompat() throws { - let json = #""" - { - "id": "com.example.legacy", - "name": "Legacy", - "version": "1.0.0", - "summary": "v1 entry", - "author": {"name": "Tester"}, - "category": "database-driver", - "downloadURL": "https://legacy.example/plugin.zip", - "sha256": "deadbeef", - "minPluginKitVersion": 11 - } - """# - let plugin = try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) - #expect(plugin.binaries.count == 2) - #expect(plugin.binaries.contains { $0.architecture == .arm64 && $0.pluginKitVersion == 11 }) - #expect(plugin.binaries.contains { $0.architecture == .x86_64 && $0.pluginKitVersion == 11 }) - } -} - -struct RegistryStaleAppTests { - - private func makePlugin(kits: [Int]) -> RegistryPlugin { - let payload: [String: Any] = [ - "id": "com.example.driver", - "name": "Example", - "version": "1.0.0", - "summary": "test", - "author": ["name": "Tester"], - "category": "database-driver", - "binaries": kits.map { kit -> [String: Any] in - [ - "architecture": PluginArchitecture.arm64.rawValue, - "downloadURL": "https://a/\(kit)", - "sha256": "sha\(kit)", - "pluginKitVersion": kit - ] - } - ] - let data = try! JSONSerialization.data(withJSONObject: payload) - return try! JSONDecoder().decode(RegistryPlugin.self, from: data) - } - - // 2026-09-13: apps on kits 19 and 20 were told their architecture was unsupported when the oldest published kit was 21. - @Test("An app below every published binary is named as the stale side, not the architecture") - func staleAppIsReportedAsStale() { - let plugin = makePlugin(kits: [21, 25, 30]) - - #expect { - _ = try plugin.resolvedBinary(for: .arm64, kits: 19...19) - } throws: { error in - guard case PluginError.appTooOldForPlugin(let oldest, let appKit) = error else { return false } - return oldest == 21 && appKit == 19 - } - } - - @Test("A binary above the app's kit is skipped for a lower one that fits") - func picksTheHighestBinaryTheAppCanLoad() throws { - let plugin = makePlugin(kits: [21, 25, 30]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 19...25) - - #expect(resolved.downloadURL == "https://a/25") - } - - @Test("A plugin the app is new enough for still resolves") - func newestAppResolvesNewestBinary() throws { - let plugin = makePlugin(kits: [21, 25, 30]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 19...30) - - #expect(resolved.downloadURL == "https://a/30") - } - - @Test("A binary too old for the app is not reported as a stale app") - func binaryBelowTheMinimumKeepsTheOriginalError() { - let plugin = makePlugin(kits: [12]) - - #expect { - _ = try plugin.resolvedBinary(for: .arm64, kits: 19...30) - } throws: { error in - guard case PluginError.noCompatibleBinary = error else { return false } - return true - } - } - - @Test("A missing architecture is still an architecture problem") - func missingArchitectureKeepsTheOriginalError() { - let plugin = makePlugin(kits: [30]) - - #expect { - _ = try plugin.resolvedBinary(for: .x86_64, kits: 19...30) - } throws: { error in - guard case PluginError.noCompatibleBinary = error else { return false } - return true - } - } -} diff --git a/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift b/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift deleted file mode 100644 index f7091761be..0000000000 --- a/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// RegistryCategoryDecodingTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct RegistryCategoryDecodingTests { - @Test("An entry with a category this app does not know decodes as other, and the rest still load") - func unknownCategoryDecodesAsOther() throws { - let json = """ - {"schemaVersion": 2, "plugins": [ - {"id": "com.example.theme", "name": "Theme", "version": "1.0.0", "summary": "test", - "author": {"name": "Tester"}, "category": "theme", "binaries": []}, - {"id": "com.example.driver", "name": "Driver", "version": "1.0.0", "summary": "test", - "author": {"name": "Tester"}, "category": "database-driver", "binaries": []} - ]} - """ - let manifest = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) - - #expect(manifest.plugins.map(\.category) == [.other, .databaseDriver]) - } -} diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index bf2895efb6..399e11b578 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -53,7 +53,11 @@ struct RegistryClientFreshnessTests { private static let testTypeId = "TestFreshnessDB" - private func manifestData(includingTestPlugin: Bool) -> Data { + private func manifestURL() throws -> URL { + try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) + } + + private func manifestData(includingTestPlugin: Bool, schemaVersion: Int = 3) -> Data { var plugins = "" if includingTestPlugin { let kit = PluginManager.currentPluginKitVersion + 1 @@ -61,19 +65,15 @@ struct RegistryClientFreshnessTests { { "id": "com.test.freshness-driver", "name": "Freshness Test Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["\(Self.testTypeId)"], - "binaries": [ - {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": \(kit)}, - {"architecture": "x86_64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": \(kit)} - ] + "releases": [{ + "version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": \(kit), + "minimumSystemVersion": "13.0", "url": "https://x", "sha256": "deadbeef" + }] } """ } - return Data("{\"schemaVersion\": 2, \"plugins\": [\(plugins)]}".utf8) + return Data("{\"schemaVersion\": \(schemaVersion), \"retainFromPluginKit\": 39, \"plugins\": [\(plugins)]}".utf8) } private func makeEnvironment( @@ -83,106 +83,124 @@ struct RegistryClientFreshnessTests { .appendingPathComponent("registry-freshness-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let cacheURL = tempDir.appendingPathComponent("registry-manifest.json") + let url = try manifestURL() + let cache = URLCache(memoryCapacity: 4_000_000, diskCapacity: 0, directory: nil) if let cachedManifest { - try cachedManifest.write(to: cacheURL) + let response = try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )) + cache.storeCachedResponse(CachedURLResponse(response: response, data: cachedManifest), for: URLRequest(url: url)) } - let defaults = suite.defaults - let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockRegistryProtocol.self] - let session = URLSession(configuration: config) - - let client = RegistryClient( - userDefaults: defaults, - session: session, - manifestCacheURL: cacheURL - ) - return (client, defaults, tempDir) + config.urlCache = cache + let client = RegistryClient(manifestURL: url, session: URLSession(configuration: config)) + return (client, suite.defaults, tempDir) } - @Test("non-forced manifest request revalidates with the origin instead of trusting the local cache") - func nonForcedRequestRevalidates() throws { + @Test("The client asks for the manifest at the URL it was given") + func requestsTheInjectedURL() throws { let (client, _, _) = try makeEnvironment() - let request = client.makeManifestRequest(forceRefresh: false) - #expect(request.cachePolicy == .reloadRevalidatingCacheData) + + #expect(client.makeRequest(policy: .ifStale).url == client.manifestURL) + #expect(client.manifestURL.host() == "registry.invalid") + } + + @Test("The shipped client reads the v3 manifest from updates.tablepro.app with a 20 MB disk cache") + func defaultManifestURLAndSession() { + let config = RegistryClient.makeDefaultSession().configuration + + #expect(RegistryClient.defaultManifestURL.absoluteString == "https://updates.tablepro.app/plugins/v3.json") + #expect(config.timeoutIntervalForRequest == 15) + #expect((config.urlCache?.diskCapacity ?? 0) >= 20_000_000) } - @Test("forced manifest request bypasses the local cache entirely") - func forcedRequestIgnoresCache() throws { + @Test("ifStale lets the HTTP cache answer, mustBeCurrent revalidates with the origin") + func cachePolicies() throws { let (client, _, _) = try makeEnvironment() - let request = client.makeManifestRequest(forceRefresh: true) - #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + #expect(client.makeRequest(policy: .ifStale).cachePolicy == .useProtocolCachePolicy) + #expect(client.makeRequest(policy: .mustBeCurrent).cachePolicy == .reloadRevalidatingCacheData) + } + + @Test("A cached v3 response is decoded when the client starts") + func cachedManifestDecodedAtInit() throws { + let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) + + #expect(client.manifest?.plugins.map(\.id) == ["com.test.freshness-driver"]) + #expect(!client.isManifestCurrent) } - @Test("ifStale skips the network inside the freshness window") - func ifStaleThrottlesInsideFreshnessWindow() async throws { + @Test("A cached v2 response is ignored") + func cachedSchemaTwoIgnored() throws { + let (client, _, _) = try makeEnvironment( + cachedManifest: manifestData(includingTestPlugin: false, schemaVersion: 2) + ) + + #expect(client.manifest == nil) + } + + @Test("concurrent callers share one in-flight fetch") + func concurrentCallersShareOneFetch() async throws { MockRegistryProtocol.reset(responses: [ (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: false)) ]) let (client, _, _) = try makeEnvironment() - await client.ensureManifest(.ifStale) - #expect(MockRegistryProtocol.requestCount == 1) - #expect(client.fetchState == .loaded) + async let first: Void = client.fetch(policy: .ifStale) + async let second: Void = client.fetch(policy: .ifStale) + _ = await (first, second) - await client.ensureManifest(.ifStale) #expect(MockRegistryProtocol.requestCount == 1) + #expect(client.isManifestCurrent) } - @Test("mustBeCurrent revalidates even inside the freshness window") - func mustBeCurrentAlwaysRevalidates() async throws { + @Test("mustBeCurrent fetches again after an ifStale fetch and replaces the manifest") + func mustBeCurrentFetchesAgain() async throws { MockRegistryProtocol.reset(responses: [ (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: true)) ]) let (client, _, _) = try makeEnvironment() - await client.ensureManifest(.ifStale) - #expect(MockRegistryProtocol.requestCount == 1) + await client.fetch(policy: .ifStale) + #expect(client.manifest?.plugins.isEmpty == true) - await client.ensureManifest(.mustBeCurrent) + await client.fetch(policy: .mustBeCurrent) #expect(MockRegistryProtocol.requestCount == 2) #expect(client.manifest?.plugins.count == 1) } - @Test("concurrent callers share one in-flight fetch") - func concurrentCallersShareOneFetch() async throws { - MockRegistryProtocol.reset(responses: [ - (200, manifestData(includingTestPlugin: false)), - (200, manifestData(includingTestPlugin: false)) - ]) - let (client, _, _) = try makeEnvironment() + @Test("A failed refresh keeps the cached manifest and is not current") + func failedRefreshKeepsCachedManifest() async throws { + MockRegistryProtocol.reset(responses: [(500, Data())]) + let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) - async let first: Void = client.ensureManifest(.ifStale) - async let second: Void = client.ensureManifest(.ifStale) - _ = await (first, second) + await client.fetch(policy: .mustBeCurrent) - #expect(MockRegistryProtocol.requestCount == 1) + #expect(client.manifest?.plugins.count == 1) + #expect(!client.isManifestCurrent) } - @Test("failed refresh keeps the cached manifest and reports it as cached") - func failedRefreshKeepsCachedManifest() async throws { - MockRegistryProtocol.reset(responses: [(500, Data())]) + @Test("A v2 document from the server is refused and the cached manifest stays") + func fetchedSchemaTwoRefused() async throws { + MockRegistryProtocol.reset(responses: [(200, manifestData(includingTestPlugin: false, schemaVersion: 2))]) let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) - #expect(client.manifest != nil) - await client.ensureManifest(.mustBeCurrent) + await client.fetch(policy: .mustBeCurrent) #expect(client.manifest?.plugins.count == 1) - guard case .loadedFromCache = client.fetchState else { - Issue.record("Expected loadedFromCache, got \(client.fetchState)") - return - } + #expect(!client.isManifestCurrent) } @Test("stale manifest miss triggers a fresh lookup before reporting not found") func staleManifestMissRevalidatesBeforeNotFound() async throws { let stale = manifestData(includingTestPlugin: false) let fresh = manifestData(includingTestPlugin: true) - MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh), (200, fresh)]) + MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh)]) let (client, defaults, tempDir) = try makeEnvironment(cachedManifest: stale) let manager = PluginManager( @@ -198,6 +216,7 @@ struct RegistryClientFreshnessTests { ) { _ in } Issue.record("Expected install to fail at compatibility validation") } catch let error as PluginError { + #expect(MockRegistryProtocol.requestCount == 2) if case .notFound = error { Issue.record("Lookup missed a plugin the forced revalidation should have found") } @@ -236,7 +255,7 @@ struct RegistryClientFreshnessTests { @Test("an offered export format is found in the registry by its plugin id") func exportFormatResolvesByRegistryId() async throws { let fresh = manifestData(includingTestPlugin: true) - MockRegistryProtocol.reset(responses: [(200, fresh), (200, fresh), (200, fresh)]) + MockRegistryProtocol.reset(responses: [(200, fresh)]) let (client, defaults, tempDir) = try makeEnvironment() let manager = PluginManager( userDefaults: defaults, diff --git a/TableProTests/Core/Plugins/RegistryClientURLTests.swift b/TableProTests/Core/Plugins/RegistryClientURLTests.swift deleted file mode 100644 index 824b9785ab..0000000000 --- a/TableProTests/Core/Plugins/RegistryClientURLTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// RegistryClientURLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -@MainActor -struct RegistryClientURLTests { - private let suite: EphemeralDefaults - private let customURLKey = RegistryClient.customRegistryURLKey - - init() throws { - suite = try EphemeralDefaults() - } - - private func makeClient(customURL: String) -> RegistryClient { - let defaults = suite.defaults - defaults.set(customURL, forKey: customURLKey) - let cacheURL = FileManager.default.temporaryDirectory - .appendingPathComponent("registry-url-tests-\(UUID().uuidString)", isDirectory: true) - .appendingPathComponent("registry-manifest.json") - return RegistryClient(userDefaults: defaults, manifestCacheURL: cacheURL) - } - - @Test("customRegistryURLKey has expected value") - func customURLKeyConstant() { - #expect(RegistryClient.customRegistryURLKey == "com.TablePro.customRegistryURL") - } - - @Test("setting a custom URL via UserDefaults is supported") - func settingCustomURL() throws { - let testURL = "https://custom.example.com/registry/manifest.json" - let client = makeClient(customURL: testURL) - - #expect(client.isUsingCustomRegistry) - let requested = client.makeManifestRequest(forceRefresh: false).url - #expect(requested?.absoluteString == testURL) - #expect(requested?.host() == "custom.example.com") - } - - @Test("session has correct timeout configuration") - func sessionConfiguration() { - let config = RegistryClient.makeDefaultSession().configuration - - #expect(config.timeoutIntervalForRequest == 15) - } - - @Test("invalid custom URL string does not crash") - func invalidCustomURLFallsBackSafely() throws { - let client = makeClient(customURL: "not a valid url %%%") - - #expect(client.makeManifestRequest(forceRefresh: false).url != nil) - #expect(client.session.configuration.timeoutIntervalForRequest == 15) - } -} diff --git a/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift new file mode 100644 index 0000000000..bed025ce38 --- /dev/null +++ b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift @@ -0,0 +1,145 @@ +import Foundation +@testable import TablePro +import Testing + +struct RegistryReleaseSelectionTests { + private static let ventura = OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0) + + private func release( + build: Int, + kit: Int, + channel: String = "stable", + minimumSystemVersion: String = "13.0" + ) -> RegistryRelease { + RegistryRelease( + version: "0.0.\(build)", + build: build, + channel: channel, + pluginKit: kit, + minimumSystemVersion: minimumSystemVersion, + url: "https://example.com/\(build).zip", + sha256: String(repeating: "a", count: 64) + ) + } + + private func plugin(_ releases: [RegistryRelease]) -> RegistryPlugin { + RegistryPlugin( + id: "com.example.driver", + name: "Example Driver", + iconName: nil, + databaseTypeIds: ["Example"], + exportFormatIds: nil, + releases: releases + ) + } + + private func newest( + _ releases: [RegistryRelease], + channel: UpdateChannel = .stable, + kits: ClosedRange<Int> = 39...40, + systemVersion: OperatingSystemVersion = ventura + ) throws -> RegistryRelease { + try plugin(releases).newestRelease(channel: channel, kits: kits, systemVersion: systemVersion) + } + + @Test("The highest build in the kit range wins") + func highestBuildInRange() throws { + let picked = try newest([release(build: 820_099, kit: 39), release(build: 820_199, kit: 40)]) + + #expect(picked.build == 820_199) + } + + @Test("A release with a kit above or below the range is skipped") + func kitOutsideRangeSkipped() throws { + let picked = try newest([ + release(build: 830_099, kit: 41), + release(build: 820_099, kit: 39), + release(build: 810_099, kit: 38) + ]) + + #expect(picked.build == 820_099) + } + + @Test("A stable app skips a beta release and a beta app takes it") + func betaFollowsTheAppChannel() throws { + let releases = [release(build: 820_099, kit: 39), release(build: 830_001, kit: 39, channel: "beta")] + + #expect(try newest(releases, channel: .stable).build == 820_099) + #expect(try newest(releases, channel: .beta).build == 830_001) + } + + @Test("A release in a channel the app does not know is skipped, and the manifest still decodes") + func unknownChannelSkipped() throws { + let json = """ + {"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": [{ + "id": "com.example.driver", "name": "Example Driver", "databaseTypeIds": ["Example"], + "releases": [ + {"version": "0.83.0-nightly.1", "build": 830050, "channel": "nightly", "pluginKit": 39, + "minimumSystemVersion": "13.0", "url": "https://example.com/n.zip", "sha256": "\(String(repeating: "b", count: 64))"}, + {"version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": 39, + "minimumSystemVersion": "13.0", "url": "https://example.com/s.zip", "sha256": "\(String(repeating: "c", count: 64))"} + ] + }]} + """ + let manifest = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + let entry = try #require(manifest.plugins.first) + let picked = try entry.newestRelease(channel: .beta, kits: 39...39, systemVersion: Self.ventura) + + #expect(entry.releases.count == 2) + #expect(picked.build == 820_099) + } + + @Test("A release that needs a newer macOS than the running one is skipped") + func newerSystemSkipped() throws { + let picked = try newest([ + release(build: 820_199, kit: 39, minimumSystemVersion: "14.0"), + release(build: 820_099, kit: 39, minimumSystemVersion: "13.0") + ]) + + #expect(picked.build == 820_099) + } + + @Test("A three-part minimumSystemVersion compares part by part") + func patchLevelSystemVersion() throws { + let candidates = [release(build: 820_199, kit: 39, minimumSystemVersion: "13.4.1")] + let ventura5 = OperatingSystemVersion(majorVersion: 13, minorVersion: 5, patchVersion: 0) + + #expect(try newest(candidates, systemVersion: ventura5).build == 820_199) + #expect(throws: PluginError.self) { try newest(candidates, systemVersion: Self.ventura) } + } + + @Test("Only releases above the app's kit report that the app is too old") + func appTooOldForPlugin() { + #expect { + _ = try newest([release(build: 840_099, kit: 42), release(build: 830_099, kit: 41)]) + } throws: { error in + guard case PluginError.appTooOldForPlugin(let oldest, let appKit) = error else { return false } + return oldest == 41 && appKit == 40 + } + } + + @Test("Nothing selectable and nothing newer reports no compatible release") + func noCompatibleRelease() { + let candidates = [ + release(build: 810_099, kit: 38), + release(build: 830_001, kit: 41, channel: "beta"), + release(build: 830_099, kit: 41, minimumSystemVersion: "15.0") + ] + + #expect { + _ = try newest(candidates, channel: .stable) + } throws: { error in + guard case PluginError.noCompatibleRelease = error else { return false } + return true + } + } + + @Test("A v2 document is refused") + func schemaTwoRefused() { + let json = #"{"schemaVersion": 2, "plugins": []}"# + + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + } + } +} diff --git a/TableProTests/Core/Storage/StandardDomainIsolationTests.swift b/TableProTests/Core/Storage/StandardDomainIsolationTests.swift index 04a1938a05..076da8caa3 100644 --- a/TableProTests/Core/Storage/StandardDomainIsolationTests.swift +++ b/TableProTests/Core/Storage/StandardDomainIsolationTests.swift @@ -1,8 +1,3 @@ -// -// StandardDomainIsolationTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TableProUtilities @@ -46,20 +41,6 @@ struct StandardDomainIsolationTests { #expect(reader.entries(connectionId: connectionId).map(\.name) == ["users"]) } - @Test("The registry client built with its defaults migrates the app's store") - func registryClientFollowsTheStorageEnvironment() { - let legacyKey = "registryETag" - appDefaults.set("stale", forKey: legacyKey) - defer { appDefaults.removeObject(forKey: legacyKey) } - let cacheURL = FileManager.default.temporaryDirectory - .appendingPathComponent("app-store-defaults-\(UUID().uuidString)", isDirectory: true) - .appendingPathComponent("registry-manifest.json") - - _ = RegistryClient(manifestCacheURL: cacheURL) - - #expect(appDefaults.object(forKey: legacyKey) == nil) - } - @Test("The privilege scope outline saves its columns only under an autosave name") func scopeOutlineSavesColumnsOnlyUnderAName() throws { let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) diff --git a/TableProTests/Helpers/OfflineRegistry.swift b/TableProTests/Helpers/OfflineRegistry.swift index 2807d8fbce..70c290acac 100644 --- a/TableProTests/Helpers/OfflineRegistry.swift +++ b/TableProTests/Helpers/OfflineRegistry.swift @@ -25,49 +25,34 @@ final class OfflineRegistryProtocol: URLProtocol, @unchecked Sendable { } Self.lock.withLock { Self.requestedURLs.append(url) } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(#"{"schemaVersion": 2, "plugins": []}"#.utf8)) + client?.urlProtocol(self, didLoad: Data(#"{"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": []}"#.utf8)) client?.urlProtocolDidFinishLoading(self) } } -/// A registry client for a view that fetches on appear. Its own URL, defaults and cache file keep one -/// test's requests countable apart from another's, and its session never reaches the network. +/// A registry client for a view that fetches on appear. Its own URL keeps one test's requests +/// countable apart from another's, and its session never reaches the network. @MainActor final class OfflineRegistry { let client: RegistryClient let manifestURL: URL - private let defaults: EphemeralDefaults - private let directory: URL init() throws { - guard let manifestURL = URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins.json") else { + guard let manifestURL = URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json") else { throw URLError(.badURL) } self.manifestURL = manifestURL - directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OfflineRegistry-\(UUID().uuidString)", isDirectory: true) - defaults = try EphemeralDefaults() - defaults.defaults.set(manifestURL.absoluteString, forKey: RegistryClient.customRegistryURLKey) let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [OfflineRegistryProtocol.self] - client = RegistryClient( - userDefaults: defaults.defaults, - session: URLSession(configuration: configuration), - manifestCacheURL: directory.appendingPathComponent("registry-manifest.json") - ) + client = RegistryClient(manifestURL: manifestURL, session: URLSession(configuration: configuration)) } var requestCount: Int { OfflineRegistryProtocol.requestCount(for: manifestURL) } - /// A Plugins pane's fetch writes defaults and the cache file when it lands, and a write after - /// deinit recreates both directories. A fresh manifest also makes a fetch still queued a no-op. + /// Waits out a fetch the Plugins pane started on appear. func settle() async { - await client.ensureManifest(.ifStale) - } - - deinit { - try? FileManager.default.removeItem(at: directory) + await client.fetch(policy: .ifStale) } } diff --git a/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift b/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift index bd8015f793..1c436c00ee 100644 --- a/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift +++ b/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift @@ -1,8 +1,3 @@ -// -// SettingsPluginsPaneRegistryTests.swift -// TableProTests -// - import AppKit @testable import TablePro import TableProTestSupport @@ -32,7 +27,7 @@ extension GlobalStateSuites { } panes.select(.plugins) - try await waitUntil { registry.client.fetchState == .loaded } + try await waitUntil { registry.client.isManifestCurrent } #expect(registry.requestCount == 1) }