Skip to content

Commit aa37cd2

Browse files
committed
feat: finish algolia env split
- centralize public and publisher Algolia env parsing - disable search clearly when public env is missing - inject dev/prod Algolia envs in deploy CI - fix the pre-existing lazy wrapper compile blockers
1 parent dacc50b commit aa37cd2

13 files changed

Lines changed: 497 additions & 71 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,38 @@ jobs:
4646
echo "SAFE_BRANCH=$SAFE_BRANCH" >> "$GITHUB_ENV"
4747
echo "VITE_DEPLOYMENT_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV"
4848
fi
49+
- name: Set Algolia env
50+
shell: bash
51+
env:
52+
ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }}
53+
ALGOLIA_INDEX_BASENAME: ${{ vars.ALGOLIA_INDEX_BASENAME }}
54+
ALGOLIA_SEARCH_API_KEY_DEV: ${{ vars.ALGOLIA_SEARCH_API_KEY_DEV }}
55+
ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }}
56+
ALGOLIA_ADMIN_API_KEY_DEV: ${{ secrets.ALGOLIA_ADMIN_API_KEY_DEV }}
57+
ALGOLIA_ADMIN_API_KEY_PROD: ${{ secrets.ALGOLIA_ADMIN_API_KEY_PROD }}
58+
run: |
59+
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then
60+
INDEX_PREFIX="prod"
61+
SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD"
62+
ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD"
63+
elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then
64+
INDEX_PREFIX="prod"
65+
SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD"
66+
ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD"
67+
else
68+
INDEX_PREFIX="dev"
69+
SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV"
70+
ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_DEV"
71+
fi
72+
73+
INDEX_NAME="${INDEX_PREFIX}_${ALGOLIA_INDEX_BASENAME}"
74+
75+
echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV"
76+
echo "VITE_ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV"
77+
echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV"
78+
echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV"
79+
echo "ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV"
80+
echo "ALGOLIA_ADMIN_API_KEY=$ADMIN_KEY" >> "$GITHUB_ENV"
4981
- name: Build
5082
run: yarn build
5183
env:

__tests__/AlgoliaConfig_.test.res

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
open Vitest
2+
3+
describe("publicConfigFrom", () => {
4+
test("returns config when all public vars are present", async () => {
5+
let result = AlgoliaConfig.publicConfigFrom(
6+
~appId=Some("app_123"),
7+
~indexName=Some("dev_rescript_lang"),
8+
~searchApiKey=Some("search_123"),
9+
)
10+
11+
let expected: AlgoliaConfig.publicConfig = {
12+
appId: "app_123",
13+
indexName: "dev_rescript_lang",
14+
searchApiKey: "search_123",
15+
}
16+
17+
expect(result)->toEqual(Some(expected))
18+
})
19+
20+
test("reports missing public vars in declaration order", async () => {
21+
let result = AlgoliaConfig.missingPublicVars(
22+
~appId=None,
23+
~indexName=Some("dev_rescript_lang"),
24+
~searchApiKey=None,
25+
)
26+
27+
expect(result)->toEqual(["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"])
28+
})
29+
})
30+
31+
describe("publisherConfigFrom", () => {
32+
test("reports missing publisher vars in declaration order", async () => {
33+
let result = AlgoliaConfig.missingPublisherVars(
34+
~appId=Some("app_123"),
35+
~indexName=None,
36+
~adminApiKey=None,
37+
)
38+
39+
expect(result)->toEqual(["ALGOLIA_INDEX_NAME", "ALGOLIA_ADMIN_API_KEY"])
40+
})
41+
})

