Skip to content

Commit 82c2add

Browse files
authored
Merge pull request #3521 from trycompai/main
[comp] Production Deploy
2 parents 7b856b2 + fecb556 commit 82c2add

186 files changed

Lines changed: 16464 additions & 699 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ FIRECRAWL_API_KEY="" # For research, self-host or use cloud-version @ https://fi
3131
TRUST_APP_URL="http://localhost:3008" # Trust portal public site for NDA signing and access requests
3232

3333
AUTH_TRUSTED_ORIGINS=http://localhost:3000,https://*.trycomp.ai,http://localhost:3002
34+
COMP_EXTENSION_TRUSTED_ORIGINS=chrome-extension://<extension-id>
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
name: Security Questionnaire Extension Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
publish_target:
7+
description: 'Who receives this release'
8+
type: choice
9+
default: trustedTesters
10+
options:
11+
- trustedTesters
12+
- default
13+
push:
14+
branches:
15+
- release
16+
paths:
17+
- '.github/workflows/security-questionnaire-extension-release.yml'
18+
- 'apps/browser-extension/security-questionnaire-ext/**'
19+
20+
permissions:
21+
contents: write
22+
23+
concurrency:
24+
group: security-questionnaire-extension-release
25+
cancel-in-progress: false
26+
27+
env:
28+
EXTENSION_DIR: apps/browser-extension/security-questionnaire-ext
29+
EXTENSION_TAG_PREFIX: security-questionnaire-ext-v
30+
WXT_PUBLIC_API_BASE_URL: https://api.trycomp.ai
31+
WXT_PUBLIC_APP_BASE_URL: https://app.trycomp.ai
32+
33+
jobs:
34+
release:
35+
name: Publish Chrome extension
36+
if: ${{ github.ref == 'refs/heads/release' }}
37+
runs-on: warp-ubuntu-latest-arm64-4x
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v4
41+
with:
42+
fetch-depth: 0
43+
fetch-tags: true
44+
45+
- name: Setup Node.js
46+
uses: actions/setup-node@v4
47+
with:
48+
node-version: '22'
49+
50+
- name: Setup Bun
51+
uses: oven-sh/setup-bun@v2
52+
53+
- name: Install dependencies
54+
run: bun install --frozen-lockfile --ignore-scripts
55+
56+
- name: Compute next extension version
57+
id: version
58+
run: |
59+
LATEST_TAG=$(git tag -l "${EXTENSION_TAG_PREFIX}*" --sort=-v:refname | grep -E "^${EXTENSION_TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+$" | head -1)
60+
61+
if [ -z "$LATEST_TAG" ]; then
62+
NEXT_VERSION="0.1.0"
63+
else
64+
CURRENT_VERSION="${LATEST_TAG#${EXTENSION_TAG_PREFIX}}"
65+
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
66+
NEXT_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
67+
fi
68+
69+
TAG_NAME="${EXTENSION_TAG_PREFIX}${NEXT_VERSION}"
70+
echo "version=$NEXT_VERSION" >> "$GITHUB_OUTPUT"
71+
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
72+
echo "zip_name=security-questionnaire-extension-${NEXT_VERSION}.zip" >> "$GITHUB_OUTPUT"
73+
74+
echo "--- Extension version ---"
75+
echo "Latest tag: ${LATEST_TAG:-none}"
76+
echo "Next version: $NEXT_VERSION"
77+
echo "Tag name: $TAG_NAME"
78+
79+
- name: Set extension package version
80+
working-directory: ${{ env.EXTENSION_DIR }}
81+
env:
82+
VERSION: ${{ steps.version.outputs.version }}
83+
run: |
84+
node -e "
85+
const fs = require('fs');
86+
const path = 'package.json';
87+
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
88+
pkg.version = process.env.VERSION;
89+
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
90+
"
91+
92+
- name: Typecheck extension
93+
run: bun run --filter '@trycompai/security-questionnaire-extension' typecheck
94+
95+
- name: Test extension
96+
run: bun run --filter '@trycompai/security-questionnaire-extension' test
97+
98+
- name: Build production extension
99+
env:
100+
WXT_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }}
101+
run: bun run --filter '@trycompai/security-questionnaire-extension' build
102+
103+
- name: Verify manifest version
104+
env:
105+
EXTENSION_DIR: ${{ env.EXTENSION_DIR }}
106+
VERSION: ${{ steps.version.outputs.version }}
107+
OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }}
108+
run: |
109+
node -e "
110+
const fs = require('fs');
111+
const manifestPath = process.env.EXTENSION_DIR + '/dist/chrome-mv3/manifest.json';
112+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
113+
if (manifest.version !== process.env.VERSION) {
114+
throw new Error(
115+
'Expected manifest version ' + process.env.VERSION +
116+
', got ' + manifest.version,
117+
);
118+
}
119+
// Google OAuth is optional. When the client id secret is set the
120+
// manifest must carry it; without it the extension ships with the
121+
// guided Sheets paste flow instead of direct Sheets API writes.
122+
const wantsOAuth = Boolean(process.env.OAUTH_CLIENT_ID);
123+
if (wantsOAuth && !manifest.oauth2?.client_id) {
124+
throw new Error('OAuth client id was provided but is missing from the built manifest');
125+
}
126+
console.log('Google Sheets API insertion:', wantsOAuth ? 'enabled' : 'disabled (guided paste)');
127+
128+
// The build silently falls back to localhost when the public URLs
129+
// are unset, so refuse to publish a package pointed at a dev host.
130+
const hosts = manifest.host_permissions || [];
131+
const localhost = hosts.filter((host) => host.includes('localhost'));
132+
if (localhost.length > 0) {
133+
throw new Error(
134+
'Refusing to publish: manifest still points at ' + localhost.join(', '),
135+
);
136+
}
137+
if (!hosts.some((host) => host.startsWith('https://api.trycomp.ai'))) {
138+
throw new Error(
139+
'Refusing to publish: manifest is missing the production API host',
140+
);
141+
}
142+
"
143+
144+
- name: Package extension
145+
working-directory: ${{ env.EXTENSION_DIR }}/dist/chrome-mv3
146+
env:
147+
ZIP_NAME: ${{ steps.version.outputs.zip_name }}
148+
run: zip -r "../$ZIP_NAME" .
149+
150+
- name: Upload build artifact
151+
uses: actions/upload-artifact@v4
152+
with:
153+
name: security-questionnaire-extension-${{ steps.version.outputs.version }}
154+
path: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }}
155+
if-no-files-found: error
156+
157+
- name: Validate Chrome Web Store secrets
158+
env:
159+
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }}
160+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
161+
run: |
162+
# SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID is intentionally
163+
# not required — it only enables direct Google Sheets API insertion.
164+
# CHROME_WEB_STORE_PUBLISHER_ID is unused by the v1.1 API but kept as a
165+
# secret for the eventual v2 migration.
166+
missing=0
167+
for name in SERVICE_ACCOUNT_JSON EXTENSION_ID; do
168+
if [ -z "${!name}" ]; then
169+
echo "::error::Missing required secret: $name"
170+
missing=1
171+
fi
172+
done
173+
if [ "$missing" -ne 0 ]; then
174+
exit 1
175+
fi
176+
177+
- name: Get Chrome Web Store access token
178+
id: token
179+
env:
180+
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }}
181+
run: |
182+
# Service account JWT bearer flow. Unlike a refresh token this does not
183+
# expire and is not tied to an individual Google account.
184+
ASSERTION=$(node -e "
185+
const crypto = require('crypto');
186+
const key = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);
187+
const encode = (value) =>
188+
Buffer.from(JSON.stringify(value))
189+
.toString('base64url');
190+
const issuedAt = Math.floor(Date.now() / 1000);
191+
const payload = {
192+
iss: key.client_email,
193+
scope: 'https://www.googleapis.com/auth/chromewebstore',
194+
aud: 'https://oauth2.googleapis.com/token',
195+
iat: issuedAt,
196+
exp: issuedAt + 3600,
197+
};
198+
const signingInput =
199+
encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode(payload);
200+
const signature = crypto
201+
.createSign('RSA-SHA256')
202+
.update(signingInput)
203+
.sign(key.private_key)
204+
.toString('base64url');
205+
process.stdout.write(signingInput + '.' + signature);
206+
")
207+
208+
RESPONSE=$(curl -fsS "https://oauth2.googleapis.com/token" \
209+
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
210+
--data-urlencode "assertion=$ASSERTION")
211+
212+
ACCESS_TOKEN=$(RESPONSE="$RESPONSE" node -e "
213+
const response = JSON.parse(process.env.RESPONSE);
214+
if (!response.access_token) throw new Error('No access_token returned');
215+
process.stdout.write(response.access_token);
216+
")
217+
218+
echo "::add-mask::$ACCESS_TOKEN"
219+
echo "access_token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
220+
221+
# The v2 API is not yet served for this publisher — every v2 route returns
222+
# 404 while the v1.1 API works with the same service account token. v1.1
223+
# is supported until 2026-10-15; revisit before then.
224+
- name: Upload package to Chrome Web Store
225+
id: upload
226+
env:
227+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
228+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
229+
ZIP_PATH: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }}
230+
run: |
231+
RESPONSE=$(curl -fsS \
232+
-H "Authorization: Bearer $ACCESS_TOKEN" \
233+
-H "x-goog-api-version: 2" \
234+
-X PUT \
235+
-T "$ZIP_PATH" \
236+
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/$EXTENSION_ID")
237+
238+
echo "$RESPONSE"
239+
UPLOAD_STATE=$(RESPONSE="$RESPONSE" node -e "
240+
const response = JSON.parse(process.env.RESPONSE);
241+
const state = response.uploadState;
242+
if (state !== 'SUCCESS' && state !== 'IN_PROGRESS') {
243+
const details = (response.itemError || [])
244+
.map((entry) => entry.error_detail)
245+
.join('; ');
246+
throw new Error(
247+
\`Chrome upload failed with state \${state || 'unknown'}\${details ? ': ' + details : ''}\`,
248+
);
249+
}
250+
process.stdout.write(state);
251+
")
252+
253+
echo "upload_state=$UPLOAD_STATE" >> "$GITHUB_OUTPUT"
254+
255+
- name: Wait for async Chrome Web Store upload
256+
if: ${{ steps.upload.outputs.upload_state == 'IN_PROGRESS' }}
257+
env:
258+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
259+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
260+
run: |
261+
for attempt in $(seq 1 30); do
262+
RESPONSE=$(curl -fsS \
263+
-H "Authorization: Bearer $ACCESS_TOKEN" \
264+
-H "x-goog-api-version: 2" \
265+
"https://www.googleapis.com/chromewebstore/v1.1/items/$EXTENSION_ID?projection=DRAFT")
266+
267+
STATE=$(RESPONSE="$RESPONSE" node -e "
268+
const response = JSON.parse(process.env.RESPONSE);
269+
process.stdout.write(response.uploadState || 'UNKNOWN');
270+
")
271+
echo "Upload state: $STATE"
272+
273+
if [ "$STATE" = "SUCCESS" ]; then
274+
exit 0
275+
fi
276+
if [ "$STATE" = "FAILURE" ]; then
277+
echo "::error::Chrome Web Store async upload failed"
278+
echo "$RESPONSE"
279+
exit 1
280+
fi
281+
282+
echo "Still $STATE; waiting before retry $attempt"
283+
sleep 10
284+
done
285+
286+
echo "::error::Timed out waiting for Chrome Web Store async upload"
287+
exit 1
288+
289+
- name: Publish Chrome Web Store item
290+
env:
291+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
292+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
293+
PUBLISH_TARGET: ${{ inputs.publish_target || 'trustedTesters' }}
294+
run: |
295+
echo "Publishing to: $PUBLISH_TARGET"
296+
RESPONSE=$(curl -fsS \
297+
-H "Authorization: Bearer $ACCESS_TOKEN" \
298+
-H "x-goog-api-version: 2" \
299+
-H "Content-Length: 0" \
300+
-X POST \
301+
"https://www.googleapis.com/chromewebstore/v1.1/items/$EXTENSION_ID/publish?publishTarget=$PUBLISH_TARGET")
302+
303+
echo "$RESPONSE"
304+
RESPONSE="$RESPONSE" node -e "
305+
const response = JSON.parse(process.env.RESPONSE);
306+
const statuses = response.status || [];
307+
const failed = statuses.filter(
308+
(entry) => entry !== 'OK' && entry !== 'PUBLISHED_WITH_FRICTION_WARNING',
309+
);
310+
if (failed.length > 0) {
311+
throw new Error(
312+
'Publish rejected: ' +
313+
failed.join(', ') +
314+
' — ' +
315+
(response.statusDetail || []).join('; '),
316+
);
317+
}
318+
"
319+
320+
- name: Tag published extension version
321+
env:
322+
TAG_NAME: ${{ steps.version.outputs.tag_name }}
323+
VERSION: ${{ steps.version.outputs.version }}
324+
run: |
325+
git config user.name "github-actions[bot]"
326+
git config user.email "github-actions[bot]@users.noreply.github.com"
327+
git tag -a "$TAG_NAME" -m "Security questionnaire extension v$VERSION"
328+
git push origin "$TAG_NAME"

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ playwright/.cache/
8585
debug-setup-page.png
8686

8787
packages/*/dist
88+
apps/browser-extension/*/.wxt
89+
apps/browser-extension/*/dist
8890

8991
# Generated Prisma Client
9092
**/src/db/generated/

0 commit comments

Comments
 (0)