Browser extension UI for the Universal Digital Assistant Network. The core logic comes from the published SDK, @udan/digital-assistant-sdk (source: https://github.com/Digital-Assistant/Digital-Assistant-SDK), so no local SDK checkout is needed.
The SDK needs realm and clientId for the customer; since SDK 0.13.0 clientSecret is optional and deprecated. There are no defaults: without realm and clientId the SDK sends no request and logs which keys are missing. Host page setup (script tags, UDAAuthDataConfig, UDAPluginSDK, the UDASdkReady rule) is in the SDK's USAGE.md, with a runnable sample/index.html.
npm install
npm run build # default build -> build/
npm run build:dev # development
npm run build:prod # production -> dist/ (local build folder, not committed)
npm run build:qa # QA -> dist/
npm start runs webpack in watch mode.
The build reads public settings from environments/<build>.env: local.env for npm run build, development.env, production.env for npm run build:prod, qa.env. Copy environments/env.example (the schema, tracked) to that name and fill in the values; the copies are gitignored. Only the keys in env.example are inlined (webpack DefinePlugin, rules in scripts/build-env.js); an environment variable with the key's name overrides the file.
| Keys | What |
|---|---|
baseProdURL, baseTestURL |
Backend REST base, including /api |
tokenProdUrl, tokenTestUrl |
Token service |
llmProdUrl, llmTestUrl |
LLM search |
keycloakProdUrl, keycloakTestUrl |
Keycloak base URL |
keycloakRealm, keycloakClientId |
Keycloak realm, and the client id the SDK sends to the token service (Chrome identity sign-in) |
keycloakPublicClientId |
Public Keycloak client of the worker's PKCE sign-in (standard flow + PKCE S256, no secret, e.g. udan-extension). Empty = no Keycloak sign-in, only the Chrome identity flow |
keycloakIdpHint |
Optional identity provider alias sent as kc_idp_hint (the Google IdP on TEST/PROD). Empty = Keycloak's own sign-in page |
googleAnalyticsMeasurementId, enableGoogleAnalytics |
Google Analytics |
Each URL has a PROD and a TEST value, so one build serves both; the environment is chosen at runtime (next section). The values are exposed as src/config/buildConfig.ts and handed to the SDK at start-up. The two Google Analytics keys are carried there but nothing reads them yet.
No secrets. Everything in these files ends up in the extension, which anyone can unzip. scripts/ci/check-env-keys.sh fails the release build if a secret key (keycloakClientSecret, profanityKey, googleTranslateApiKey, googleAnalyticsSecretKey, clientSecret) has a value or is referenced in src/.
Older env files: rename keyCloakUrl to keycloakProdUrl / keycloakTestUrl, split tokenUrl and llmUrl into the Prod/Test pairs, add keycloakPublicClientId and keycloakIdpHint (empty is allowed; check-env-keys.sh needs every schema key present), and delete the secret keys. baseURL, profanity* and googleTranslate* are no longer read.
One bundle serves both backends. The extension decides when it starts, first match wins:
| Order | Source | Notes |
|---|---|---|
| 1 | The host page: window.UDAPluginSDK({ environment: 'TEST' }) |
Only for pages that talk to the extension; call it after UDASdkReady (below). Ignored when the build is stamped TEST and the page asks for PROD |
| 2 | The options page override (chrome.storage.local, key udanEnvironment) |
Extension options: Automatic, TEST or PROD; a change applies within seconds, no reload of the extension. A stored PROD is ignored when the build is stamped TEST |
| 3 | The tier stamp: tier.json at the root of the package, exactly {"tier":"TEST"} or {"tier":"PROD"} |
Written by the store packaging (the TEST and the PROD store zip come from the same bundle and differ only by this file, the manifest name and a build-info line). Read by the worker with a fetch of its own package file (src/config/tierStamp.ts). Missing, unreadable or invalid (bad JSON, extra keys, other case or value) = no stamp |
| 4 | Install type: an unpacked install (chrome.management.getSelf().installType === 'development') is TEST, any other install is PROD |
Needs no extra permission; background service worker only |
| 5 | Default: PROD | Same as the SDK |
Tier stamp. The stamp is the default tier of a store install, so the TEST store item ("UDA TEST") and the PROD item ("Universal Digital Assistant by Nistapp (UDAN)") run the same code and differ only in this default. A stamp TEST locks the extension to TEST: the options page hides the PROD choice, and a PROD request from the options override or from a host page is ignored (the worker and the page side both check). A stamp PROD accepts the overrides as before. Without a stamp nothing changes: an unpacked development build (the test-current zip has no stamp) is TEST, anything else is PROD. A stamped zip that is loaded unpacked follows its stamp (a PROD-stamped zip gives PROD), so the file you unpack declares its tier. The stamp holds no secret and no URL; the URLs still come from the build settings. The source reported by the options page and by UDASdkReady is stamp when the stamp decided.
The URLs for the chosen environment come, per key, from the page (apiBaseUrl, tokenUrl, llmUrl, keycloakUrl), then from the build settings above, then from the SDK's own defaults. A key the page set is never overwritten. When the environment (or a backend, realm or client setting) changes while the panel is open, the panel drops the stored session of the old backend, asks the worker for the session again and runs the search again; no page reload is needed. This is AuthContext listening to UDAConfigUpdated. The Client never calls authManager.init (sign-in runs in the worker), so there is nothing else to re-run.
- TEST badge. When the extension runs against TEST, the panel header and the login screen show a
TESTbadge and the toolbar icon showsTEST. No badge means PROD. UDASdkReady. After the extension has applied the environment it fires awindoweventUDASdkReadywithdetail: { environment: 'TEST' | 'PROD', source: 'page' | 'override' | 'stamp' | 'install-type' | 'default' }and setswindow.UDASdkIsReady = true(for pages that attach late). A host page must callUDAAuthDataConfigandUDAPluginSDKonly after it: earlier calls reach a copy of the SDK that is replaced later and are lost. Guide: SDKUSAGE.md.- Manifest. The runtime choice added
action(toolbar badge) andoptions_ui; no new permission. Thekeyfield pins the extension id (next section). - Rule of thumb for testers. Unpacked build from
test-current= TEST (no stamp). A build installed from the Chrome Web Store follows its stamp: the TEST item is TEST and cannot be switched to PROD, the PROD item is PROD.
- Get a zip. Testers: the newest TEST build is on the
test-currentprerelease (every build also has its owntest-v<version>prerelease). Developers: theclient-zipartifact of a CI run, orscripts/ci/package-extension.shlocally (out/udan-extension-<version>.zip). Unzip it into a new folder. A plainnpm run build:prodalso works: usedist/. - Open
chrome://extensions, turn on Developer mode, click Load unpacked and pick the folder. - The card shows the build identity, for example
1.1.0 (abc1234) run 173.2 cfg 8b97165d(theversion_name; it is also the first line of the release page); Details shows the full1.1.0.<run>. Same version but a different card text means a different build: reinstall.
Unpacked extensions do not update themselves: remove the old one and load the new folder.
npm install
cp environments/env.example environments/production.env # fill in public values; see "Environment settings"
npm run build:prod # -> dist/
Then load dist/ unpacked as above, or run scripts/ci/package-extension.sh to get the same zip CI makes. A plain npm run build writes to build/ and npm start rebuilds on change; reload the extension on chrome://extensions after each rebuild. npm test runs a small unit suite for the runtime-environment rules and the tier stamp (tests/); the old Jest tests tested code that now lives in the SDK, which has its own. The other checks are the CI build, the type check and the sanity check below. build:prod and build:qa drop console.* calls (terser drop_console); build and build:dev keep them.
- Two sign-in paths, both in the background worker. The panel's Sign-in button sends the page event
RequestUDASessionDatawithUDASignIn.- Chrome identity (
src/browser-auth/LoginWithBrowser.ts,chrome.identity.getProfileUserInfo): used when the browser has a signed-in profile and the build carries the token service'sclientSecret(only a local test build that adds it; no shipped build has one). Unchanged otherwise; it stays until the old token-service path is retired (track phase PK15). In a build without the secret the worker never starts it, on the Sign-in button or on page load (it could only end in a refusedPOST /user/token), so a signed-in Chrome profile signs in through Keycloak. - Keycloak, authorization code + PKCE (
src/browser-auth/LoginWithKeycloak.ts): used when the Chrome identity flow is not (no Chrome identity, no client secret in the build, or the worker getsUDASignInKeycloak, the user's explicit choice), and only if the build haskeycloakPublicClientId. It runschrome.identity.launchWebAuthFlowagainst the public client: S256 challenge from a random verifier,stateandnoncechecked, scopeopenid offline_access,kc_idp_hintfromkeycloakIdpHint, orgooglewhen the build has none (users sign in with Google and have no Keycloak password, so the window goes straight to Google; a realm without that alias ignores the hint and shows its own page),login_hintfrom the Chrome email when there is one. It tries silently first (interactive: false,prompt=none) and opens a window only because the user pressed Sign in. The code is exchanged at Keycloak's/tokenwith no client secret. The worker then callscheckauthidandcheckusersessionwith the access token; the Server takes the user from the token (sub, authsourcekeycloak) and the session'sauthData.idis the numeric user row id the Server uses as owner key, so owner buttons work. - Without a public client in the build, or with neither path possible, the worker answers "login" and the login screen stays.
- Chrome identity (
- Sign-in screen and messages. A signed-out panel shows "Sign in with Google" (
LoginScreen); the button is disabled and reads "Signing in…" until the worker answers (the page's own immediateloginanswer to the press does not count). The worker reports a problem with aUDAAlertMessageDatacode, andAuthContextshows the plain-words text above the button (role="alert"):signin-no-identity(no Chrome profile is signed in and the build has no other sign-in),signin-cancelled(the sign-in window was closed),signin-network(Keycloak or the Server did not answer),signin-failed(anything else, also sent by the content script when the worker's answer is a failure or it cannot answer),signin-reload(the content script cannot reach the worker because the extension was reloaded or updated; reload the page),session-expired(Keycloak refused the refresh token while the panel was renewing; the worker signs out and the panel returns to the Sign-in button). The codeloginonly means "not signed in" and shows no message. Logout is in the panel's Settings menu ("Sign Out"). - Where the Keycloak tokens live. Only in the worker:
chrome.storage.local, keyudanKeycloakTokens(access, refresh/offline and id token, expiry, subject). The refresh token never leaves the worker. The worker refreshes a minute before the access token expires, and on demand after a worker restart (it reads storage, it keeps nothing in memory). A refused refresh (invalid_grant: revoked, idle too long) signs the user out. - Panel token. The panel runs in the host page and calls the Server itself, so it gets the short-lived access token only, in the session the worker sends (
UDAAuthenticatedUserSessionData).AuthContexthands it to the SDK's host-token API (setAuthTokenwith its expiry) and registers a renewer: before expiry the SDK firesUDARequestAccessToken, the content script asks the worker (UDARenewAccessToken) and answers withUDAAccessTokenData({token, expiresAt}or null). A401still firesUDAGetNewToken; for a Keycloak session the worker refreshes and sends the session again. - Sign-out (
UDASignOut, sent by the panel's Logout): the worker posts the refresh token to Keycloak's logout endpoint, which ends the offline session and the browser session it came from, then forgets the tokens and the stored session. - No Keycloak
check-ssoon page load (removed on purpose; the original extension never had it): opening a page makes zero Keycloak requests for a signed-out user. Do not add one back. A signed-in Keycloak user's page load may refresh the access token once it is about to expire. - Extension id and redirect URI.
public/manifest.jsoncarries a publickey, which pins the dev/TEST extension id tolcnkfnijlogkgifbfhkcnmndhabnlocofor every unpacked build, so the redirect URIhttps://lcnkfnijlogkgifbfhkcnmndhabnloco.chromiumapp.org/is the same on every machine. Keycloak'sudan-extensionclient must listhttps://<id>.chromiumapp.org/*for this id and, later, the store id. The private key never goes into git (owner decision Q6). A Chrome Web Store upload must not carrykey, because the store item keeps its own id: the store zips (README "Store zips") are cut without it andcheck-store-zip.shrefuses one that has it. - Firefox is not supported (owner decision Q4):
manifest-firefox.jsonhas no gecko id and nokey, and Firefox has nogetProfileUserInfo. The PKCE module uses onlyidentity.launchWebAuthFlow/identity.getRedirectURL, which Firefox also has, so adding it later needs a gecko id and a Firefox redirect URI in Keycloak. - Config reaches the worker only through the build. The worker (
Background.ts) readssrc/config/buildConfig.ts, which comes from the env settings above. A host page callingUDAPluginSDK({...})only changes the page world, not the worker, so realm, URLs and the Keycloak client cannot be set from a site. The host-page copy of the SDK (src/Headers.js) carries its own SDK build. - Backend CORS. The extension calls the backend from a
chrome-extension://origin, and customer sites from their own origins. The backend must allow those origins (with credentials) and the request headersContent-Type,Authorization,X-Requested-With,x-sdk-clientandudan-realm; otherwise the browser reports a CORS failure and the sanity check prints "TEST backend CORS". See the Server README, "CORS". The worker's Keycloak calls (/token,/logout) needed no web origin on the client in the local proof: the extension's host access (content-script matches*://*/*) covers them.
The CI sanity check is unauthenticated. To exercise the whole flow against a local backend (no secrets in the repo, no committed flags):
- Start the backend (Server
README, "Run locally") and note its/apiURL and the realm/client id the SDK must use. - Build the SDK you want to test (
npm packin the SDK repo, see itsRELEASE_PROCESS.md) and install the tarball without touchingpackage.json:npm install --no-save <sdk>.tgz. A laternpm cireinstalls the pinned npm version, so install the tarball again after it. Check withnpm ls @udan/digital-assistant-sdk. npm run build:devwith a local env file, then loadbuild/unpacked (chrome://extensions).- For automation use Playwright Chromium with the extension loaded in a persistent profile (
scripts/ci/smoke-extension.mjsis the pattern) and stubchrome.identity.getProfileUserInfoin the service worker, so the Google sign-in needs no real account. For the Keycloak sign-in stubchrome.identity.launchWebAuthFlowinstead (the runner'skeycloak-pkcedriver, next section). - Test the Keycloak sign-in (no secret anywhere): put
keycloakPublicClientId=udan-extension(and an emptykeycloakIdpHint=: the worker then sendsgoogle, which a local realm without a Google IdP ignores) in your gitignoredenvironments/local.env; register the redirect URI of the pinned id,https://lcnkfnijlogkgifbfhkcnmndhabnloco.chromiumapp.org/*, on the localudan-extensionclient (the stack owner's Keycloak script does it); build; loadbuild/unpacked. The panel shows "Sign in with Google"; the button opens Keycloak's page (or Google, where the realm has that IdP) in a window, with or without a Chrome profile signed in.npm run e2e:behaviour -- --auth keycloak-pkcedoes the same unattended (next sections). The old Chrome identity path (kept until PK15) still needsclientSecretfor the token service in an untracked local build; never share or upload such a build.
Behaviour checklist (all 14 pass on dev with the current SDK against a local backend). npm run e2e:behaviour runs it, see the next section.
| # | Check |
|---|---|
| 1 | Extension loads, worker starts |
| 2 | Content script injects, panel mounts (#udan-react-root, shadow root) |
| 3 | Panel opens, login screen shown |
| 4 | No console errors on load |
| 5 | Backend reached with the build's own defaults |
| 6 | Early host-page UDAPluginSDK({environment, realm}) honoured (page world; the worker takes the build config) |
| 7 | Zero Keycloak requests on page load |
| 8 | Login: getsessionkey 200, checkauthid 200, token service POST /user/token 201, checkusersession 200, with the UDAN-Realm header (Keycloak sign-in: Keycloak POST /token 200 instead of /user/token, which must not be called; checkauthid/checkusersession carry the bearer token) |
| 9 | Search list GET /api/search/all 200; a text filter shows a saved recording |
| 10 | Record two clicks; steps listed |
| 11 | Save: clickednode x2 200, recordsequencedata 200 |
| 12 | Saved recording listed and searchable |
| 13 | Playback navigates the steps, userclick 204, ends on "Playback completed" |
| 14 | No failed backend calls in the whole run |
Sign-in UI checks (extras after the 14, run by the keycloak-pkce driver; chrome-identity runs the logout one). All must pass too:
| Extra | Check |
|---|---|
| No identity | With no Chrome identity the "Sign in with Google" button is shown |
| Busy | The button stays disabled ("Signing in…") while the worker signs in |
| Cancel | Closing the sign-in window shows "Sign-in was cancelled" and keeps the button; no tokens are stored |
| Worker error | A sign-in that fails inside the worker (a storage read refused) shows "Sign-in did not work" and keeps the button |
The sign-in window carries kc_idp_hint=google (build without keycloakIdpHint) and the Chrome profile's email as login_hint |
|
| Refresh | An access token that expired 6 minutes ago (idle longer than its 5 minutes) is renewed with the stored refresh token and the panel gets the new one |
| Logout | Settings > Sign Out shows the Sign-in button; Keycloak logout 2xx, tokens and session gone, the old refresh token is refused |
| Session expired | A refused refresh token shows "Your session has expired" and the Sign-in button |
| Owner | The saved recording's owner equals the panel's user id |
scripts/e2e/behaviour-check.mjs runs the 14 checks above without a person: Chromium (pinned playwright-core, new headless mode) loads an unpacked build, opens scripts/ci/fixtures/host-page.html from a local server, signs in, records the two buttons, saves, searches and plays the recording.
npm run build # local.env -> build/ (the backend URLs of your local stack)
npm run e2e:behaviour -- --build <dir> --backend http://localhost:<port>/api [--auth chrome-identity|keycloak-pkce] [--identity <label>] [--keycloak-user <name>] [--out <dir>] [--headed]
- The build must be able to sign in.
chrome-identityneeds the token service'sclientSecret, which this repo never holds, so a plainbuild/stops at check 5 ("worker SDK not configured"): pass a copy that your own local tooling completed, keep it out of the repo and delete it after the run.keycloak-pkceneeds no secret, only a build withkeycloakPublicClientId(check 5 fails if the worker holds a secret). The runner reads no secret and prints none. - Auth drivers (
--auth); in both the build is not patched, the runner replaces browser functions in the running worker:chrome-identity(default) stubschrome.identity.getProfileUserInfo. It answers "not signed in" while the page loads (check 3), then a labelled test identity (<label>@example.test, default labelpk-e2e-a) when the runner clicks "Sign in with Google".keycloak-pkcestubsgetProfileUserInfo(signed out for the cancel and worker-error checks, then a signed-in profile<label>@example.testfor the real sign-in, as on a tester's Chrome: the build has no secret, so the worker must still take the Keycloak path) andchrome.identity.launchWebAuthFlowwith a stub that parks the authorization URL. The runner opens it in a tab, signs in on Keycloak's own page as--keycloak-userwith the password from the environment variableUDAN_E2E_KEYCLOAK_PASSWORD(never printed), catches the redirect tohttps://<extension id>.chromiumapp.org/by route interception and hands it back. A silent request gets Keycloak's own answer (login_requiredon a fresh profile). Needs the client's redirect URI registered for the pinned id. Before check 8 it presses Sign in and closes the window (busy button, cancel message), then makes the worker's next storage read fail (worker-error message); after check 14 it adds the extras listed above (Google hints, owner, refresh after idle, logout through the Settings menu, session expired). Check 14 also fails if any call carries a client secret field.
- Data: each run saves a new recording named
pk e2e <label> <run id>on a fresh host-page port (the SDK scopes recordings by host and port), so reruns stay green. The data stays in the local backend. - Output in
--out(defaultout/behaviour/<run id>/, gitignored):behaviour-check.json(checks plus every backend, token-service and Keycloak call: method, path, status,UDAN-Realmyes/no) andbehaviour-check.md(check, result, evidence), plusfailure.pngwhen a check fails. Hosts are shown as<backend>,<token service>,<keycloak>; tokens and bodies are never recorded. - Exit codes: 0 all 14 pass, 1 a check failed, 2 usage or setup error, 3 backend unreachable (checked before the browser starts).
- Not in CI: it needs the Server, the token service and Keycloak. The first run on a machine needs
npx playwright-core install chromium.
For the store listing and the privacy notice (the owner submits; draft text in docs/store-listing-draft.md).
- In the browser,
chrome.storage.local(the extension's own storage, not readable by websites): with the Keycloak sign-in, an access token, a long-lived refresh token (offline) and an ID token from Keycloak, their expiry, the Keycloak user id, and the email and name Keycloak got from Google. With the older Chrome identity sign-in: a session key and a SHA-512 hash of the Chrome profile id and email. Sign Out ends the Keycloak session and deletes the tokens. - In the web page the panel runs in: the short-lived access token only (about 5 minutes, renewed through the extension) and the session data (user row id, hashed email). Any script on that page can ask the extension for a fresh access token (
UDARequestAccessToken), as it could already read the session from the page'slocalStorage; the refresh token never leaves the extension's worker. - Sent to the UDAN backend: the access token on each call, the recordings the user saves, search terms, and the Keycloak user id. The email is sent as a hash.
- Tier stamp: the package file
tier.json(TESTorPROD) is read from the extension's own package; it stores nothing and sends nothing. The TEST store item is locked to the TEST backend by it. - Not stored or sent: the Google password (Google or Keycloak's own page handles it), browsing history outside the pages where the user records.
Branch from dev and open a PR into dev; the PR title must be a Conventional Commit (feat: ..., fix: ...) because it becomes the squash commit and drives the changelog.
Build once. Every CI run builds the extension once and packs it into one zip, the extension's equivalent of an image digest. Testers and later the store get exactly that zip, never a rebuild. Scripts and details: scripts/ci/README.md.
- Zip:
udan-extension-<X.Y.Z.N>.zip, plusSHA256SUMSandbuild-info.json(commit, branch, run, attempt, config hash, SDK version and integrity, names of the env keys that were set). Same commit, run number, attempt and build configuration give the same bytes. - Version: CI stamps the built manifest with
version = X.Y.Z.<run number>andversion_name = "X.Y.Z (<short commit>) run <run number>.<attempt> cfg <config hash>". X.Y.Z comes frompackage.json(Release Please owns it); the run number makes each build higher than the last.version_nameis the build identity: a re-run of the same CI run (new attempt, e.g. after fixing an Actions variable) or any changed build setting changes it, whileversionstays. The config hash is 8 hex of a sha256 over the build settings the bundle inlines (node scripts/build-env.js production --hash); the values are not recoverable from it. The source manifests only hold the base version, for local builds. - Store zips: the same run also writes the two Chrome Web Store zips,
udan-extension-<X.Y.Z.N>-store-test.zip(for the unlisted TEST item "UDA TEST") andudan-extension-<X.Y.Z.N>-store-prod.zip(for the PROD item, "Universal Digital Assistant by Nistapp (UDAN)"), each with its line inSHA256SUMS. See "Store zips" below. - Retention: artifact
client-zip, 90 days for pushes todev, 14 days for PRs. The run summary shows the version, SHA256 and SDK version. - Build settings in CI come from GitHub Actions variables (
BASE_PROD_URL,TOKEN_TEST_URL, ...,KEYCLOAK_PUBLIC_CLIENT_ID,KEYCLOAK_IDP_HINT:scripts/ci/generate-env.sh --print-names), never from secrets. They are set once at the organization level and shared with the SDK repo, which uses the same names.
flowchart LR
PR["PR into dev<br/>Conventional Commit title"] --> CI{"CI Gate<br/>build and pack, typecheck, gitleaks,<br/>workflow lint, npm audit report-only"}
CI -- no --> PR
CI -- "yes: squash merge" --> DEV["dev"]
CI -. "PR runs: artifact client-zip, kept 14 days" .-> PZIP[("client-zip (PR)")]
DEV --> BUILD["CI on dev: one build<br/>udan-extension-X.Y.Z.run.zip<br/>SHA256SUMS, build-info.json"]
BUILD --> ZIP[("artifact client-zip<br/>kept 90 days")]
ZIP -- "Deploy to TEST: manual, dev only<br/>same zip, no rebuild" --> PRE["Prereleases<br/>test-vX.Y.Z.run and test-current"]
PRE --> SAN{"Sanity check TEST<br/>Chromium loads the same zip"}
SAN --> TESTERS["Testers: load unpacked,<br/>badge says TEST"]
SAN -- "red" --> RB["Rollback: Deploy to TEST<br/>with the previous version"]
RB --> PRE
ZIP -- "Release to Chrome Web Store: manual, dev only<br/>same store zips, STAGED_PUBLISH" --> STORE["Chrome Web Store<br/>TEST item, then PROD item"]
ZIP -. "later: C09 SDK CDN" .-> CDN["SDK CDN"]
DEV --> REL["PR dev into master, merge commit"]
REL --> RP["Release Please PR<br/>package.json, CHANGELOG"]
RP --> TAG["Tag vX.Y.Z and GitHub release"]
TAG --> BM["Back-merge PR to dev<br/>new X.Y.Z for the next dev builds"]
Release Please no longer touches the manifests: CI owns the extension version. Attaching the zip to production releases comes in a later step; the store upload is the workflow Release to Chrome Web Store (below). dist/ is no longer committed: it is only the local build:prod folder.
Store zips (Chrome Web Store). scripts/ci/package-extension.sh --store (CI always passes it) cuts two more zips from the same dist/ as the sideload zip: no second npm run build, one config_hash. Nothing is uploaded by this step; the zips are the input of Release to Chrome Web Store.
Sideload zip udan-extension-<v>.zip |
...-store-test.zip |
...-store-prod.zip |
|
|---|---|---|---|
| Used for | Deploy to TEST prereleases, unpacked testing |
the unlisted TEST store item | the PROD store item |
Manifest key |
kept (pins the dev/TEST id) | removed (the store fixes the item id) | removed |
Manifest name |
unchanged | UDA TEST |
unchanged (Universal Digital Assistant by Nistapp (UDAN) from public/manifest.json) |
tier.json |
none (unpacked = TEST, as before) | {"tier":"TEST"} |
{"tier":"PROD"} |
| Icons | logos/nist-logo-*.png |
TEST badge from store/test-icons/ |
unchanged |
The two store zips differ only in manifest.json (name), tier.json, one tier field in build-info.json and the four badged icons; every other file is byte-identical, so what testers run on the TEST item is what ships on the PROD item. The extension reads tier.json at run time (see "TEST or PROD at runtime"). A store zip must never carry a secret: scripts/ci/check-env-keys.sh runs first, and the build job then runs scripts/ci/check-store-zip.sh on the pair.
scripts/ci/check-store-zip.sh <store-test zip> <store-prod zip> # checks each zip and the pair
scripts/ci/check-store-zip.sh --require-keys keycloakPublicClientId,keycloakRealm <zip>
scripts/ci/test-store-packaging.sh # fixture test of both scripts, no build
check-store-zip.sh prints only pass/FAIL lines, booleans (which build keys were set, never values) and sha256 hashes, because the run logs of this repo are public. It fails when the name is wrong, the archive is damaged, a key is in the manifest, the manifest or build-info.json version differs from the name, tier.json is not exactly the one-key stamp of the zip's tier, the TEST name is not UDA TEST, a secret key name is in env_keys_set or is assigned a non-empty string anywhere in the zip (the published SDK has an empty clientSecret property, so that bare name alone is allowed), or, for the pair, any file other than the allowed ones differs. To change the TEST badge, replace the PNGs in store/test-icons/ (same file names and sizes as public/logos/).
Release to Chrome Web Store. Run the workflow Release to Chrome Web Store (release-store.yml) on dev with tier (test or prod), version (X.Y.Z.N) and dry_run (default true). A dry run checks the build and changes nothing. With dry_run=false it uploads that build's store zip to the item of the tier (test-store environment for the unlisted TEST item, production with two reviewers for the PROD item) and submits it with STAGED_PUBLISH; after Google's review the owner presses Publish in the dashboard. Release a version to TEST first: tier=prod is refused unless the TEST run of the same version uploaded the same bundle and the TEST item shows it published or staged; approving production is the smoke-pass sign-off. One pending submission per item: a second is refused unless replace_pending=true, which cancels the pending one (it loses its review queue position). The TEST store item is for release candidates (each upload is reviewed, days to weeks); per-commit checks stay on Deploy to TEST. Before cutting a release, ask what is pending: run the same workflow with status_only=true and the tier (no version, leave dry_run on). It is read-only (token exchange and fetchStatus, no upload, no submission, no cancel) and prints whether a new submission would be accepted (green) or refused because one is pending (red, exit code 3). It runs in test-store for both tiers, so it needs no approval, even for tier=prod. Guards, variables and the laptop command: scripts/ci/README.md "Store release".
Deploy to TEST. Testers get builds from GitHub prereleases, never from a rebuild. Run the workflow Deploy to TEST on dev (Actions tab, or gh workflow run deploy-test.yml --ref dev). With no input it takes the newest dev build; -f version=X.Y.Z.N (from a CI run summary) takes that one, which is also the rollback. It verifies SHA256SUMS, the version and that the commit is on dev, then publishes prerelease test-v<version> and moves test-current to it. Neither is ever marked latest. The notes say whether the build talks to the TEST backend and list known issues (scripts/ci/test-known-issues.md). The workflow refuses any other branch. A build is deployable while its artifact lives (90 days). After publishing, job Sanity check TEST loads that same zip into Chromium (Playwright) and checks that the extension starts, the panel mounts and the TEST backend answers; it is read-only and skips the backend checks for builds that call no backend. A failure turns the run red but does not unpublish; redeploy the previous version to roll back. Details and the laptop command: scripts/ci/README.md.
Roll back TEST. Run Deploy to TEST on dev with version set to the previous build (for example 1.1.0.11; the version is in the earlier prerelease's name and in each CI run summary). test-current moves back to that build, the sanity job runs again, and testers reinstall from test-current. It works while that build's CI artifact is still kept (90 days). In the release notes of a rollback the "changes since" link compares newer to older and reads as empty; ignore it.
Bumping the SDK. The Client pins @udan/digital-assistant-sdk to an exact version (package.json and lockfile). To try a new SDK: PR with the new pin into dev, wait for the build, run Deploy to TEST, test. Only after that passes does the SDK maintainer move npm latest (SDK RELEASE_PROCESS.md, "Promotion and rollback"). The SDK version of every build is in build-info.json and in the release notes.
Sanity check. The Sanity check TEST job runs scripts/ci/smoke-extension.sh (details in scripts/ci/README.md); you can run it on a laptop with the same zip.
webpack is pinned to 5.112.2 (exact, like the other build tools). 5.112.0 once failed on the SDK bundle; 5.112.2 builds it and passes the extension smoke check (retried 2026-10-28).