__tests__/Search_.test.res

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,12 @@ describe("isChildHit", () => {
424424
)
425425
})
426426
})
427+
428+
test("renders disabled search copy when Algolia config is missing", async () => {
429+
await viewport(1440, 500)
430+
431+
let screen = await render(<Search />)
432+
433+
await element(await screen->getByText("Search unavailable"))->toBeVisible
434+
await element(await screen->getByLabelText("Search unavailable for this build"))->toBeVisible
435+
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Algolia Env Split Design
2+
3+
Date: 2026-04-25
4+
5+
## Summary
6+
7+
This change keeps the site on its current pre-rendered Cloudflare Pages setup and splits Algolia configuration into:
8+
9+
- public build-time variables for browser search
10+
- private publish-only variables for search index uploads
11+
12+
Fork PRs and any build without the public Algolia variables should still build successfully, but the UI must clearly show that search is unavailable. The search indexing script should continue to skip cleanly when its private publish variables are missing.
13+
14+
## Goals
15+
16+
- Keep the existing static React Router + Cloudflare Pages architecture.
17+
- Do not adopt `@react-router/cloudflare`.
18+
- Make browser search depend only on `VITE_` variables that are safe to bundle.
19+
- Keep Algolia admin credentials out of the browser bundle and out of fork PR builds.
20+
- Make preview and production index names deterministic via CI-computed `dev_` and `prod_` prefixes.
21+
- Make disabled search obvious in the UI and in local/build logs.
22+
23+
## Non-Goals
24+
25+
- Changing the site from prerendered Pages to a Cloudflare Workers runtime app.
26+
- Solving team-wide local secret distribution in this PR.
27+
- Enabling Algolia-backed search for fork preview builds.
28+
- Introducing GitHub Environments. This design uses repo-level variables and secrets only.
29+
30+
## Current Constraints
31+
32+
- The site is prerendered with `ssr: false` and uploaded to Cloudflare Pages as static output.
33+
- Frontend values must be present at build time because Vite bakes `VITE_*` values into the client bundle.
34+
- The deploy workflow builds in GitHub Actions before uploading to Cloudflare Pages, so Cloudflare dashboard vars are not the primary source for these build-time values.
35+
- Fork PR workflows must not receive Algolia secrets or public search variables.
36+
37+
## Environment Contract
38+
39+
### GitHub repo variables and secrets
40+
41+
Public build-time values:
42+
43+
- `ALGOLIA_APP_ID`
44+
- `ALGOLIA_INDEX_BASENAME`
45+
- `ALGOLIA_SEARCH_API_KEY_DEV`
46+
- `ALGOLIA_SEARCH_API_KEY_PROD`
47+
48+
Private publish-only secrets:
49+
50+
- `ALGOLIA_ADMIN_API_KEY_DEV`
51+
- `ALGOLIA_ADMIN_API_KEY_PROD`
52+
53+
### Computed values in CI
54+
55+
The workflow computes the full index name from the base name:
56+
57+
- pull request builds with Algolia config: `dev_${ALGOLIA_INDEX_BASENAME}`
58+
- preview / non-fork PR deploys: `dev_${ALGOLIA_INDEX_BASENAME}`
59+
- production deploys from `master`: `prod_${ALGOLIA_INDEX_BASENAME}`
60+
61+
### Variables exposed to the browser build
62+
63+
The build step exports:
64+
65+
- `VITE_ALGOLIA_APP_ID`
66+
- `VITE_ALGOLIA_INDEX_NAME`
67+
- `VITE_ALGOLIA_SEARCH_API_KEY`
68+
69+
These are the only Algolia values used by the frontend.
70+
71+
### Variables used by the indexing script
72+
73+
The indexing script receives:
74+
75+
- `ALGOLIA_APP_ID`
76+
- `ALGOLIA_INDEX_NAME`
77+
- `ALGOLIA_ADMIN_API_KEY`
78+
79+
These values remain private and are never read from `import.meta.env`.
80+
81+
## Workflow Behavior
82+
83+
### `deploy.yml`
84+
85+
- `pull_request` for non-fork branches uses the `DEV` public key and `DEV` admin key.
86+
- `pull_request` builds target the Algolia `dev_` index prefix, never the `prod_` index prefix.
87+
- `push` to `master` uses the `PROD` public key and `PROD` admin key.
88+
- `workflow_dispatch` keeps the existing `environment` input and maps it to either `DEV` or `PROD`.
89+
- The workflow computes the index name with the matching prefix before running the build.
90+
- The workflow exports both the public `VITE_*` variables and the private indexing variables for the build pipeline.
91+
92+
### `deploy-fork-preview.yml`
93+
94+
- Do not inject any Algolia variables or secrets.
95+
- The build must still succeed.
96+
- Search should render in its disabled state for these builds.
97+
- The indexing step should skip because its private variables are absent.
98+
- Even for pull requests, fork preview builds do not use the Algolia dev index because they receive no Algolia configuration.
99+
100+
### `pull-request.yml`
101+
102+
- Do not inject Algolia variables by default.
103+
- Validation should continue to pass with search disabled.
104+
- Only revisit this if a test later requires Algolia-backed search specifically.
105+
106+
## Application Behavior
107+
108+
### Frontend search availability
109+
110+
Search is enabled only when all of these values are present and non-empty:
111+
112+
- `VITE_ALGOLIA_APP_ID`
113+
- `VITE_ALGOLIA_INDEX_NAME`
114+
- `VITE_ALGOLIA_SEARCH_API_KEY`
115+
116+
If any one is missing, the app treats search as unavailable.
117+
118+
### Disabled UI
119+
120+
When search is unavailable:
121+
122+
- Do not mount the Algolia DocSearch modal.
123+
- Do not wire the search trigger to open the modal.
124+
- Render a visibly disabled search affordance instead of a normal active trigger.
125+
- Include explicit text such as `Search unavailable`.
126+
- Include accessible labeling that explains search is disabled for this build.
127+
128+
The result should be obvious to users rather than silently removing the feature or showing a broken interaction.
129+
130+
### Logging
131+
132+
When the public browser variables are incomplete:
133+
134+
- log a clear message during local development and build startup
135+
- include the missing variable names in the message
136+
137+
Example shape:
138+
139+
`Algolia search disabled: missing VITE_ALGOLIA_APP_ID, VITE_ALGOLIA_INDEX_NAME`
140+
141+
When the indexing variables are incomplete:
142+
143+
- keep the current graceful skip behavior
144+
- log which private variable is missing
145+
146+
## Local Development
147+
148+
- Remove tracked Algolia values from `.env`.
149+
- Developers can opt into local search with untracked local environment files such as `.env.local`.
150+
- If local Algolia values are absent, the site should still run normally with disabled search UI and a console warning.
151+
152+
This keeps local development usable without requiring every contributor to have Algolia credentials.
153+
154+
## Naming Decision
155+
156+
This design uses one Algolia application and separate indices for preview and production:
157+
158+
- `dev_<base>`
159+
- `prod_<base>`
160+
161+
Index naming alone does not create a special Algolia environment. It only scopes records into separate indices. This is sufficient for this PR and avoids introducing a second Algolia application.
162+
163+
## Verification Plan
164+
165+
- Confirm non-fork preview deploys receive `dev_` index names and the `DEV` keys.
166+
- Confirm production deploys receive `prod_` index names and the `PROD` keys.
167+
- Confirm fork preview builds complete without Algolia configuration.
168+
- Confirm the UI renders the disabled search state when public vars are absent.
169+
- Confirm the build/dev logs clearly state why search is disabled.
170+
- Confirm the indexing script skips cleanly when its private variables are absent.
171+
172+
## Open Questions
173+
174+
None for this design. Team distribution of local development variables is intentionally deferred.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@
1717
"build:search-index": "node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs",
1818
"build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index",
1919
"build:vite": "react-router build",
20-
"build": "yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite",
20+
"check:algolia-public-env": "node scripts/log_algolia_env_status.mjs",
21+
"build": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite",
2122
"ci:format": "prettier . --check --experimental-cli",
2223
"ci:test": "yarn vitest --run --browser.headless",
2324
"clean:res": "rescript clean",
24-
"convert-images": "auto-convert-images",
25+
"convert-images": "auto-image-converter",
2526
"dev:res": "rescript watch",
2627
"dev:vite": "react-router dev --host",
2728
"dev:wrangler": "yarn wrangler pages dev build/client",
2829
"dev": "yarn prepare && yarn dev:res & yarn dev:vite & yarn dev:wrangler",
2930
"format": "prettier . --write --experimental-cli && rescript format",
30-
"prepare": "yarn build:res && yarn build:scripts && yarn build:update-index",
31+
"prepare": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index",
3132
"preview": "yarn build && static-server build/client",
3233
"reanalyze": "rescript-tools reanalyze -all-cmt .",
3334
"test": "node scripts/test-examples.mjs && node scripts/test-hrefs.mjs",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import {
4+
formatDisabledMessage,
5+
getMissingPublicAlgoliaVars,
6+
} from "../log_algolia_env_status.mjs";
7+
8+
test("reports missing public vars in declaration order", () => {
9+
assert.deepEqual(
10+
getMissingPublicAlgoliaVars({
11+
VITE_ALGOLIA_APP_ID: "",
12+
VITE_ALGOLIA_INDEX_NAME: "dev_rescript_lang",
13+
VITE_ALGOLIA_SEARCH_API_KEY: undefined,
14+
}),
15+
["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"],
16+
);
17+
});
18+
19+
test("formats the disabled search warning", () => {
20+
assert.equal(
21+
formatDisabledMessage(["VITE_ALGOLIA_APP_ID"]),
22+
"Algolia search disabled: missing VITE_ALGOLIA_APP_ID",
23+
);
24+
});

scripts/generate_search_index.res

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
// Runs as a standalone Node script via: node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs
33
//
44
// Required env vars:
5+
// ALGOLIA_APP_ID -- Algolia application ID
56
// ALGOLIA_ADMIN_API_KEY -- API key with addObject/deleteObject/editSettings ACLs
67
// ALGOLIA_INDEX_NAME -- e.g. "rescript-lang-dev" or "rescript-lang"
78
//
8-
// If either is missing, the script logs a warning and exits 0 (graceful skip).
9+
// If any are missing, the script logs a warning and exits 0 (graceful skip).
910

1011
let getEnv = (key: string): option<string> =>
1112
Node.Process.env
@@ -74,9 +75,10 @@ let main = async () => {
7475
let appId = getEnv("ALGOLIA_APP_ID")
7576
let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY")
7677
let indexName = getEnv("ALGOLIA_INDEX_NAME")
78+
let publisherConfig = AlgoliaConfig.publisherConfigFrom(~appId, ~indexName, ~adminApiKey)
7779

78-
switch (appId, adminApiKey, indexName) {
79-
| (Some(appId), Some(apiKey), Some(idx)) => {
80+
switch publisherConfig {
81+
| Some({appId, indexName, adminApiKey}) => {
8082
Console.log("[search-index] Building search index records...")
8183

8284
let apiDir = resolveApiDir()->Option.getOr("markdown-pages/docs/api")
@@ -176,11 +178,11 @@ let main = async () => {
176178
let jsonRecords = allRecords->Array.map(SearchIndex.toJson)
177179

178180
// 4. Initialize Algolia client and upload
179-
let client = Algolia.make(appId, apiKey)
181+
let client = Algolia.make(appId, adminApiKey)
180182

181-
Console.log(`[search-index] Uploading to index "${idx}"...`)
183+
Console.log(`[search-index] Uploading to index "${indexName}"...`)
182184
let _ = await client->Algolia.replaceAllObjects({
183-
indexName: idx,
185+
indexName,
184186
objects: jsonRecords,
185187
batchSize: 1000,
186188
})
@@ -189,7 +191,7 @@ let main = async () => {
189191
// 5. Configure index settings
190192
Console.log("[search-index] Updating index settings...")
191193
let _ = await client->Algolia.setSettings({
192-
indexName: idx,
194+
indexName,
193195
indexSettings: {
194196
searchableAttributes: [
195197
"hierarchy.lvl0",
@@ -213,10 +215,10 @@ let main = async () => {
213215

214216
Console.log("[search-index] Done.")
215217
}
216-
| (None, _, _) => Console.log("[search-index] ALGOLIA_APP_ID not set, skipping index upload.")
217-
| (_, None, _) =>
218-
Console.log("[search-index] ALGOLIA_ADMIN_API_KEY not set, skipping index upload.")
219-
| (_, _, None) => Console.log("[search-index] ALGOLIA_INDEX_NAME not set, skipping index upload.")
218+
| None =>
219+
AlgoliaConfig.missingPublisherVars(~appId, ~indexName, ~adminApiKey)->Array.forEach(name => {
220+
Console.log(`[search-index] ${name} not set, skipping index upload.`)
221+
})
220222
}
221223
}
222224

0 commit comments

Comments
 (0)