From dfb858a7d494e69ea6e26c9d5a00fd99ac26ade2 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 21:56:28 -0400 Subject: [PATCH 1/4] ci: deploy studio.geolibre.app on every push to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit studio.geolibre.app is the Clerk-gated instance of this app, hosted from the separate private repo opengeos/studio.geolibre.app. That repo only holds the built site, so this cannot use actions/deploy-pages (which deploys the Pages site of the repository it runs in) — the build is force-pushed as a single orphan commit to its gh-pages branch instead, keeping that repo the size of one build rather than one commit per merge. The workflow lives here so the deployment is versioned with the code it ships. It needs two secrets: VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY (the gate) and STUDIO_DEPLOY_TOKEN (write access to the hosting repo). A missing key builds cleanly and would publish an *ungated* instance, so the run fails unless the configured key is actually in the bundle. The check matches the key's value rather than its pk_test_/pk_live_ prefix: @clerk/shared ships those prefixes as literals in its own validation, so a prefix check passes even with no key configured. Also add https://studio.geolibre.app to the AI proxy's ALLOWED_ORIGINS. GitHub Pages cannot proxy, so unlike the Docker image the assistant and the disaster-news lookup call the Worker cross-origin and 401 without it. --- .github/workflows/studio-deploy.yml | 141 ++++++++++++++++++++++++++++ workers/ai-proxy/wrangler.jsonc | 2 +- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/studio-deploy.yml diff --git a/.github/workflows/studio-deploy.yml b/.github/workflows/studio-deploy.yml new file mode 100644 index 000000000..803e8db1e --- /dev/null +++ b/.github/workflows/studio-deploy.yml @@ -0,0 +1,141 @@ +name: Deploy studio.geolibre.app + +# Publishes the gated instance at https://studio.geolibre.app on every push to +# main. +# +# studio.geolibre.app is a *separate* private repository that only hosts the +# built site, so this cannot use actions/deploy-pages — that action deploys the +# Pages site of the repository it runs in. Instead the build is force-pushed as +# a single orphan commit to that repository's `gh-pages` branch, which its Pages +# site serves. Keeping the workflow here means the deployment is versioned +# alongside the code it ships. +# +# This is the same app as web.geolibre.app with the Clerk sign-in gate switched +# on. It is a client-side gate, not a server boundary: see docs/getting-started.md. + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + ref: + description: Ref to build and publish (branch, tag, or SHA) + default: main + +permissions: + contents: read + +concurrency: + # Never let two deploys race for the same force-pushed branch. + group: studio-deploy + cancel-in-progress: true + +jobs: + deploy: + name: Build and publish + # Forks have neither the deploy token nor a reason to publish. + if: github.repository == 'opengeos/GeoLibre' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: npm + cache-dependency-path: package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Build the gated web app + run: npm run build -w geolibre-desktop + env: + # No GEOLIBRE_APP_BASE: this site is served from the root of its own + # domain, unlike the /demo/ subpath pages.yml publishes. + # + # The Clerk publishable key, which turns the sign-in gate on. It is + # public by design (it encodes only the Frontend API hostname), so a + # repository variable would do; a secret keeps rotation in one place. + # Unset means no gate, so a missing secret publishes an open app — + # the guard step below fails the run instead. + VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY }} + # Clerk's waitlist form, for an instance whose sign-up mode is + # Waitlist. Drop this for an invite-only (Restricted) instance. + VITE_GEOLIBRE_CLERK_WAITLIST: "1" + # The AI assistant and the NASA OPERA disaster-news lookup. Pages + # cannot proxy, so these are cross-origin calls to the Worker and + # https://studio.geolibre.app must be in its ALLOWED_ORIGINS. + VITE_GEOLIBRE_AI_URL: https://ai.geolibre.app + VITE_GEOLIBRE_AI_MODEL: ${{ vars.VITE_GEOLIBRE_AI_MODEL }} + # Optional third-party keys, as in pages.yml. Each is baked into the + # bundle and readable by any signed-in visitor; the matching features + # hide themselves when unset. + VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} + VITE_PROTOMAPS_API_KEY: ${{ secrets.VITE_PROTOMAPS_API_KEY }} + VITE_GOOGLE_MAPS_API_KEY: ${{ secrets.VITE_GOOGLE_MAPS_API_KEY }} + VITE_MAPILLARY_ACCESS_TOKEN: ${{ secrets.VITE_MAPILLARY_ACCESS_TOKEN || vars.VITE_MAPILLARY_ACCESS_TOKEN }} + VITE_GEOLIBRE_COLLAB_URL: wss://collab.geolibre.app + + - name: Verify the gate is in the bundle + # The point of this deployment is that it is gated. A missing or + # misspelled key builds cleanly and publishes an *open* instance, so + # confirm the configured key reached the bundle before anything is + # pushed. + # + # Matches the key's exact value, not its `pk_test_`/`pk_live_` prefix: + # @clerk/shared ships those prefixes as literals in its own key + # validation, so they appear in the bundle whether or not a key is + # configured, and a prefix check would pass on an ungated build. + env: + CLERK_KEY: ${{ secrets.VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY }} + run: | + if [ -z "$CLERK_KEY" ]; then + echo "::error::VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY is unset — refusing to publish an ungated studio.geolibre.app." + exit 1 + fi + if ! grep -rqsF -- "$CLERK_KEY" apps/geolibre-desktop/dist/assets; then + echo "::error::The configured Clerk key is not in the build — refusing to publish an ungated studio.geolibre.app." + exit 1 + fi + + - name: Prepare the site for GitHub Pages + run: | + cd apps/geolibre-desktop/dist + # Pages serves this branch through its legacy (Jekyll) pipeline, which + # silently drops files and directories beginning with an underscore. + touch .nojekyll + # Keeps the custom domain if the Pages settings are ever reset. + echo "studio.geolibre.app" > CNAME + # Published Pages sites may be no larger than 1 GB. The build is + # ~200 MB today, most of it the two DuckDB-WASM binaries; fail early + # rather than publish a site Pages will reject. + size=$(du -sm . | cut -f1) + echo "Built site: ${size} MB" + if [ "$size" -gt 900 ]; then + echo "::error::Site is ${size} MB, at the 1 GB GitHub Pages limit." + exit 1 + fi + + - name: Publish to opengeos/studio.geolibre.app + env: + # Fine-grained PAT with Contents: write on opengeos/studio.geolibre.app. + STUDIO_DEPLOY_TOKEN: ${{ secrets.STUDIO_DEPLOY_TOKEN }} + run: | + cd apps/geolibre-desktop/dist + git init -q -b gh-pages + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -q -m "Deploy ${GITHUB_SHA} from ${GITHUB_REPOSITORY}" + # A fresh orphan commit force-pushed each time, so the hosting + # repository stays the size of one build rather than accumulating a + # ~200 MB commit per merge to main. + git push -q --force \ + "https://x-access-token:${STUDIO_DEPLOY_TOKEN}@github.com/opengeos/studio.geolibre.app.git" \ + gh-pages + echo "Published ${GITHUB_SHA} to https://studio.geolibre.app" diff --git a/workers/ai-proxy/wrangler.jsonc b/workers/ai-proxy/wrangler.jsonc index 3e62d7fed..727a9ed44 100644 --- a/workers/ai-proxy/wrangler.jsonc +++ b/workers/ai-proxy/wrangler.jsonc @@ -18,7 +18,7 @@ ] }, "vars": { - "ALLOWED_ORIGINS": "https://geolibre.app,https://web.geolibre.app,https://viewer.geolibre.app,http://localhost:5173,http://localhost:1420,tauri://localhost,http://tauri.localhost", + "ALLOWED_ORIGINS": "https://geolibre.app,https://web.geolibre.app,https://viewer.geolibre.app,https://studio.geolibre.app,http://localhost:5173,http://localhost:1420,tauri://localhost,http://tauri.localhost", "ALLOWED_MODELS": "openai/gpt-5.5,anthropic/claude-opus-5,anthropic/claude-sonnet-5,google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite", "DEFAULT_MODEL": "openai/gpt-5.5", "AI_GATEWAY_ID": "default", From 44976a1d9213a4bb6ea0f669c8a92a1e92475cec Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 22:12:13 -0400 Subject: [PATCH 2/4] ci: make the studio waitlist opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance is invite-only (Clerk sign-up mode "Restricted") by default, where a waitlist form would collect requests nobody can approve. Drive it from the repository variable GEOLIBRE_CLERK_WAITLIST instead of hardcoding it on: unset expands to "", which the app already reads as unset, so the waitlist can be turned on later from Settings → Variables without a code change. --- .github/workflows/studio-deploy.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/studio-deploy.yml b/.github/workflows/studio-deploy.yml index 803e8db1e..138e04588 100644 --- a/.github/workflows/studio-deploy.yml +++ b/.github/workflows/studio-deploy.yml @@ -64,9 +64,14 @@ jobs: # Unset means no gate, so a missing secret publishes an open app — # the guard step below fails the run instead. VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY }} - # Clerk's waitlist form, for an instance whose sign-up mode is - # Waitlist. Drop this for an invite-only (Restricted) instance. - VITE_GEOLIBRE_CLERK_WAITLIST: "1" + # Clerk's waitlist form. Off unless the repository variable + # GEOLIBRE_CLERK_WAITLIST is set to 1, because the instance is + # invite-only (Clerk sign-up mode "Restricted") by default and a + # waitlist form there would collect requests nobody can approve. An + # unset variable expands to "", which the app reads as unset. Flip it + # in Settings → Variables — no code change, and set the Clerk + # instance's sign-up mode to Waitlist to match. + VITE_GEOLIBRE_CLERK_WAITLIST: ${{ vars.GEOLIBRE_CLERK_WAITLIST }} # The AI assistant and the NASA OPERA disaster-news lookup. Pages # cannot proxy, so these are cross-origin calls to the Worker and # https://studio.geolibre.app must be in its ALLOWED_ORIGINS. From e4922062264900b6ffc9e0d030042bc464cf665e Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 22:15:56 -0400 Subject: [PATCH 3/4] Address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label the deployment with the commit that was actually built. On workflow_dispatch, $GITHUB_SHA is the SHA of the ref chosen in the "Run workflow" dropdown, not the `ref` input, so a manual rollback pushed a gh-pages commit citing main's tip while serving older content — misleading for anyone auditing what is live. Capture `git rev-parse HEAD` after checkout (the input may be a branch or tag name, not a SHA) and use it in the commit message and the summary line. - Set persist-credentials: false on the checkout (zizmor "artipacked"). No step uses the checkout's token: npm ci fetches from the registry and the publish step carries its own credentials. --- .github/workflows/studio-deploy.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/studio-deploy.yml b/.github/workflows/studio-deploy.yml index 138e04588..14ec32b8a 100644 --- a/.github/workflows/studio-deploy.yml +++ b/.github/workflows/studio-deploy.yml @@ -41,6 +41,19 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} + # Nothing here uses the checkout's token — npm ci fetches from the + # registry and the publish step carries its own credentials — so do + # not leave it behind in .git/config. + persist-credentials: false + + - name: Record the commit that was built + id: built + # $GITHUB_SHA is the SHA of the ref chosen in the "Run workflow" + # dropdown, not the `ref` input typed into the form, so on a manual + # rollback it names main's tip while the build is of something older. + # Resolve what was actually checked out — `ref` may also be a branch or + # tag name rather than a SHA — and label the deployment with that. + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Set up Node.js uses: actions/setup-node@v7 @@ -130,17 +143,18 @@ jobs: env: # Fine-grained PAT with Contents: write on opengeos/studio.geolibre.app. STUDIO_DEPLOY_TOKEN: ${{ secrets.STUDIO_DEPLOY_TOKEN }} + BUILT_SHA: ${{ steps.built.outputs.sha }} run: | cd apps/geolibre-desktop/dist git init -q -b gh-pages git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A - git commit -q -m "Deploy ${GITHUB_SHA} from ${GITHUB_REPOSITORY}" + git commit -q -m "Deploy ${BUILT_SHA} from ${GITHUB_REPOSITORY}" # A fresh orphan commit force-pushed each time, so the hosting # repository stays the size of one build rather than accumulating a # ~200 MB commit per merge to main. git push -q --force \ "https://x-access-token:${STUDIO_DEPLOY_TOKEN}@github.com/opengeos/studio.geolibre.app.git" \ gh-pages - echo "Published ${GITHUB_SHA} to https://studio.geolibre.app" + echo "Published ${BUILT_SHA} to https://studio.geolibre.app" From f54b71f209bc8c858909e1c5797fd697a094f96e Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 22:22:18 -0400 Subject: [PATCH 4/4] Address Claude review feedback - Regenerate workers/ai-proxy/worker-configuration.d.ts so its ALLOWED_ORIGINS literal matches wrangler.jsonc again. - Declare TAVILY_API_KEY in wrangler.jsonc's secrets.required, without which the regeneration above is not reproducible: `wrangler types` derives the secret bindings from that list, so running it on a clean checkout dropped TAVILY_API_KEY and broke the worker's own typecheck (src/index.ts:254 reads env.TAVILY_API_KEY). The binding reached the committed types when #1838 landed but the config entry did not, so the generated file could not be reproduced from the config it mirrors. --- workers/ai-proxy/worker-configuration.d.ts | 4 ++-- workers/ai-proxy/wrangler.jsonc | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/workers/ai-proxy/worker-configuration.d.ts b/workers/ai-proxy/worker-configuration.d.ts index d987ca9b6..407a0b339 100644 --- a/workers/ai-proxy/worker-configuration.d.ts +++ b/workers/ai-proxy/worker-configuration.d.ts @@ -1,9 +1,9 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: d305911522b75e606a41762aa46b28e3) +// Generated by Wrangler by running `wrangler types` (hash: c930d16aa86422cf711cded8b5fc487a) // Runtime types generated with workerd@1.20260730.1 2026-07-26 nodejs_compat interface __BaseEnv_Env { AI_RATE_LIMITER: RateLimit; - ALLOWED_ORIGINS: "https://geolibre.app,https://web.geolibre.app,https://viewer.geolibre.app,http://localhost:5173,http://localhost:1420,tauri://localhost,http://tauri.localhost"; + ALLOWED_ORIGINS: "https://geolibre.app,https://web.geolibre.app,https://viewer.geolibre.app,https://studio.geolibre.app,http://localhost:5173,http://localhost:1420,tauri://localhost,http://tauri.localhost"; ALLOWED_MODELS: "openai/gpt-5.5,anthropic/claude-opus-5,anthropic/claude-sonnet-5,google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite"; DEFAULT_MODEL: "openai/gpt-5.5"; AI_GATEWAY_ID: "default"; diff --git a/workers/ai-proxy/wrangler.jsonc b/workers/ai-proxy/wrangler.jsonc index 727a9ed44..1263fe856 100644 --- a/workers/ai-proxy/wrangler.jsonc +++ b/workers/ai-proxy/wrangler.jsonc @@ -14,7 +14,8 @@ "required": [ "CF_AI_GATEWAY_TOKEN", "CLOUDFLARE_ACCOUNT_ID", - "GEOLIBRE_AI_PROXY_TOKEN" + "GEOLIBRE_AI_PROXY_TOKEN", + "TAVILY_API_KEY" ] }, "vars": {