From 6a1ebe583e9b30ea6a4a0229f95d4f6cb70d0f6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 18:04:05 +0700 Subject: [PATCH] feat(integrations): sync the integrations registry into the site --- .github/workflows/integrations-sync.yml | 237 +++++++++++++++ CONTRIBUTING.md | 4 + LICENSE-CONTENT | 8 +- README.md | 14 +- app/Providers/AppServiceProvider.php | 5 + app/Support/Content/IntegrationCatalog.php | 96 +++++++ composer.json | 1 + composer.lock | 192 ++++++++++++- docs/deployment.md | 4 +- resources/data/integrations.json | 8 + tests/Feature/Data/IntegrationsDataTest.php | 124 ++++++++ .../command-line-icon-128-c39d875c6357.png | Bin 0 -> 4515 bytes .../command-line-icon-512-ee2ee8eecb33.png | Bin 0 -> 18542 bytes .../shortcuts-icon-128-005e96c58ac1.png | Bin 0 -> 4154 bytes .../shortcuts-icon-512-4e4cc25aa23d.png | Bin 0 -> 15860 bytes tests/Fixtures/integrations/index.json | 180 ++++++++++++ .../Support/integrations-index.v1.schema.json | 270 ++++++++++++++++++ 17 files changed, 1134 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/integrations-sync.yml create mode 100644 app/Support/Content/IntegrationCatalog.php create mode 100644 resources/data/integrations.json create mode 100644 tests/Feature/Data/IntegrationsDataTest.php create mode 100644 tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png create mode 100644 tests/Fixtures/integrations/images/command-line-icon-512-ee2ee8eecb33.png create mode 100644 tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png create mode 100644 tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png create mode 100644 tests/Fixtures/integrations/index.json create mode 100644 tests/Support/integrations-index.v1.schema.json diff --git a/.github/workflows/integrations-sync.yml b/.github/workflows/integrations-sync.yml new file mode 100644 index 00000000..d739b0c5 --- /dev/null +++ b/.github/workflows/integrations-sync.yml @@ -0,0 +1,237 @@ +name: integrations sync + +# Copies a TableProApp/integrations release into resources/data/integrations.json +# and public/images/integrations, then opens a pull request as the web bot +# (docs/deployment.md, "The web bot"). +# +# `fetch` parses the download and holds no secret. `pull-request` holds the +# App token, runs nothing from the release and copies only those two paths. +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + inputs: + tag: + description: "Registry release, v1.YYYYMMDD.N (empty for the latest)" + required: false + type: string + +permissions: {} + +concurrency: + group: integrations-sync + cancel-in-progress: false + +env: + REGISTRY: TableProApp/integrations + +jobs: + fetch: + runs-on: ubuntu-26.04 + timeout-minutes: 15 + permissions: + contents: read + outputs: + changed: ${{ steps.place.outputs.changed }} + tag: ${{ steps.release.outputs.tag }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve the release + id: release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + if [ -z "$TAG" ]; then + TAG=$(gh release list --repo "$REGISTRY" --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName') + fi + if [ -z "$TAG" ]; then + echo "The registry has no release yet." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if ! [[ "$TAG" =~ ^v1\.[0-9]{8}\.[0-9]+$ ]]; then + echo "::error::The tag is not a v1 registry release (v1.YYYYMMDD.N)." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Download and verify the release + if: steps.release.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + RELEASE: ${{ runner.temp }}/release + run: | + gh release download "$TAG" --repo "$REGISTRY" --dir "$RELEASE" \ + --pattern index.json --pattern SHA256SUMS --pattern '*.png' + cd "$RELEASE" + for file in *; do + if ! [[ "$file" =~ ^(index\.json|SHA256SUMS|[a-z0-9]+(-[a-z0-9]+)*-[0-9a-f]{12}\.png)$ ]]; then + echo "::error::The release has an asset name outside the index contract." + exit 1 + fi + done + gh release verify "$TAG" --repo "$REGISTRY" + for file in *; do + gh release verify-asset "$TAG" "./$file" --repo "$REGISTRY" + done + sha256sum --check --strict SHA256SUMS + listed=$(awk '{ print $2 }' SHA256SUMS | sort) + downloaded=$(find . -maxdepth 1 -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) + if [ "$listed" != "$downloaded" ]; then + echo "::error::SHA256SUMS and the downloaded assets differ." + exit 1 + fi + + - name: Place the files + id: place + if: steps.release.outputs.tag != '' + env: + RELEASE: ${{ runner.temp }}/release + run: | + cp "$RELEASE/index.json" resources/data/integrations.json + rm -rf public/images/integrations + mkdir -p public/images/integrations + find "$RELEASE" -maxdepth 1 -type f -name '*.png' -exec cp {} public/images/integrations/ \; + if [ -n "$(git status --porcelain -- resources/data/integrations.json public/images/integrations)" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "The site already has this release." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Setup PHP + if: steps.place.outputs.changed == 'true' + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: "8.5" + tools: composer:v2 + coverage: none + + - name: Resolve the Composer cache directory + id: composer-cache + if: steps.place.outputs.changed == 'true' + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + if: steps.place.outputs.changed == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}- + + - name: Test the data + if: steps.place.outputs.changed == 'true' + run: | + composer install --no-interaction --prefer-dist + cp .env.example .env + php artisan key:generate + php artisan test --compact tests/Feature/Data/IntegrationsDataTest.php + + # A non-English hub lists an active entry once every locale has its + # tagline and the English tagline still equals the registry summary. + - name: Write the pull request body + if: steps.place.outputs.changed == 'true' + env: + TAG: ${{ steps.release.outputs.tag }} + SYNC: ${{ runner.temp }}/sync + run: | + mkdir -p "$SYNC/images" + git show HEAD:resources/data/integrations.json > "$RUNNER_TEMP/before.json" 2>/dev/null \ + || echo '{"integrations": []}' > "$RUNNER_TEMP/before.json" + for locale in $(jq -r '.supported | keys[]' resources/data/locales.json); do + file="resources/data/content/$locale/integrations/index.json" + if [ -f "$file" ]; then + jq -c --arg locale "$locale" '{($locale): (.taglines // {})}' "$file" + else + jq -nc --arg locale "$locale" '{($locale): {}}' + fi + done | jq -s 'add' > "$RUNNER_TEMP/taglines.json" + jq -nr --arg tag "$TAG" --arg registry "$REGISTRY" \ + --slurpfile before "$RUNNER_TEMP/before.json" \ + --slurpfile after resources/data/integrations.json \ + --slurpfile taglines "$RUNNER_TEMP/taglines.json" ' + def bySlug: .integrations | map({key: .slug, value: .}) | from_entries; + def list: if length == 0 then "- None" else map("- `\(.)`") | join("\n") end; + ($before[0] | bySlug) as $old + | ($after[0] | bySlug) as $new + | $taglines[0] as $t + | [$new | keys[] | select($old[.] == null)] as $added + | [$old | keys[] | select($new[.] == null)] as $removed + | [$new | keys[] | select($old[.] != null and $old[.] != $new[.])] as $changed + | [$new[] | select(.status.state == "active") | . as $entry + | select($t.en[$entry.slug] != $entry.summary + or any($t | to_entries[] | select(.key != "en"); .value[$entry.slug] == null)) + | $entry.slug] as $untranslated + | "Registry release [\($tag)](https://github.com/\($registry)/releases/tag/\($tag)), copied by `integrations-sync.yml`.", + "", "**Added**", ($added | list), + "", "**Changed**", ($changed | list), + "", "**Removed**", ($removed | list), + "", "**English only until the tagline is translated**", ($untranslated | list) + ' > "$SYNC/pr-body.md" + cp resources/data/integrations.json "$SYNC/integrations.json" + find public/images/integrations -maxdepth 1 -type f -name '*.png' -exec cp {} "$SYNC/images/" \; + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.place.outputs.changed == 'true' + with: + name: integrations-sync + path: ${{ runner.temp }}/sync/ + if-no-files-found: error + retention-days: 1 + + pull-request: + needs: fetch + if: needs.fetch.outputs.changed == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 10 + environment: web-bot + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: integrations-sync + path: ${{ runner.temp }}/sync + + - name: Place the files + env: + SYNC: ${{ runner.temp }}/sync + run: | + cp "$SYNC/integrations.json" resources/data/integrations.json + rm -rf public/images/integrations + mkdir -p public/images/integrations + find "$SYNC/images" -maxdepth 1 -type f -name '*.png' -exec cp {} public/images/integrations/ \; + + - name: Mint a web bot token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.WEB_BOT_CLIENT_ID }} + private-key: ${{ secrets.WEB_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Open a pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + sign-commits: true + branch: bot/integrations-sync + delete-branch: true + commit-message: "chore(integrations): sync registry ${{ needs.fetch.outputs.tag }}" + title: "chore(integrations): sync registry ${{ needs.fetch.outputs.tag }}" + body-path: ${{ runner.temp }}/sync/pr-body.md + # A directory, not a glob: git add accepts it when a release has no images. + add-paths: | + resources/data/integrations.json + public/images/integrations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1570dfd2..d966a53c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,3 +66,7 @@ screenshots and stand up a competing commercial site with them. Do not commit images you do not have the rights to. Third-party logos under `public/images/sponsors/` belong to their owners and are used with permission; neither licence above extends to them. + +`resources/data/integrations.json` and `public/images/integrations/` are copied +from [TableProApp/integrations](https://github.com/TableProApp/integrations) +under its terms, and the next sync replaces them. Change an entry there. diff --git a/LICENSE-CONTENT b/LICENSE-CONTENT index b52dfbc4..65671cfb 100644 --- a/LICENSE-CONTENT +++ b/LICENSE-CONTENT @@ -13,9 +13,11 @@ This licence covers the EDITORIAL CONTENT of this repository: The source code in this repository is licensed separately under the MIT licence. See LICENSE. -Excluded from this licence: third-party logos under public/images/sponsors, -which are the property of their respective owners, and the TablePro name and -logo, which are trademarks. +Excluded from this licence: third-party logos under public/images/sponsors and +the icons and screenshots under public/images/integrations, which are the +property of their respective owners; resources/data/integrations.json, which is +copied from TableProApp/integrations, where it is CC0-1.0; and the TablePro +name and logo, which are trademarks. YOU ARE FREE TO: diff --git a/README.md b/README.md index bf376dcc..efd53e59 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ If any instruction ever tells you otherwise, that is a bug in this README. | `resources/js/i18n/` | UI strings per language, and the locale helpers. | | `app/Http/Controllers/` | One controller per page family. | | `public/og/` | Pre-rendered Open Graph cards, committed. | +| `resources/data/integrations.json`, `public/images/integrations/` | Copied from a [TableProApp/integrations](https://github.com/TableProApp/integrations) release by `.github/workflows/integrations-sync.yml`. Change entries there. | ## Writing a blog post @@ -129,6 +130,13 @@ Two licences, because code and writing want different terms: the images under `public/images` and `public/og`. Share and adapt it with attribution, but not commercially. -Neither licence covers the third-party logos under `public/images/sponsors`, -which belong to their owners, or the TablePro name and logo, which are -trademarks. See [CONTRIBUTING.md](CONTRIBUTING.md#licensing). +Neither licence covers: + +- the third-party logos under `public/images/sponsors` and the integration + icons and screenshots under `public/images/integrations`, which belong to + their owners; +- `resources/data/integrations.json`, which is CC0-1.0 in + [TableProApp/integrations](https://github.com/TableProApp/integrations); +- the TablePro name and logo, which are trademarks. + +See [CONTRIBUTING.md](CONTRIBUTING.md#licensing). diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fb4ab006..44a317ff 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Services\Og\BrowsershotOgImageRenderer; use App\Services\Og\OgImageRenderer; use App\Support\Content\ContentRepository; +use App\Support\Content\IntegrationCatalog; use App\Support\Content\MarkdownRenderer; use Illuminate\Support\ServiceProvider; @@ -31,5 +32,9 @@ public function register(): void )); $this->app->singleton(MarkdownRenderer::class); + + $this->app->singleton(IntegrationCatalog::class, fn(): IntegrationCatalog => new IntegrationCatalog( + path: resource_path('data/integrations.json'), + )); } } diff --git a/app/Support/Content/IntegrationCatalog.php b/app/Support/Content/IntegrationCatalog.php new file mode 100644 index 00000000..3dac27ae --- /dev/null +++ b/app/Support/Content/IntegrationCatalog.php @@ -0,0 +1,96 @@ +, + * platforms: list, + * categories: list, + * surfaces: list, + * disclosures: array, + * links: array, + * icon: array{512: Image, 128: Image}, + * screenshots: list, + * keywords?: list, + * addedAt: string, + * lastVerifiedAt: string, + * } + */ +final class IntegrationCatalog +{ + public const int SCHEMA_VERSION = 1; + + /** + * @var array|null + */ + private ?array $integrations = null; + + public function __construct( + private readonly string $path, + ) {} + + /** + * @return list + */ + public function all(): array + { + return array_values($this->integrations()); + } + + /** + * @return Integration|null + */ + public function find(string $slug): ?array + { + return $this->integrations()[$slug] ?? null; + } + + /** + * @return array + */ + private function integrations(): array + { + if ($this->integrations !== null) { + return $this->integrations; + } + + $data = File::json($this->path, JSON_THROW_ON_ERROR); + + if (! is_array($data) || ($data['schemaVersion'] ?? null) !== self::SCHEMA_VERSION || ! is_array($data['integrations'] ?? null) || ! array_is_list($data['integrations'])) { + throw new UnexpectedValueException("{$this->path} is not a v" . self::SCHEMA_VERSION . ' integrations index.'); + } + + $integrations = []; + + foreach ($data['integrations'] as $integration) { + $slug = $integration['slug'] ?? null; + + if (! is_string($slug) || isset($integrations[$slug])) { + throw new UnexpectedValueException("{$this->path} has a missing or repeated slug."); + } + + $integrations[$slug] = $integration; + } + + return $this->integrations = $integrations; + } +} diff --git a/composer.json b/composer.json index cd115413..9172dfb4 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "laravel/pint": "^1.32.1", "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9.5", + "opis/json-schema": "^2.6.0", "pestphp/pest": "^5.3", "pestphp/pest-plugin-laravel": "^5.0.1" }, diff --git a/composer.lock b/composer.lock index 1fb20db9..93300497 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "680f792681663efbeb17d03fada55a26", + "content-hash": "16ccd9b7f4b48d45c778b6e04b8fd66d", "packages": [ { "name": "brick/math", @@ -7579,6 +7579,196 @@ ], "time": "2026-07-15T19:09:14+00:00" }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "pestphp/pest", "version": "v5.3.0", diff --git a/docs/deployment.md b/docs/deployment.md index c7a20252..8c1cc68f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -415,8 +415,8 @@ cache, it is used on the runner, and it is never sent to the server. ## The web bot `main` takes changes only through a pull request with green checks (the -repository rulesets), so `og.yml` cannot push to it. It opens a pull request -as the GitHub App `tablepro-web-bot` instead. +repository rulesets), so `og.yml` and `integrations-sync.yml` cannot push to +it. They open pull requests as the GitHub App `tablepro-web-bot` instead. - **What it can do.** The App is installed on this repository only, with Contents and Pull requests read and write, and no Workflows permission. A run diff --git a/resources/data/integrations.json b/resources/data/integrations.json new file mode 100644 index 00000000..ebd6c6a2 --- /dev/null +++ b/resources/data/integrations.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-10-10T07:41:01Z", + "commit": "f6c6d0ffd22190ac19e148d86ba1b26058851de7", + "contentSha256": "9230f56066af08026e1500e27f6e2ccc4ab6a26e6fa2b5040c8412dc77411b4a", + "integrations": [], + "removed": [] +} diff --git a/tests/Feature/Data/IntegrationsDataTest.php b/tests/Feature/Data/IntegrationsDataTest.php new file mode 100644 index 00000000..b482cfbf --- /dev/null +++ b/tests/Feature/Data/IntegrationsDataTest.php @@ -0,0 +1,124 @@ + ['resources/data/integrations.json', 'public/images/integrations'], + 'a two-entry release' => ['tests/Fixtures/integrations/index.json', 'tests/Fixtures/integrations/images'], +]); + +/** + * @return array{integrations: list>} + */ +function integrationsIndex(string $index): array +{ + return json_decode(File::get(base_path($index)), true, 512, JSON_THROW_ON_ERROR); +} + +it('matches the index schema the registry publishes', function (string $index, string $images): void { + $validator = new Validator(); + $validator->setMaxErrors(20); + + $result = $validator->validate( + json_decode(File::get(base_path($index)), false, 512, JSON_THROW_ON_ERROR), + File::get(base_path(INTEGRATIONS_SCHEMA)), + ); + $errors = $result->hasError() ? (new ErrorFormatter())->format($result->error()) : []; + + expect($errors)->toBe([]); +})->with('integration indexes'); + +it('gives every integration its own slug, safe in a URL', function (string $index, string $images): void { + $slugs = array_column(integrationsIndex($index)['integrations'], 'slug'); + + foreach ($slugs as $slug) { + expect($slug)->toMatch('/^[a-z0-9]+(?:-[a-z0-9]+)*$/'); + } + + expect($slugs)->toBe(array_values(array_unique($slugs))); +})->with('integration indexes'); + +it('ships every image the index names, as the PNG it describes, and no other file', function (string $index, string $images): void { + $named = []; + + foreach (integrationsIndex($index)['integrations'] as $integration) { + foreach ([...array_values($integration['icon']), ...$integration['screenshots']] as $image) { + $path = base_path("{$images}/{$image['file']}"); + $named[] = $image['file']; + + expect(is_file($path))->toBeTrue("{$integration['slug']}: {$image['file']} is missing"); + expect(hash_file('sha256', $path))->toBe($image['sha256'], $image['file']); + expect(array_slice(getimagesize($path) ?: [], 0, 3))->toBe([$image['width'], $image['height'], IMAGETYPE_PNG], $image['file']); + } + } + + $directory = base_path($images); + $files = is_dir($directory) ? array_values(array_diff(scandir($directory) ?: [], ['.', '..'])) : []; + $named = array_values(array_unique($named)); + sort($files); + sort($named); + + expect($files)->toBe($named); +})->with('integration indexes'); + +it('links only to https addresses', function (string $index, string $images): void { + $wrong = []; + + foreach (integrationsIndex($index)['integrations'] as $integration) { + $urls = array_filter([ + $integration['install']['url'], + $integration['source']['url'] ?? null, + $integration['publisher']['url'] ?? null, + $integration['host']['url'] ?? null, + $integration['status']['link'] ?? null, + ...array_values($integration['links']), + ], fn(mixed $url): bool => $url !== null); + + foreach ($urls as $url) { + $https = is_string($url) + && filter_var($url, FILTER_VALIDATE_URL) !== false + && parse_url($url, PHP_URL_SCHEME) === 'https' + && parse_url($url, PHP_URL_USER) === null; + + if (! $https) { + $wrong[] = "{$integration['slug']}: " . json_encode($url); + } + } + } + + expect($wrong)->toBe([]); +})->with('integration indexes'); + +it('reads the site data through the container', function (): void { + $slugs = array_column(integrationsIndex('resources/data/integrations.json')['integrations'], 'slug'); + + expect(array_column(app(IntegrationCatalog::class)->all(), 'slug'))->toBe($slugs); +}); + +it('finds an integration by its slug', function (): void { + $catalog = new IntegrationCatalog(base_path('tests/Fixtures/integrations/index.json')); + + expect(array_column($catalog->all(), 'slug'))->toBe(['command-line', 'shortcuts']); + expect($catalog->find('shortcuts')['name'] ?? null)->toBe('Shortcuts'); + expect($catalog->find('raycast'))->toBeNull(); +}); + +it('refuses an index it cannot read safely', function (array $index): void { + $path = tempnam(sys_get_temp_dir(), 'integrations') ?: throw new RuntimeException('No temp file.'); + File::put($path, json_encode($index, JSON_THROW_ON_ERROR)); + + try { + expect(fn(): array => (new IntegrationCatalog($path))->all())->toThrow(UnexpectedValueException::class); + } finally { + File::delete($path); + } +})->with([ + 'a later schema version' => [['schemaVersion' => 2, 'integrations' => []]], + 'a repeated slug' => [['schemaVersion' => 1, 'integrations' => [['slug' => 'raycast'], ['slug' => 'raycast']]]], +]); diff --git a/tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png b/tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png new file mode 100644 index 0000000000000000000000000000000000000000..cba9b2b8664c5663020731c4b1802200aa4e7893 GIT binary patch literal 4515 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 literal 0 HcmV?d00001 diff --git a/tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png b/tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png new file mode 100644 index 0000000000000000000000000000000000000000..46f9f55570ffd3287345661c13e04939f6c05b4a GIT binary patch literal 4154 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 Ef^JI7AOHXW literal 0 HcmV?d00001 diff --git a/tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png b/tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png new file mode 100644 index 0000000000000000000000000000000000000000..83d103cfccb36bb4e6e40f4e5639552c7c0e864c GIT binary patch literal 15860 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 ij{pDi?$^Qd9OtmEd literal 0 HcmV?d00001 diff --git a/tests/Fixtures/integrations/index.json b/tests/Fixtures/integrations/index.json new file mode 100644 index 00000000..135e7f92 --- /dev/null +++ b/tests/Fixtures/integrations/index.json @@ -0,0 +1,180 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-10-10T10:19:30Z", + "commit": "416ffad0c54749fed4de3ab60ca61bb3061349c7", + "release": "v1.20261010.1", + "contentSha256": "9a643efdd4d011bbaad102e8fdcacc1608de24691c7627e4eae6f9d2e8f32d1a", + "integrations": [ + { + "slug": "command-line", + "name": "Command line", + "summary": "Open database URLs and SQL files in TablePro from Terminal with the tablepro command.", + "description": "The tablepro command opens a connection URL, a database file or a SQL file in TablePro from Terminal or a script. tablepro \"postgresql://user@127.0.0.1:5432/app\" opens that database, and tablepro schema.sql opens the file in a query tab.\n\nA URL that matches a saved connection uses it. Any other URL opens a temporary session that is not saved. Scripts can also run open -b com.TablePro with the same URL.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TablePro/Core/Services/Infrastructure", + "url": "https://github.com/TableProApp/TablePro/tree/HEAD/TablePro/Core/Services/Infrastructure" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/terminal", + "url": "https://docs.tablepro.app/integrations/terminal" + }, + "minTableProVersion": { + "macos": "0.57.0" + }, + "platforms": [ + "macos" + ], + "categories": [ + "command-line" + ], + "surfaces": [ + "cli", + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/terminal", + "docs": "https://docs.tablepro.app/connections/urls", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": { + "512": { + "file": "command-line-icon-512-ee2ee8eecb33.png", + "width": 512, + "height": 512, + "sha256": "ee2ee8eecb33b49d9fa995e8a3faed66a140dbab7313713e4961a3bb6cdd7dc0" + }, + "128": { + "file": "command-line-icon-128-c39d875c6357.png", + "width": 128, + "height": 128, + "sha256": "c39d875c6357430c83de5685d6d58da6ae44dd87a1d3f4852b5f73d7f39b6c7a" + } + }, + "keywords": [ + "terminal", + "shell", + "cli", + "database url" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10", + "summarySha256": "b9a57ebb1c91b32cb7be94df126462c870c388e590398cdf0993119db0305b2f", + "closedSource": false, + "screenshots": [] + }, + { + "slug": "shortcuts", + "name": "Shortcuts", + "summary": "Add rows to a table and open a connection from Shortcuts on iPhone and iPad.", + "description": "The TablePro app for iPhone and iPad adds three actions to Shortcuts, Siri and the Share Sheet: Add Row to Table, Add Rows to Table and Open Connection.\n\nThe two insert actions take JSON or CSV, insert up to 10,000 rows per run, and run in the background without opening the app.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TableProMobile/TableProMobile/Intents", + "url": "https://github.com/TableProApp/TablePro/tree/HEAD/TableProMobile/TableProMobile/Intents" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/ios-shortcuts", + "url": "https://docs.tablepro.app/integrations/ios-shortcuts" + }, + "host": { + "name": "Shortcuts", + "url": "https://support.apple.com/guide/shortcuts/welcome/ios" + }, + "minTableProVersion": { + "ios": "1.0", + "ipados": "1.0" + }, + "platforms": [ + "ios", + "ipados" + ], + "categories": [ + "automation" + ], + "surfaces": [ + "shortcuts" + ], + "disclosures": { + "reads": [ + "connections", + "schema" + ], + "writes": [ + "data" + ], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/ios-shortcuts", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": { + "512": { + "file": "shortcuts-icon-512-4e4cc25aa23d.png", + "width": 512, + "height": 512, + "sha256": "4e4cc25aa23d475eeec1315ab0099c21cc64cacce796cc92e31314c596482ebe" + }, + "128": { + "file": "shortcuts-icon-128-005e96c58ac1.png", + "width": 128, + "height": 128, + "sha256": "005e96c58ac156191391d3e3e25c014e00c5e0efa4a67322b36a43cec4b950c8" + } + }, + "keywords": [ + "ios", + "ipad", + "siri", + "app intents", + "automation" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10", + "summarySha256": "9208fcf6688e6cec5ad0dbaaeb1b4510590ce02416855aecca7aa8bed6ef3b49", + "closedSource": false, + "screenshots": [] + } + ], + "removed": [] +} diff --git a/tests/Support/integrations-index.v1.schema.json b/tests/Support/integrations-index.v1.schema.json new file mode 100644 index 00000000..5e968a55 --- /dev/null +++ b/tests/Support/integrations-index.v1.schema.json @@ -0,0 +1,270 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://integrations.tablepro.app/schema/v1/index.schema.json", + "title": "TablePro integrations index, v1", + "description": "The published index, at https://integrations.tablepro.app/v1/index.json and as the index.json asset of each release. A release holds index.json, SHA256SUMS and every image this file names, each as a flat asset listed in SHA256SUMS. Consumers ignore unknown properties and treat unknown enum-like values (tier, category, surface, install type, platform) as absent or as other. New optional properties and new values do not change v1. A removed or renamed property, a new required property or a type change ships as v2 beside v1.", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "commit", + "contentSha256", + "integrations", + "removed" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "release": { + "description": "Release tag this file was attached to. Absent on Pages builds made before the release exists.", + "type": "string", + "pattern": "^v1\\.[0-9]{8}\\.[0-9]+$" + }, + "contentSha256": { + "description": "SHA-256 over everything except generatedAt, commit and release. Equal values mean the same data.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "integrations": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + }, + "removed": { + "description": "Tombstones. A consumer answers 410 Gone for these slugs.", + "type": "array", + "items": { + "type": "object", + "required": [ + "slug", + "removedAt", + "reason" + ], + "properties": { + "slug": { + "type": "string" + }, + "removedAt": { + "type": "string", + "format": "date" + }, + "reason": { + "type": "string" + } + } + } + } + }, + "$defs": { + "image": { + "type": "object", + "required": [ + "file", + "width", + "height", + "sha256" + ], + "properties": { + "file": { + "description": "Flat file name ending in the first 12 hex digits of its SHA-256, for example raycast-icon-512-0123456789ab.png. It resolves relative to index.json: a release asset of the same name, or the file beside index.json on Pages. Safe to cache forever.", + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\\.png$" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "entry": { + "type": "object", + "required": [ + "slug", + "name", + "summary", + "summarySha256", + "description", + "tier", + "status", + "publisher", + "source", + "closedSource", + "install", + "platforms", + "categories", + "surfaces", + "disclosures", + "links", + "icon", + "screenshots", + "addedAt", + "lastVerifiedAt" + ], + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "summarySha256": { + "description": "SHA-256 of summary. A consumer that stores translations keeps this beside each one to spot a changed English source.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "description": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "status": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "publisher": { + "type": "object", + "required": [ + "name", + "github", + "githubId" + ] + }, + "source": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Browsable source URL, derived from repo and path.", + "type": "string" + } + } + }, + "closedSource": { + "type": "boolean" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "install": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Resolved destination for the install button, for every install type.", + "type": "string" + } + } + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "disclosures": { + "type": "object" + }, + "links": { + "type": "object", + "required": [ + "homepage" + ] + }, + "icon": { + "type": "object", + "required": [ + "512", + "128" + ], + "properties": { + "512": { + "$ref": "#/$defs/image" + }, + "128": { + "$ref": "#/$defs/image" + } + } + }, + "screenshots": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/image" + } + ], + "required": [ + "alt" + ], + "properties": { + "alt": { + "type": "string" + } + } + } + }, + "addedAt": { + "type": "string", + "format": "date" + }, + "lastVerifiedAt": { + "type": "string", + "format": "date" + } + } + } + } +}