From 879f60507dd18c07ce497877dc7cc145465d20cd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 00:36:38 +0700 Subject: [PATCH] ci(release): build the Release config on main and release branches --- .claude/rules/ci.md | 28 ++-- .github/workflows/ios-tests.yml | 2 +- .github/workflows/macos-tests.yml | 2 +- .github/workflows/release-build.yml | 111 +++++++++++++++ .github/workflows/repo-hygiene.yml | 2 +- .github/workflows/swiftlint.yml | 2 +- project.yml | 55 ++++--- scripts/ci/check-plugin-manifest.py | 127 ++++++++++++++--- scripts/ci/detect-changed-paths.sh | 31 ++-- scripts/ci/test_check_plugin_manifest.py | 157 ++++++++++++++++++++ scripts/ci/test_detect_changed_paths.py | 144 +++++++++++++++++++ scripts/release/build.sh | 173 +++++++++++++++++++++++ scripts/release/package.sh | 102 +++++++++++++ 13 files changed, 875 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/release-build.yml create mode 100644 scripts/ci/test_check_plugin_manifest.py create mode 100644 scripts/ci/test_detect_changed_paths.py create mode 100755 scripts/release/build.sh create mode 100755 scripts/release/package.sh diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index f1d243c3fd..eeb483b1f3 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -17,7 +17,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## Rulesets and required checks - There are no gate jobs. A test job runs on `!cancelled() && needs.changes.outputs.run != 'false'`. A job its `if:` skips reports success, and a failed changes job runs the suite rather than skipping it. -- `Changes (macOS)` and `Changes (iOS)` are not required checks. +- `Changes (macOS)`, `Changes (iOS)` and `Changes (Release)` are not required checks. - A PR need not be up to date with main to merge (`strict_required_status_checks_policy: false`). - A second ruleset blocks force-push and deletion of `main`, with no bypass. - A required check's name is its job's `name:`. Renaming a job changes the ruleset in the same change, and every open PR then merges main in before its checks report the new name. @@ -31,7 +31,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## macOS Tests (`macos-tests.yml`) -- Triggers: pull request, push to main, dispatch. A PR keeps only its newest run; each main commit has its own concurrency group and is never cancelled. +- Triggers: pull request, push to main or `release/**`, dispatch. A PR keeps only its newest run; each pushed commit has its own concurrency group and is never cancelled. - `Changes (macOS)` outputs `run` (detector `macos`) and `plugins` (detector `plugins`). The test job has a 120-minute limit and sets `NSUnbufferedIO=YES`. - Setup, in order: checkout, setup-xcode, setup-xcodegen, `scripts/download-libs.sh`, `generate-project.sh macos`, SPM restore, resolve, SPM save. - One `build-for-testing` with no `-testPlan` builds both plans and writes one `.xctestrun` per plan, with signing, the index store and the compilation cache off. @@ -48,6 +48,15 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `swift test --package-path Packages/TableProCore --parallel`, then TableProOracle with `--force-resolved-versions` when its detector is not false. Each step has `if: !cancelled()`, so one red step never hides the next. - The summary step always runs. Evidence uploads only on failure. +## Release Build (`release-build.yml`) + +- Push to main or `release/**`, and dispatch; never a PR. Each commit has its own concurrency group and is never cancelled. Not a required check. +- `Changes (Release)` outputs `run` (detector `release`). `Release Build` runs on `xcode-27` with a 150-minute limit. +- Setup as macOS Tests up to the SPM restore, which it never saves; then Go, `build-dameng.sh both`, `build-hana.sh both` and create-dmg. +- `scripts/release/build.sh app --unsigned` archives the `TablePro` scheme; `build.sh plugins --unsigned` builds `RegistryPlugins` and copies the publish set (the `.github/plugin-registry.json` entries not bundled) to `build/release/plugins`. Both build universal and run `lipo -verify_arch arm64 x86_64` and `verify-plugin-loads.sh`. +- `scripts/release/package.sh app 0.0.0-dev --unsigned` writes the release assets: the DMG, the `.tar.xz` (extracted and compared with the app) and the dSYMs zip; `package.sh plugins` writes one zip per plugin and the plugin dSYMs zip. +- The plugins build runs after an app failure too. The xcodebuild logs in `build/release/logs` upload only on failure. + ## Summaries (`scripts/ci/summarize-xcresult.sh`) - Runs `scripts/lib/xcresult-cases.py summary`. `verify.sh` reads its verdicts through the same parser, and `scripts/lib/test_xcresult_cases.py` tests it in Repo Hygiene. @@ -59,7 +68,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## SwiftLint (`swiftlint.yml`) -- PR, push to main, dispatch; Ubuntu, 10 minutes. +- PR, push to main or `release/**`, dispatch; Ubuntu, 10 minutes. - Runs the digest-pinned `ghcr.io/realm/swiftlint` image over the whole repo with `lint --strict`. The image's entrypoint is `swiftlint`, so the command never repeats it. - Fails when stderr has `Invalid configuration` or `is not a valid rule identifier`, because SwiftLint drops a custom rule whose regex does not compile and still exits 0. @@ -70,7 +79,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## Repo Hygiene (`repo-hygiene.yml`) -- PR, push to main, dispatch, no path filter; Ubuntu, 15 minutes. +- PR, push to main or `release/**`, dispatch, no path filter; Ubuntu, 15 minutes. - Steps, in order: - actionlint, which also shellchecks every inline `run:` block; - `shellcheck --severity=warning` on every `*.sh` under `scripts` and `.claude/skills`; @@ -86,12 +95,13 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## Change detector (`scripts/ci/detect-changed-paths.sh`) -- `detect-changed-paths.sh [--base ] macos|plugins|ios|oracle|hana` prints `true` or `false`. An unknown suite or flag exits 2. -- A PR passes its base SHA, a push `github.event.before`. No base, or the all-zero SHA of a new ref, prints true. +- `detect-changed-paths.sh [--base ] macos|plugins|ios|oracle|hana|release` prints `true` or `false`. An unknown suite or flag exits 2. +- A PR passes its base SHA, a push `github.event.before`. No base prints true. The all-zero SHA of a new ref diffs against `git merge-base HEAD origin/main`, so a branch cut from main skips what main already ran; with no merge base it prints true. - `macos` watches the app, test, plugin, package, native, config and libs folders, `TestPlans/`, `project.yml`, the app's `Package.resolved`, the build scripts and CI scripts it runs, both setup actions and its workflow. `Packages/` covers TableProMacCore. - `plugins` watches `Plugins/`, `Packages/TableProCore/`, `Packages/TableProOracle/`, `Native/`, `Libs/`, `Configs/`, `project.yml`, the bridge scripts and `verify-plugin-loads.sh`. - `ios` watches `TableProMobile/`, `Libs/`, `Plugins/TableProPluginKit/` (the package target is a symlink to it), and every `../` path it reads out of `TableProMobile/project.yml`. - `oracle` watches `Packages/TableProOracle/`; `hana` watches `Native/HanaBridge/`. Each suite also watches its own workflow. +- `release` watches what the Release build reads: the app, plugin, package, native, config and libs folders, `project.yml`, the app's `Package.resolved`, `.github/plugin-registry.json`, `scripts/release/`, the build and CI scripts it runs and both setup actions. It skips `Plugins/*Tests/` and `Packages/*/Tests/`. - A path a suite newly reads joins that suite's pattern in the same change, or the suite skips on it. - Release-only files never trigger a suite: `CHANGELOG.md`, `appcast.xml`, `TablePro/Resources/WhatsNew.md`, `.github/release-flags.json`, and `Configs/Version.xcconfig` while its diff touches only the two version keys. - Every file the release commit stages (`.claude/skills/release/SKILL.md`, Commit, tag, push) is on that list; `appcast.xml` is `build.yml`'s own push. @@ -100,15 +110,15 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## What runs where - A pull request: both changes jobs; macOS Tests and iOS and Package Tests unless their detector says false (Oracle only when its detector is not false); SwiftLint; Repo Hygiene; PR title. `AllPlugins` only when the `plugins` detector is not false. -- A push to main: the same minus PR title, never cancelled, `AllPlugins` always, the SPM cache saved on a miss. Every code push runs both plans in full; a docs-only or release-only push skips the suites through the detector. +- A push to main or `release/**`: the same minus PR title, plus Release Build; never cancelled, `AllPlugins` always, the SPM cache saved on a miss. Every code push runs both plans in full; a docs-only or release-only push skips the suites through the detector. - A `v*` tag runs only `build.yml`; a `plugin-*-v*` tag runs only `build-plugin.yml` (`RELEASING.md`). The test workflows never run on a tag. - `workflow_dispatch` exists on every workflow except `pr-title.yml`. - `build-linux.yml` is on `main` only for its Monday schedule, which GitHub runs from the default branch. Until the Linux app's directory lands on `main`, every build job skips and `weekly-dispatch` starts the workflow on the branch the `LINUX_BRANCH` variable names (the linux branch when unset), whose testing guide describes the jobs. ## Caches and runner budget -- Swift package checkouts live in `~/.spm-cache`, keyed `spm--` with prefix restore. The cache is saved only on a push to main and only on a miss: a cache saved on a PR ref is visible to that PR alone. -- `build.yml` restores that cache and never saves it, since a save in tag scope is unreadable from other tags. +- Swift package checkouts live in `~/.spm-cache`, keyed `spm--` with prefix restore. macOS Tests saves it only on a push and only on a miss: a cache saved on a PR ref is visible to that PR alone. +- `build.yml` and `release-build.yml` restore that cache and never save it; a save in tag scope is unreadable from other tags. - There is no other cache: no libs or DerivedData cache, and none in the iOS, plugin or Ubuntu workflows. - CI fits the GitHub Free and Pro plans: no paid larger runner, no self-hosted Mac. At most 5 macOS jobs run at once, and main runs queue behind PR runs for those slots. diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 50b2fd619b..8f25229a86 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -3,7 +3,7 @@ name: iOS and Package Tests on: pull_request: push: - branches: [main] + branches: [main, 'release/**'] workflow_dispatch: concurrency: diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 20c02d75cd..d57c0b8c4a 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -3,7 +3,7 @@ name: macOS Tests on: pull_request: push: - branches: [main] + branches: [main, 'release/**'] workflow_dispatch: # A pull request keeps only its newest run. Each main commit gets a group of its own, so no main diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000000..6f0cc9ce04 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,111 @@ +name: Release Build + +# The Release configuration of the app and every registry plugin, universal and unsigned, packaged +# the way a tag packages it, so a packaging failure shows on the commit instead of on the tag. + +on: + push: + branches: [main, 'release/**'] + workflow_dispatch: + +# Each commit has a group of its own and is never cancelled, as in macos-tests.yml. +concurrency: + group: release-build-${{ github.sha }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + changes: + name: Changes (Release) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Decide whether the Release build runs + id: decide + env: + BASE: ${{ github.event.before }} + run: | + set -euo pipefail + run=$(scripts/ci/detect-changed-paths.sh --base "$BASE" release) + echo "run=$run" >> "$GITHUB_OUTPUT" + + # Reads "not false" so a failed changes job builds instead of passing. + build: + name: Release Build + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} + runs-on: xcode-27 + timeout-minutes: 150 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-xcode + + - uses: ./.github/actions/setup-xcodegen + + - name: Download static libraries + env: + GH_TOKEN: ${{ github.token }} + run: scripts/download-libs.sh + + - name: Generate the Xcode project + run: scripts/generate-project.sh macos + + # macos-tests.yml saves this cache. + - name: Restore Swift package checkouts + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.spm-cache + key: spm-${{ runner.os }}-${{ hashFiles('TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-${{ runner.os }}- + + - name: Set up Go for the SAP HANA helper + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: Native/HanaBridge/go.mod + cache: false + + # The Dameng and HANA targets copy these artifacts in and never build them. + - name: Build the native bridges + run: | + scripts/build-dameng.sh both + scripts/build-hana.sh both + + - name: Install create-dmg + run: brew list create-dmg > /dev/null 2>&1 || brew install create-dmg + + - name: Build the app + id: app + run: scripts/release/build.sh app --unsigned + + # Runs after an app failure too, so one run reports both. + - name: Build the registry plugins + id: plugins + if: ${{ !cancelled() }} + run: scripts/release/build.sh plugins --unsigned + + - name: Package the app + if: ${{ !cancelled() && steps.app.outcome == 'success' }} + run: scripts/release/package.sh app 0.0.0-dev --unsigned + + - name: Package the plugins + if: ${{ !cancelled() && steps.plugins.outcome == 'success' }} + run: scripts/release/package.sh plugins 0.0.0-dev --unsigned + + - name: Upload the build logs + if: ${{ failure() }} + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-build-logs-${{ github.run_attempt }} + path: build/release/logs + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index b74ab2acba..105e340a7a 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -6,7 +6,7 @@ name: Repo Hygiene on: pull_request: push: - branches: [main] + branches: [main, 'release/**'] workflow_dispatch: concurrency: diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index d077a8e056..b50ebb6bd1 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -3,7 +3,7 @@ name: SwiftLint on: pull_request: push: - branches: [main] + branches: [main, 'release/**'] workflow_dispatch: concurrency: diff --git a/project.yml b/project.yml index e46c688d19..fd3efe4dfc 100644 --- a/project.yml +++ b/project.yml @@ -246,6 +246,7 @@ targetTemplates: # in a directory from mktemp: the user-script sandbox lets the phase write its declared output # and nothing beside it, codesign writes a temporary file next to whatever it signs, and it # reads every parent of that file, which the sandbox refuses inside the build folders. + # scripts/release/build.sh passes OTHER_CODE_SIGN_FLAGS=--timestamp; other builds skip the timestamp server. HanaHelperEmbedding: postBuildScripts: - name: Embed tablepro-hana-helper @@ -266,7 +267,7 @@ targetTemplates: trap 'rm -rf "$staging"' EXIT cp "$helper" "$staging/tablepro-hana-helper" if [ "${CODE_SIGNING_ALLOWED:-NO}" = "YES" ]; then - /usr/bin/codesign --force --options runtime --timestamp=none --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$staging/tablepro-hana-helper" + /usr/bin/codesign --force --options runtime ${OTHER_CODE_SIGN_FLAGS:---timestamp=none} --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$staging/tablepro-hana-helper" fi cp -f "$staging/tablepro-hana-helper" "$SCRIPT_OUTPUT_FILE_0" @@ -350,8 +351,7 @@ targets: removeHeaders: false copy: destination: executables - # Plugins shipped inside the app bundle. Everything else under Plugins/ is - # published to the registry and is built by its own scheme or by AllPlugins. + # Plugins shipped inside the app bundle: the BundledPlugins aggregate lists the same set. - target: MySQLDriver embed: true copy: { destination: plugins } @@ -508,6 +508,8 @@ targets: base: ENABLE_HARDENED_RUNTIME: YES PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.tablepro-mcp + # The app embeds it; installed on its own it turns the app archive into a generic one. + SKIP_INSTALL: YES PRODUCT_NAME: tablepro-mcp SWIFT_APPROACHABLE_CONCURRENCY: YES SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES @@ -529,6 +531,8 @@ targets: base: ENABLE_HARDENED_RUNTIME: YES PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.tablepro-imagerender + # The app embeds it; installed on its own it turns the app archive into a generic one. + SKIP_INSTALL: YES PRODUCT_NAME: tablepro-imagerender SWIFT_APPROACHABLE_CONCURRENCY: YES SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES @@ -1203,18 +1207,33 @@ targets: - package: TableProCore product: TableProWeaviateCore -# Compile-checks every plugin, including the registry-only ones the app does not -# embed. CI builds this scheme so a registry plugin cannot rot between releases. +# Every plugin is in exactly one of BundledPlugins (embedded in the app) and RegistryPlugins (each +# release publishes it); scripts/ci/check-plugin-manifest.py enforces it. AllPlugins compiles both. aggregateTargets: - AllPlugins: + BundledPlugins: + targets: + - CSVExport + - CSVImport + - ClickHouseDriver + - JSONExport + - JSONImport + - MQLExport + - MarkdownExport + - MySQLDriver + - PostgreSQLDriver + - RedisDriver + - SQLExport + - SQLImport + - SQLiteDriver + - XLSXExport + - XLSXImport + + RegistryPlugins: targets: - BeancountDriver - BigQueryDriverPlugin - BigtableDriverPlugin - - CSVExport - - CSVImport - CassandraDriver - - ClickHouseDriver - CloudflareD1DriverPlugin - CloudflareR2SQLDriverPlugin - DamengDriver @@ -1224,23 +1243,12 @@ aggregateTargets: - EtcdDriverPlugin - FirebaseDriverPlugin - HanaDriver - - JSONExport - - JSONImport - KafkaDriverPlugin - LibSQLDriverPlugin - - MQLExport - MSSQLDriver - MongoDBDriver - - MySQLDriver - OracleDriver - - PostgreSQLDriver - - RedisDriver - - MarkdownExport - ParquetExport - - SQLExport - - XLSXImport - - SQLImport - - SQLiteDriver - SnowflakeDriverPlugin - SpannerDriverPlugin - SurrealDBDriverPlugin @@ -1248,7 +1256,12 @@ aggregateTargets: - TrinoDriverPlugin - TypesenseDriverPlugin - WeaviateDriverPlugin - - XLSXExport + scheme: {} + + AllPlugins: + targets: + - BundledPlugins + - RegistryPlugins scheme: {} schemes: diff --git a/scripts/ci/check-plugin-manifest.py b/scripts/ci/check-plugin-manifest.py index d3108ad5b2..dca111f733 100755 --- a/scripts/ci/check-plugin-manifest.py +++ b/scripts/ci/check-plugin-manifest.py @@ -1,15 +1,5 @@ #!/usr/bin/env python3 -"""Fails when .github/plugin-registry.json disagrees with the plugin classes it describes. - -Two of the manifest's fields also exist in Swift: `icon` is `DriverPlugin.iconName`, and -`databaseTypeIds` is `databaseTypeId` plus `additionalDatabaseTypeIds`. Nothing checked that the -two agreed, and by the time this was written seven icons had drifted: the registry advertised -`chart.bar.xaxis` for ClickHouse while the plugin declared `clickhouse-icon`, and so on. Users see -the manifest's value in the plugin browser and the plugin's value once it is installed. - -Every plugin must resolve. A plugin whose declarations cannot be parsed is a failure, not a skip: -a check that silently passes when it cannot read its input is worse than no check. -""" +"""Fails when .github/plugin-registry.json, the plugin classes and the project.yml plugin aggregates disagree.""" import json import pathlib @@ -18,15 +8,110 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] MANIFEST = REPO_ROOT / ".github" / "plugin-registry.json" +PROJECT = REPO_ROOT / "project.yml" PLUGINS_DIR = REPO_ROOT / "Plugins" ICON = re.compile(r"^\s*(?:public |internal |private )?static (?:let|var) iconName(?:\s*:\s*String)?\s*=\s*\"([^\"]+)\"", re.M) PRIMARY_ID = re.compile(r"^\s*(?:public |internal |private )?static (?:let|var) databaseTypeId(?:\s*:\s*String)?\s*=\s*\"([^\"]+)\"", re.M) EXTRA_IDS = re.compile(r"^\s*(?:public |internal |private )?static (?:let|var) additionalDatabaseTypeIds\s*(?::\s*\[String\]\s*)?=\s*\[([^\]]*)\]", re.M) +TOP_LEVEL_KEY = re.compile(r"^([A-Za-z_][\w-]*):") +ENTRY = re.compile(r"^ ([A-Za-z_][\w-]*):\s*$") +INLINE_LIST = re.compile(r"^ (templates|targets):\s*\[(.*)\]\s*$") +BLOCK_LIST = re.compile(r"^ (templates|targets|dependencies):\s*$") +ITEM = re.compile(r"^ - (.*?)\s*$") +ITEM_FIELD = re.compile(r"^ (.*?)\s*$") +DEPENDENCY_TARGET = re.compile(r"^target:\s*(\S+)") +COPY_TO_PLUGINS = re.compile(r"\bcopy:\s*\{?\s*destination:\s*plugins\b") + +BUNDLED = "BundledPlugins" +REGISTRY = "RegistryPlugins" +ALL = "AllPlugins" +DRIVER_TEMPLATE = "DriverPlugin" +APP_TARGET = "TablePro" + + +def parse_project(text): + sections = {"targets": {}, "aggregateTargets": {}} + section = entry = field = None + for raw in text.splitlines(): + line = re.sub(r"\s+#.*$", "", raw) + if not line.strip() or line.lstrip().startswith("#"): + continue + if match := TOP_LEVEL_KEY.match(line): + section, entry, field = match.group(1), None, None + continue + if section not in sections: + continue + if match := ENTRY.match(line): + entry = sections[section].setdefault(match.group(1), {"templates": [], "targets": [], "dependencies": []}) + field = None + elif entry is None: + continue + elif match := INLINE_LIST.match(line): + entry[match.group(1)] += [name.strip() for name in match.group(2).split(",") if name.strip()] + field = None + elif match := BLOCK_LIST.match(line): + field = match.group(1) + elif field and (match := ITEM.match(line)): + entry[field].append([match.group(1)] if field == "dependencies" else match.group(1)) + elif field == "dependencies" and entry["dependencies"] and (match := ITEM_FIELD.match(line)): + entry["dependencies"][-1].append(match.group(1)) + elif not line.startswith(" "): + field = None + return sections + + +def embedded_plugins(app): + found = set() + for item in app["dependencies"]: + target = DEPENDENCY_TARGET.match(item[0]) + if target and COPY_TO_PLUGINS.search(" ".join(item[1:])): + found.add(target.group(1)) + return found + + +def publish_set(plugins): + return {entry["target"] for entry in plugins.values() if not entry.get("bundled")} + + +def aggregate_problems(project, plugins): + targets, aggregates = project["targets"], project["aggregateTargets"] + problems = [] + for name in (BUNDLED, REGISTRY, ALL): + if name not in aggregates: + problems.append(f"project.yml has no {name} aggregate target") + if problems: + return problems + + if sorted(aggregates[ALL]["targets"]) != sorted([BUNDLED, REGISTRY]): + problems.append(f"{ALL} must depend on {BUNDLED} and {REGISTRY} only, not {aggregates[ALL]['targets']}") + + registry = set(aggregates[REGISTRY]["targets"]) + bundled = set(aggregates[BUNDLED]["targets"]) + published = publish_set(plugins) + for name in sorted(published - registry): + problems.append(f"{MANIFEST.name} publishes {name} but {REGISTRY} does not build it") + for name in sorted(registry - published): + problems.append(f"{REGISTRY} builds {name} but {MANIFEST.name} does not publish it") + + embedded = embedded_plugins(targets.get(APP_TARGET, {"dependencies": []})) + for name in sorted(embedded - bundled): + problems.append(f"the {APP_TARGET} target embeds {name} but {BUNDLED} does not list it") + for name in sorted(bundled - embedded): + problems.append(f"{BUNDLED} lists {name} but the {APP_TARGET} target does not embed it") + + drivers = {name for name, entry in targets.items() if DRIVER_TEMPLATE in entry["templates"]} + for name in sorted(drivers): + count = (name in bundled) + (name in registry) + if count != 1: + problems.append(f"{name} is in {count} of {BUNDLED} and {REGISTRY}, expected exactly one") + for name in sorted((bundled | registry) - drivers): + problems.append(f"{name} is in a plugin aggregate but is not a {DRIVER_TEMPLATE} target") + return problems + def plugin_directory(target): - """Plugin folders and targets are named alike once the Driver/Plugin suffixes are dropped.""" def normalise(name): return name.lower().replace("driverplugin", "").replace("driver", "").replace("plugin", "") @@ -38,7 +123,6 @@ def normalise(name): def declared(directory): - """The values the plugin's own class declares, read across its Swift sources.""" icon = ids = None extra = [] for source in sorted(directory.rglob("*.swift")): @@ -55,10 +139,7 @@ def declared(directory): return icon, ids, extra -def main(): - with open(MANIFEST, encoding="utf-8") as handle: - plugins = json.load(handle)["plugins"] - +def declaration_problems(plugins): problems = [] for slug, entry in sorted(plugins.items()): directory = plugin_directory(entry["target"]) @@ -86,13 +167,21 @@ def main(): f"{slug}: manifest databaseTypeIds {entry['databaseTypeIds']} " f"but the plugin declares {[primary, *extra]}" ) + return problems + + +def main(): + with open(MANIFEST, encoding="utf-8") as handle: + plugins = json.load(handle)["plugins"] + project = parse_project(PROJECT.read_text(encoding="utf-8")) + problems = declaration_problems(plugins) + aggregate_problems(project, plugins) if problems: for problem in problems: print(f"::error::{problem}", file=sys.stderr) - sys.exit(f"\n{len(problems)} plugin(s) disagree with {MANIFEST.name}") + sys.exit(f"\n{len(problems)} problem(s) between {MANIFEST.name}, the plugin classes and {PROJECT.name}") - print(f"{len(plugins)} plugins agree with {MANIFEST.name}") + print(f"{len(plugins)} plugins agree with {MANIFEST.name}, and {PROJECT.name}'s plugin aggregates agree with both") if __name__ == "__main__": diff --git a/scripts/ci/detect-changed-paths.sh b/scripts/ci/detect-changed-paths.sh index 230e74f1ee..0062d4273b 100755 --- a/scripts/ci/detect-changed-paths.sh +++ b/scripts/ci/detect-changed-paths.sh @@ -2,11 +2,7 @@ set -euo pipefail # Prints "true" when a suite has to run for the changes since , "false" when it can skip. -# -# Usage: detect-changed-paths.sh [--base ] macos|plugins|ios|oracle|hana -# -# A pull request passes its base, a push passes github.event.before. No base, or the all-zero -# sha of a new ref, runs everything. +# Usage: detect-changed-paths.sh [--base ] macos|plugins|ios|oracle|hana|release BASE="" SUITE="" @@ -20,6 +16,7 @@ done cd "$(git rev-parse --show-toplevel)" +SKIP="" case "$SUITE" in macos) PATTERNS=('TablePro/' 'TableProTests/' 'TableProUITests/' 'TestPlans/' 'Plugins/' 'Packages/' @@ -53,12 +50,27 @@ case "$SUITE" in '\.github/actions/setup-xcode/' '\.github/workflows/ios-tests\.yml$') ;; hana) PATTERNS=('Native/HanaBridge/' '\.github/workflows/repo-hygiene\.yml$') ;; + release) + # A Release build compiles no test target, so the test folders under Plugins/ and + # Packages/ are skipped too. + PATTERNS=('TablePro/' 'Plugins/' 'Packages/' 'Native/' 'Configs/' 'Libs/' 'project\.yml$' + 'TablePro\.xcodeproj/project\.xcworkspace/xcshareddata/swiftpm/Package\.resolved$' + '\.github/plugin-registry\.json$' + 'scripts/(generate-project|download-libs|create-openssl-dylibs|build-dameng|build-hana)\.sh$' + 'scripts/lib/' 'scripts/release/' 'scripts/ci/(detect-changed-paths|verify-plugin-loads)\.sh$' + '\.github/actions/setup-xcode(gen)?/' '\.github/workflows/release-build\.yml$') + SKIP='Plugins/[^/]+Tests/|Packages/[^/]+/Tests/' ;; *) - echo "detect-changed-paths.sh: expected macos, plugins, ios, oracle or hana, got '$SUITE'" >&2 + echo "detect-changed-paths.sh: expected macos, plugins, ios, oracle, hana or release, got '$SUITE'" >&2 exit 2 ;; esac -if [ -z "$BASE" ] || [ -z "${BASE//0/}" ]; then +# A new branch has no previous commit, so it is compared with the point where it left main. +if [ -n "$BASE" ] && [ -z "${BASE//0/}" ]; then + BASE="$(git merge-base HEAD origin/main 2> /dev/null || true)" +fi + +if [ -z "$BASE" ]; then echo "true" exit 0 fi @@ -73,13 +85,16 @@ OTHER_EDITS="$(awk '/^@@/ { hunk = 1; next } hunk && /^[+-]/ && !/^[+-](MARKETING_VERSION|CURRENT_PROJECT_VERSION) = [0-9.]+$/' <<< "$VERSION_DIFF")" [ -n "$OTHER_EDITS" ] || RELEASE_ONLY+='|Configs/Version\.xcconfig' +IGNORED="^($RELEASE_ONLY)\$" +[ -z "$SKIP" ] || IGNORED+="|^($SKIP)" + # Raw NUL-separated paths: core.quotePath would quote a non-ASCII path and defeat the ^ anchor. # Files rather than $(...), which drops NUL bytes and would hide grep's exit status. CHANGED="$(mktemp)" RELEVANT="$(mktemp)" trap 'rm -f "$CHANGED" "$RELEVANT"' EXIT git -c core.quotePath=false diff --no-renames --name-only -z "$BASE" HEAD > "$CHANGED" -LC_ALL=C grep -zvE "^($RELEASE_ONLY)$" "$CHANGED" > "$RELEVANT" || [ $? -eq 1 ] +LC_ALL=C grep -zvE "$IGNORED" "$CHANGED" > "$RELEVANT" || [ $? -eq 1 ] JOINED="$(IFS='|'; echo "${PATTERNS[*]}")" status=0 diff --git a/scripts/ci/test_check_plugin_manifest.py b/scripts/ci/test_check_plugin_manifest.py new file mode 100644 index 0000000000..2e7d504fe9 --- /dev/null +++ b/scripts/ci/test_check_plugin_manifest.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Fixture tests for the project.yml aggregate checks in check-plugin-manifest.py.""" + +import importlib.util +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location("check_plugin_manifest", Path(__file__).with_name("check-plugin-manifest.py")) +check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check) + +PROJECT = """name: App + +targetTemplates: + DriverPlugin: + type: bundle + dependencies: + - target: TableProPluginKit + +targets: + TablePro: + type: application + dependencies: + - target: TableProPluginKit + embed: true + # Plugins shipped inside the app bundle. + - target: MySQLDriver + embed: true + copy: { destination: plugins } + - target: RedisDriver + embed: true + copy: + destination: plugins + - target: mcp-server + copy: + destination: executables + - package: Sparkle + MySQLDriver: + templates: [DriverPlugin, MySQLLinkage] + RedisDriver: + templates: + - DriverPlugin + OracleDriver: + templates: [DriverPlugin] # registry only + MySQLDriverTests: + templates: [PluginTests, MySQLLinkage] + +aggregateTargets: + BundledPlugins: + targets: + - MySQLDriver + - RedisDriver + + RegistryPlugins: + targets: [OracleDriver] + scheme: {} + + AllPlugins: + targets: + - BundledPlugins + - RegistryPlugins + scheme: {} + +schemes: + App: + build: + targets: + App: all +""" + +PLUGINS = { + "oracle": {"target": "OracleDriver", "bundled": False}, + "redis": {"target": "RedisDriver", "bundled": True}, +} + + +def problems(project=PROJECT, plugins=None): + return check.aggregate_problems(check.parse_project(project), PLUGINS if plugins is None else plugins) + + +def edited(old, new): + assert old in PROJECT, old + return PROJECT.replace(old, new, 1) + + +class ParseProjectTests(unittest.TestCase): + def test_reads_inline_and_block_templates(self): + targets = check.parse_project(PROJECT)["targets"] + self.assertEqual(targets["MySQLDriver"]["templates"], ["DriverPlugin", "MySQLLinkage"]) + self.assertEqual(targets["RedisDriver"]["templates"], ["DriverPlugin"]) + self.assertEqual(targets["OracleDriver"]["templates"], ["DriverPlugin"]) + + def test_embedded_plugins_take_both_copy_forms(self): + app = check.parse_project(PROJECT)["targets"]["TablePro"] + self.assertEqual(check.embedded_plugins(app), {"MySQLDriver", "RedisDriver"}) + + def test_reads_aggregate_targets(self): + aggregates = check.parse_project(PROJECT)["aggregateTargets"] + self.assertEqual(aggregates["BundledPlugins"]["targets"], ["MySQLDriver", "RedisDriver"]) + self.assertEqual(aggregates["RegistryPlugins"]["targets"], ["OracleDriver"]) + self.assertEqual(aggregates["AllPlugins"]["targets"], ["BundledPlugins", "RegistryPlugins"]) + + def test_ignores_template_dependencies(self): + self.assertNotIn("DriverPlugin", check.parse_project(PROJECT)["targets"]) + + +class PublishSetTests(unittest.TestCase): + def test_bundled_entries_are_not_published(self): + self.assertEqual(check.publish_set(PLUGINS), {"OracleDriver"}) + + +class AggregateProblemsTests(unittest.TestCase): + def test_consistent_project_passes(self): + self.assertEqual(problems(), []) + + def test_published_plugin_missing_from_registry_aggregate(self): + found = problems(edited(" targets: [OracleDriver]", " targets: []")) + self.assertIn("plugin-registry.json publishes OracleDriver but RegistryPlugins does not build it", found) + self.assertIn("OracleDriver is in 0 of BundledPlugins and RegistryPlugins, expected exactly one", found) + + def test_registry_aggregate_target_that_is_not_published(self): + plugins = {"redis": PLUGINS["redis"]} + self.assertEqual( + problems(plugins=plugins), + ["RegistryPlugins builds OracleDriver but plugin-registry.json does not publish it"], + ) + + def test_embedded_plugin_missing_from_bundled_aggregate(self): + found = problems(edited(" - MySQLDriver\n - RedisDriver\n", " - RedisDriver\n")) + self.assertIn("the TablePro target embeds MySQLDriver but BundledPlugins does not list it", found) + + def test_bundled_aggregate_lists_a_plugin_the_app_does_not_embed(self): + project = edited(" - target: MySQLDriver\n embed: true\n copy: { destination: plugins }\n", "") + self.assertEqual(problems(project), ["BundledPlugins lists MySQLDriver but the TablePro target does not embed it"]) + + def test_plugin_in_both_aggregates(self): + project = edited(" targets: [OracleDriver]", " targets: [OracleDriver, RedisDriver]") + self.assertIn("RedisDriver is in 2 of BundledPlugins and RegistryPlugins, expected exactly one", problems(project)) + + def test_aggregate_member_that_is_not_a_plugin(self): + project = edited(" - MySQLDriver\n - RedisDriver\n", " - MySQLDriver\n - RedisDriver\n - mcp-server\n") + self.assertIn("mcp-server is in a plugin aggregate but is not a DriverPlugin target", problems(project)) + + def test_all_plugins_depends_on_the_two_aggregates_only(self): + project = edited(" - BundledPlugins\n - RegistryPlugins\n", " - BundledPlugins\n - OracleDriver\n") + self.assertEqual( + problems(project), + ["AllPlugins must depend on BundledPlugins and RegistryPlugins only, not ['BundledPlugins', 'OracleDriver']"], + ) + + def test_missing_aggregate(self): + project = edited(" RegistryPlugins:\n targets: [OracleDriver]\n scheme: {}\n", "") + self.assertEqual(problems(project), ["project.yml has no RegistryPlugins aggregate target"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_detect_changed_paths.py b/scripts/ci/test_detect_changed_paths.py new file mode 100644 index 0000000000..07e98fa0ef --- /dev/null +++ b/scripts/ci/test_detect_changed_paths.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests detect-changed-paths.sh against throwaway git repositories.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("detect-changed-paths.sh") +ZERO = "0" * 40 + +SEED = [ + "TablePro/App.swift", + "Plugins/OracleDriverPlugin/OraclePlugin.swift", + "Packages/TableProCore/Sources/Core.swift", + "CHANGELOG.md", + "README.md", +] + + +class DetectChangedPathsTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.repo = Path(self.temp.name) + self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.git("-c", "init.defaultBranch=main", "init", "-q") + (self.repo / "scripts/ci").mkdir(parents=True) + shutil.copy(SCRIPT, self.repo / "scripts/ci/detect-changed-paths.sh") + self.seed = self.commit(SEED) + + def tearDown(self): + self.temp.cleanup() + + def git(self, *args): + result = subprocess.run( + ["git", "-c", "user.name=test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args], + cwd=self.repo, env=self.env, check=True, capture_output=True, text=True, + ) + return result.stdout.strip() + + def commit(self, paths): + for path in paths: + file = self.repo / path + file.parent.mkdir(parents=True, exist_ok=True) + with open(file, "a", encoding="utf-8") as handle: + handle.write("change\n") + self.git("add", "-A") + self.git("commit", "-q", "-m", "change") + return self.git("rev-parse", "HEAD") + + def detect(self, suite, base=None): + args = ["bash", "scripts/ci/detect-changed-paths.sh"] + if base is not None: + args += ["--base", base] + result = subprocess.run(args + [suite], cwd=self.repo, env=self.env, capture_output=True, text=True) + return result.returncode, result.stdout.strip() + + def verdict_after(self, suite, path): + self.git("checkout", "-q", "-B", "case", self.seed) + self.commit([path]) + code, out = self.detect(suite, self.seed) + self.assertEqual(code, 0) + return out + + def test_unknown_suite_exits_2(self): + self.assertEqual(self.detect("linux")[0], 2) + + def test_unknown_flag_exits_2(self): + self.assertEqual(self.detect("--head")[0], 2) + + def test_no_base_runs_the_suite(self): + self.assertEqual(self.detect("release"), (0, "true")) + + def test_release_runs_on_what_the_release_build_reads(self): + for path in [ + "TablePro/App.swift", + "Plugins/OracleDriverPlugin/OraclePlugin.swift", + "Plugins/TableProPluginKit/PluginKit.swift", + "Packages/TableProCore/Sources/Core.swift", + "Packages/TableProCore/Package.swift", + "Native/HanaBridge/main.go", + "Configs/Base.xcconfig", + "Libs/checksums.sha256", + "project.yml", + "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + ".github/plugin-registry.json", + "scripts/release/build.sh", + "scripts/release/package.sh", + "scripts/build-hana.sh", + "scripts/lib/common.sh", + "scripts/ci/verify-plugin-loads.sh", + ".github/actions/setup-xcode/action.yml", + ".github/workflows/release-build.yml", + ]: + with self.subTest(path=path): + self.assertEqual(self.verdict_after("release", path), "true") + + def test_release_skips_tests_docs_and_other_workflows(self): + for path in [ + "TableProTests/AppTests.swift", + "TableProUITests/AppUITests.swift", + "TestPlans/Unit.xctestplan", + "Packages/TableProCore/Tests/CoreTests.swift", + "Plugins/MySQLDriverTests/MySQLTests.swift", + "TableProMobile/App.swift", + "CHANGELOG.md", + "README.md", + "scripts/ci/summarize-xcresult.sh", + ".github/workflows/macos-tests.yml", + ]: + with self.subTest(path=path): + self.assertEqual(self.verdict_after("release", path), "false") + + def test_macos_keeps_its_test_folders_and_ignores_the_release_workflow(self): + self.assertEqual(self.verdict_after("macos", "TableProTests/AppTests.swift"), "true") + self.assertEqual(self.verdict_after("macos", "Plugins/MySQLDriverTests/MySQLTests.swift"), "true") + self.assertEqual(self.verdict_after("macos", ".github/workflows/release-build.yml"), "false") + self.assertEqual(self.verdict_after("macos", "scripts/release/build.sh"), "false") + + def test_new_branch_at_main_skips(self): + self.git("update-ref", "refs/remotes/origin/main", self.seed) + self.assertEqual(self.detect("release", ZERO), (0, "false")) + self.assertEqual(self.detect("macos", ZERO), (0, "false")) + + def test_new_branch_compares_with_where_it_left_main(self): + self.git("update-ref", "refs/remotes/origin/main", self.seed) + self.commit(["TablePro/App.swift"]) + self.assertEqual(self.detect("release", ZERO), (0, "true")) + + def test_new_branch_ignores_commits_main_gained_since(self): + self.git("checkout", "-q", "-b", "upstream") + self.git("update-ref", "refs/remotes/origin/main", self.commit(["TablePro/App.swift"])) + self.git("checkout", "-q", "-B", "release/0.82", self.seed) + self.commit(["CHANGELOG.md"]) + self.assertEqual(self.detect("release", ZERO), (0, "false")) + + def test_new_branch_without_main_runs(self): + self.assertEqual(self.detect("release", ZERO), (0, "true")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/build.sh b/scripts/release/build.sh new file mode 100755 index 0000000000..373437f252 --- /dev/null +++ b/scripts/release/build.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: build.sh app|plugins [--unsigned] +# VERSION and BUILD, when set, become MARKETING_VERSION and CURRENT_PROJECT_VERSION. +# Writes build/release/TablePro.app and its archive, or build/release/plugins and plugins-dSYMs. + +usage() { + echo "Usage: build.sh app|plugins [--unsigned]" >&2 + exit 2 +} + +die() { + echo "build.sh: $*" >&2 + exit 1 +} + +KIND="" +SIGNED=true +for arg in "$@"; do + case "$arg" in + app | plugins) [ -z "$KIND" ] || usage; KIND="$arg" ;; + --unsigned) SIGNED=false ;; + *) usage ;; + esac +done +[ -n "$KIND" ] || usage +case "${BUILD:-}" in + *[!0-9]*) echo "build.sh: BUILD must be an integer, got '$BUILD'" >&2; exit 2 ;; +esac + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +[ -d TablePro.xcodeproj ] || die "no TablePro.xcodeproj; run scripts/generate-project.sh macos first" + +OUT="build/release" +DERIVED="$OUT/DerivedData" +EXPORT_OPTIONS="scripts/release/ExportOptions.plist" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +SETTINGS=(COMPILATION_CACHE_ENABLE_CACHING=NO COMPILER_INDEX_STORE_ENABLE=NO) +[ -z "${VERSION:-}" ] || SETTINGS+=("MARKETING_VERSION=$VERSION") +[ -z "${BUILD:-}" ] || SETTINGS+=("CURRENT_PROJECT_VERSION=$BUILD") +if [ "$SIGNED" = true ]; then + # xcodebuild expands $(TABLEPRO_DEVELOPMENT_TEAM) itself, from Configs/Base.xcconfig. + # shellcheck disable=SC2016 + SETTINGS+=(CODE_SIGN_STYLE=Manual "CODE_SIGN_IDENTITY=Developer ID Application" + 'DEVELOPMENT_TEAM=$(TABLEPRO_DEVELOPMENT_TEAM)') +else + SETTINGS+=(CODE_SIGNING_ALLOWED=NO) +fi + +run_xcodebuild() { + local name="$1" + shift + local log="$OUT/logs/$name.log" + mkdir -p "$OUT/logs" + echo "xcodebuild $name, log: $log" + if ! xcodebuild "$@" > "$log" 2>&1; then + grep -E '(error|fatal error):' "$log" | sort -u | head -40 >&2 || true + tail -40 "$log" >&2 + die "xcodebuild $name failed, full log: $log" + fi +} + +xcodebuild_project() { + local name="$1" + shift + run_xcodebuild "$name" -project TablePro.xcodeproj -configuration Release \ + -destination 'generic/platform=macOS' -derivedDataPath "$DERIVED" \ + -clonedSourcePackagesDirPath "$HOME/.spm-cache" -skipPackagePluginValidation \ + "$@" "${SETTINGS[@]}" +} + +is_macho() { + [ -f "$1" ] && [ ! -L "$1" ] && case "$(file -b "$1")" in *Mach-O*) true ;; *) false ;; esac +} + +check_universal() { + local bundle="$1" binary arch found=0 + for binary in "$bundle/Contents/MacOS"/* "$bundle/Contents/Helpers"/*; do + is_macho "$binary" || continue + # Xcode 27's lipo refuses a second arch after -verify_arch, so each is checked alone. + for arch in arm64 x86_64; do + lipo "$binary" -verify_arch "$arch" || die "$binary has no $arch slice: $(lipo -archs "$binary")" + done + found=1 + done + [ "$found" = 1 ] || die "$bundle has no executable" +} + +# codesign --verify --strict --deep also accepts an ad-hoc signed helper, which notarization +# rejects, so each executable is checked for what notarization needs. +check_signature() { + local bundle="$1" binary details + codesign --verify --strict --deep "$bundle" || die "$bundle fails codesign --verify" + for binary in "$bundle" "$bundle/Contents/MacOS"/* "$bundle/Contents/Helpers"/*; do + [ "$binary" = "$bundle" ] || is_macho "$binary" || continue + details="$(codesign -dvvv "$binary" 2>&1)" || die "$binary is not signed: $details" + grep -qE '^CodeDirectory .*flags=0x[0-9a-f]+\([^)]*runtime' <<< "$details" \ + || die "$binary is not signed with the hardened runtime" + grep -q '^Authority=Developer ID Application: ' <<< "$details" \ + || die "$binary is not signed with a Developer ID Application identity" + grep -q '^Timestamp=' <<< "$details" || die "$binary has no secure timestamp" + done +} + +build_app() { + local archive="$OUT/TablePro.xcarchive" app="$OUT/TablePro.app" name profile=() + rm -rf "$archive" "$app" "$OUT/export" + if [ "$SIGNED" = true ]; then + [ -f "$EXPORT_OPTIONS" ] || die "$EXPORT_OPTIONS is missing" + name="$(/usr/libexec/PlistBuddy -c 'Print :provisioningProfiles:com.TablePro' "$EXPORT_OPTIONS")" + profile=("TABLEPRO_PROVISIONING_PROFILE=$name") + fi + + xcodebuild_project archive-app archive -scheme TablePro -archivePath "$archive" \ + ${profile[@]+"${profile[@]}"} + # -exportArchive refuses a generic archive, which is what any other installed product makes it. + [ "$(plutil -extract ApplicationProperties.ApplicationPath raw -o - "$archive/Info.plist" 2> /dev/null)" \ + = "Applications/TablePro.app" ] || die "$archive is not an app archive: $(find "$archive/Products" -mindepth 2 -maxdepth 3)" + + if [ "$SIGNED" = true ]; then + run_xcodebuild export-app -exportArchive -archivePath "$archive" -exportPath "$OUT/export" \ + -exportOptionsPlist "$EXPORT_OPTIONS" + mv "$OUT/export/TablePro.app" "$app" + rm -rf "$OUT/export" + else + ditto "$archive/Products/Applications/TablePro.app" "$app" + fi + + check_universal "$app" + [ "$SIGNED" = false ] || check_signature "$app" + scripts/ci/verify-plugin-loads.sh "$app/Contents/PlugIns" "$app/Contents/Frameworks" + echo "Built $app ($(plutil -extract CFBundleShortVersionString raw -o - "$app/Contents/Info.plist"))" +} + +build_plugins() { + local products="$DERIVED/Build/Products/Release" targets="$WORK/targets" kit="$WORK/kit" target plugin extra=() + rm -rf "$OUT/plugins" "$OUT/plugins-dSYMs" + [ "$SIGNED" = false ] || extra=(OTHER_CODE_SIGN_FLAGS=--timestamp) + + xcodebuild_project build-plugins build -scheme RegistryPlugins ${extra[@]+"${extra[@]}"} + + # The publish set: the registry entries the app does not bundle. + python3 - .github/plugin-registry.json > "$targets" << 'PYTHON' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plugins = json.load(handle)["plugins"] +print("\n".join(sorted(entry["target"] for entry in plugins.values() if not entry.get("bundled")))) +PYTHON + [ -s "$targets" ] || die ".github/plugin-registry.json publishes no plugin" + + mkdir -p "$OUT/plugins" "$OUT/plugins-dSYMs" + while IFS= read -r target; do + plugin="$products/$target.tableplugin" + [ -d "$plugin" ] || die "RegistryPlugins built no $target.tableplugin" + [ -d "$plugin.dSYM" ] || die "RegistryPlugins built no $target.tableplugin.dSYM" + ditto "$plugin" "$OUT/plugins/$target.tableplugin" + ditto "$plugin.dSYM" "$OUT/plugins-dSYMs/$target.tableplugin.dSYM" + check_universal "$OUT/plugins/$target.tableplugin" + [ "$SIGNED" = false ] || check_signature "$OUT/plugins/$target.tableplugin" + done < "$targets" + + mkdir "$kit" + ln -s "$PWD/$products/TableProPluginKit.framework" "$kit/TableProPluginKit.framework" + scripts/ci/verify-plugin-loads.sh "$OUT/plugins" "$kit" + echo "Built $(wc -l < "$targets" | tr -d ' ') plugins in $OUT/plugins" +} + +"build_$KIND" diff --git a/scripts/release/package.sh b/scripts/release/package.sh new file mode 100755 index 0000000000..6c01ba5ac0 --- /dev/null +++ b/scripts/release/package.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: package.sh app|plugins [--unsigned] +# Turns what scripts/release/build.sh wrote to build/release into the release assets in dist/. + +usage() { + echo "Usage: package.sh app|plugins [--unsigned]" >&2 + exit 2 +} + +die() { + echo "package.sh: $*" >&2 + exit 1 +} + +KIND="" +VERSION="" +SIGNED=true +for arg in "$@"; do + case "$arg" in + --unsigned) SIGNED=false ;; + -*) usage ;; + *) + if [ -z "$KIND" ]; then + KIND="$arg" + elif [ -z "$VERSION" ]; then + VERSION="$arg" + else + usage + fi + ;; + esac +done +case "$KIND" in app | plugins) ;; *) usage ;; esac +case "$VERSION" in '' | *[!0-9A-Za-z.-]*) usage ;; esac + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +OUT="build/release" +DIST="dist" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +package_app() { + local app="$OUT/TablePro.app" dsyms="$OUT/TablePro.xcarchive/dSYMs" + local dmg="$DIST/TablePro-$VERSION.dmg" update="$DIST/TablePro-$VERSION.tar.xz" + local symbols="$DIST/TablePro-$VERSION-dSYMs.zip" extracted="$WORK/extracted" + [ -d "$app" ] || die "no $app; run scripts/release/build.sh app first" + [ -d "$dsyms" ] || die "no $dsyms; run scripts/release/build.sh app first" + command -v create-dmg > /dev/null || die "create-dmg is not installed: brew install create-dmg" + mkdir -p "$DIST" + rm -f "$dmg" "$update" "$symbols" + + mkdir "$WORK/dmg" + ditto "$app" "$WORK/dmg/TablePro.app" + create-dmg --volname "TablePro $VERSION" --volicon "$app/Contents/Resources/AppIcon.icns" \ + --window-pos 200 120 --window-size 600 400 --icon-size 80 --icon TablePro.app 150 190 \ + --hide-extension TablePro.app --app-drop-link 450 190 --format ULMO "$dmg" "$WORK/dmg" + if [ "$SIGNED" = true ]; then + codesign --sign "Developer ID Application" --timestamp "$dmg" + codesign --verify --strict "$dmg" || die "$dmg fails codesign --verify" + fi + + # tar keeps the framework symlinks codesign seals; a signed bundle needs no extended attributes. + /usr/bin/tar --no-mac-metadata --no-xattrs --no-acls --uid 0 --gid 0 \ + --options xz:compression-level=9 -cJf "$update" -C "$OUT" TablePro.app + + # Sparkle's SUPipedUnarchiver pipes a .tar.xz into this same command. + mkdir "$extracted" + /usr/bin/tar -zxC "$extracted" < "$update" + [ "$(ls -A "$extracted")" = "TablePro.app" ] || die "$update must hold TablePro.app and nothing else" + diff -rq --no-dereference "$app" "$extracted/TablePro.app" || die "$update does not extract to $app" + if [ "$SIGNED" = true ]; then + codesign --verify --deep --strict "$extracted/TablePro.app" || die "the app in $update fails codesign --verify" + fi + + ditto -c -k --keepParent "$dsyms" "$symbols" + ls -lh "$dmg" "$update" "$symbols" +} + +package_plugins() { + local plugin target symbols="$DIST/TablePro-$VERSION-Plugins-dSYMs.zip" count=0 + [ -d "$OUT/plugins" ] || die "no $OUT/plugins; run scripts/release/build.sh plugins first" + [ -d "$OUT/plugins-dSYMs" ] || die "no $OUT/plugins-dSYMs; run scripts/release/build.sh plugins first" + mkdir -p "$DIST" + rm -f "$DIST"/*-"$VERSION".zip "$symbols" + + for plugin in "$OUT/plugins"/*.tableplugin; do + [ -d "$plugin" ] || continue + target="$(basename "$plugin" .tableplugin)" + ditto -c -k --keepParent "$plugin" "$DIST/$target-$VERSION.zip" + count=$((count + 1)) + done + [ "$count" -gt 0 ] || die "no plugins in $OUT/plugins" + + ditto -c -k --keepParent "$OUT/plugins-dSYMs" "$symbols" + echo "Packaged $count plugins as $DIST/-$VERSION.zip" + ls -lh "$symbols" +} + +"package_$KIND"