diff --git a/.github/workflows/chrome-web-store.yml b/.github/workflows/chrome-web-store.yml index 562b7d8..daeb452 100644 --- a/.github/workflows/chrome-web-store.yml +++ b/.github/workflows/chrome-web-store.yml @@ -31,15 +31,20 @@ jobs: environment: ${{ inputs.channel }} env: BACKEND_PUBLIC_URL: ${{ vars.BACKEND_PUBLIC_URL || secrets.BACKEND_PUBLIC_URL }} - CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} - CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} CWS_EXTENSION_ID: ${{ vars.CWS_EXTENSION_ID || secrets.CWS_EXTENSION_ID }} CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID || secrets.CWS_PUBLISHER_ID }} - CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} steps: - name: Checkout repo uses: actions/checkout@v4 + - name: Authenticate Chrome Web Store service account + id: auth + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/chromewebstore + - name: Set up Node uses: actions/setup-node@v4 with: @@ -70,28 +75,15 @@ jobs: (cd apps/extension/dist && zip -qr "../../../artifacts/${artifact_name}.zip" .) echo "zip_path=artifacts/${artifact_name}.zip" >> "$GITHUB_OUTPUT" - - name: Request Chrome Web Store access token - id: token + - name: Validate Chrome Web Store configuration run: | : "${CWS_PUBLISHER_ID:?CWS_PUBLISHER_ID is required}" : "${CWS_EXTENSION_ID:?CWS_EXTENSION_ID is required}" - : "${CWS_CLIENT_ID:?CWS_CLIENT_ID is required}" - : "${CWS_CLIENT_SECRET:?CWS_CLIENT_SECRET is required}" - : "${CWS_REFRESH_TOKEN:?CWS_REFRESH_TOKEN is required}" - curl -fsS "https://oauth2.googleapis.com/token" \ - -d "client_id=${CWS_CLIENT_ID}" \ - -d "client_secret=${CWS_CLIENT_SECRET}" \ - -d "refresh_token=${CWS_REFRESH_TOKEN}" \ - -d "grant_type=refresh_token" \ - -o token.json - access_token="$(node -e "const fs=require('node:fs'); console.log(JSON.parse(fs.readFileSync('token.json', 'utf8')).access_token)")" - echo "::add-mask::${access_token}" - echo "access_token=${access_token}" >> "$GITHUB_OUTPUT" - name: Check current store version run: | curl -fsS \ - -H "Authorization: Bearer ${{ steps.token.outputs.access_token }}" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ "https://chromewebstore.googleapis.com/v2/publishers/${CWS_PUBLISHER_ID}/items/${CWS_EXTENSION_ID}:fetchStatus" \ -o cws-status.json node scripts/compare-extension-version.mjs apps/extension/dist/manifest.json cws-status.json @@ -99,7 +91,7 @@ jobs: - name: Upload package run: | curl -fsS \ - -H "Authorization: Bearer ${{ steps.token.outputs.access_token }}" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ -H "Content-Type: application/zip" \ -X POST \ --upload-file "${{ steps.package.outputs.zip_path }}" \ @@ -110,7 +102,7 @@ jobs: for attempt in $(seq 1 20); do sleep 5 curl -fsS \ - -H "Authorization: Bearer ${{ steps.token.outputs.access_token }}" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ "https://chromewebstore.googleapis.com/v2/publishers/${CWS_PUBLISHER_ID}/items/${CWS_EXTENSION_ID}:fetchStatus" \ -o cws-status-after-upload.json upload_state="$(node -e "const fs=require('node:fs'); const status=JSON.parse(fs.readFileSync('cws-status-after-upload.json', 'utf8')); console.log(status.lastAsyncUploadState || 'SUCCESS');")" @@ -129,7 +121,7 @@ jobs: run: | node -e "const fs=require('node:fs'); fs.writeFileSync('publish-body.json', JSON.stringify({ publishType: process.env.PUBLISH_TYPE }));" curl -fsS \ - -H "Authorization: Bearer ${{ steps.token.outputs.access_token }}" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ -H "Content-Type: application/json" \ -X POST \ -d @publish-body.json \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdd9b20..806997d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,11 @@ jobs: - name: Install just run: cargo install just --locked + - name: Install pnpm + run: npm install -g pnpm@11.3.0 + - name: Install JS dependencies - run: npx pnpm@latest install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Run repo checks run: just check diff --git a/.release-inputs.example.env b/.release-inputs.example.env index e25f924..9ba5b46 100644 --- a/.release-inputs.example.env +++ b/.release-inputs.example.env @@ -5,9 +5,7 @@ BETA_BACKEND_PUBLIC_URL=https://beta.example.com BETA_CADDY_EMAIL=admin@example.com BETA_CWS_PUBLISHER_ID=publisher-id BETA_CWS_EXTENSION_ID=extension-id -BETA_CWS_CLIENT_ID=client-id -BETA_CWS_CLIENT_SECRET=client-secret -BETA_CWS_REFRESH_TOKEN=refresh-token +BETA_CWS_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"project-id","private_key_id":"key-id","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"chrome-web-store@project-id.iam.gserviceaccount.com","client_id":"client-id","token_uri":"https://oauth2.googleapis.com/token"}' BETA_VPS_HOST=beta-vps.example.com BETA_VPS_USER=deploy BETA_VPS_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----" @@ -16,9 +14,7 @@ PRODUCTION_BACKEND_PUBLIC_URL=https://watch.example.com PRODUCTION_CADDY_EMAIL=admin@example.com PRODUCTION_CWS_PUBLISHER_ID=publisher-id PRODUCTION_CWS_EXTENSION_ID=extension-id -PRODUCTION_CWS_CLIENT_ID=client-id -PRODUCTION_CWS_CLIENT_SECRET=client-secret -PRODUCTION_CWS_REFRESH_TOKEN=refresh-token +PRODUCTION_CWS_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"project-id","private_key_id":"key-id","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"chrome-web-store@project-id.iam.gserviceaccount.com","client_id":"client-id","token_uri":"https://oauth2.googleapis.com/token"}' PRODUCTION_VPS_HOST=production-vps.example.com PRODUCTION_VPS_USER=deploy PRODUCTION_VPS_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----" diff --git a/docs/chrome-store.md b/docs/chrome-store.md index 325af65..13ae871 100644 --- a/docs/chrome-store.md +++ b/docs/chrome-store.md @@ -4,7 +4,7 @@ Selected release strategy: -- Alpha and beta use one private Chrome Web Store item limited to trusted testers. +- Beta uses one private Chrome Web Store item limited to trusted testers. - A separate beta item is only needed if beta must run for a long time in parallel with production. - Production starts as an unlisted item for the first public release window, then can move to public distribution after the support and review process is stable. diff --git a/docs/deployment.md b/docs/deployment.md index 9491464..6e6acb3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -147,9 +147,7 @@ Environment secrets: - `VPS_SSH_KEY` - `BACKEND_PUBLIC_URL` if not stored as an environment variable - `CADDY_EMAIL` if not stored as an environment variable -- `CWS_CLIENT_ID` -- `CWS_CLIENT_SECRET` -- `CWS_REFRESH_TOKEN` +- `CWS_SERVICE_ACCOUNT_JSON` Environment variables: @@ -171,13 +169,14 @@ gh variable set CWS_EXTENSION_ID --env beta --body extension-id gh secret set VPS_HOST --env beta --body vps.example.com gh secret set VPS_USER --env beta --body deploy gh secret set VPS_SSH_KEY --env beta < ~/.ssh/videotogether_deploy -gh secret set CWS_CLIENT_ID --env beta --body client-id -gh secret set CWS_CLIENT_SECRET --env beta --body client-secret -gh secret set CWS_REFRESH_TOKEN --env beta --body refresh-token +gh secret set CWS_SERVICE_ACCOUNT_JSON --env beta --body "$(jq -c . service-account.json)" ``` Repeat for `production` with its production backend URL, VPS target, and Chrome Web Store item ID. +The Chrome Web Store service account must also be granted access in the Chrome +Web Store Developer Dashboard under the publisher account settings. + You can also set the same inputs from local environment variables or a dotenv-style file: ```bash diff --git a/docs/pre-release-goals.md b/docs/pre-release-goals.md index 47a499b..72ce6cc 100644 --- a/docs/pre-release-goals.md +++ b/docs/pre-release-goals.md @@ -11,12 +11,24 @@ VideoTogether Lite backend and Chrome extension. The target release path is: - Docs: wording updated so the repo describes the current implementation rather than its fork-era history. +## Current Status + +- [x] Beta VPS is provisioned. +- [x] Docker is installed on the beta VPS. +- [x] Cloudflare DNS points the beta backend domain to the VPS. +- [x] GitHub Actions `beta` environment inputs are configured. +- [x] Chrome Web Store beta submission has been sent for review. +- [ ] Chrome Web Store beta review is approved. +- [x] Production environment setup is postponed until beta is healthy. + ## Release Principles - Keep the backend to one running replica until room/session state moves out of process memory. -- Treat the production backend URL and Chrome Web Store item IDs as - environment-specific release inputs. +- Treat beta as the active release target. +- Keep production backend URL, production Chrome Web Store item IDs, production + tagged deploys, and public production release work deferred until beta is + deployed and tested. - Keep secrets in GitHub Actions environments, not checked-in files. - Make beta testing explicit: separate backend host, tester-only store distribution, and clear feedback instructions. @@ -69,34 +81,35 @@ VideoTogether Lite backend and Chrome extension. The target release path is: - Run `docker compose up -d`. - Verify `/healthz` through the public Caddy URL. -- [x] Configure GitHub Actions environments. +- [x] Configure active GitHub Actions environments. - `beta` - - `production` + - `production` remains deferred for now. -- [x] Define required secrets and variables per environment. +- [x] Define required beta secrets and variables. - `VPS_HOST` - `VPS_USER` - `VPS_SSH_KEY` - `BACKEND_PUBLIC_URL` - `CWS_PUBLISHER_ID` - `CWS_EXTENSION_ID` - - Chrome Web Store OAuth/client credentials + - Chrome Web Store service-account credentials - any registry credentials not covered by `GITHUB_TOKEN` -- [x] Gate production deploys. +- [x] Keep production deploy gates ready for later. - Use required reviewers for the `production` environment. - Add deployment concurrency so two production deploys cannot race. + - Do not require production environment values for the beta unblock. ## Extension Release Channels - [x] Decide Chrome Web Store item strategy. - For short beta testing: use one private item with trusted testers. - For long-running beta in parallel with production: use a separate beta item. - - For production: decide public vs unlisted. + - Production distribution stays deferred until beta is stable. - [x] Add channel-specific build support. - `beta` builds point at the beta backend URL. - - `production` builds point at the production backend URL. + - `production` build wiring exists, but production values are postponed. - Artifact names include channel, manifest version, and git SHA. - [x] Add extension package workflow. @@ -209,14 +222,19 @@ VideoTogether Lite backend and Chrome extension. The target release path is: - [ ] `just check` passes locally and in GitHub Actions. - [x] Backend Docker image runs locally. - [x] Caddy reverse proxy handles HTTP and WebSocket traffic. +- [x] Beta VPS is provisioned. +- [x] Docker is installed on the beta VPS. +- [x] Cloudflare DNS points the beta backend domain to the VPS. +- [x] GitHub Actions `beta` environment inputs are configured. - [ ] Beta backend is deployed from GitHub Actions. - [ ] Beta extension ZIP is built by GitHub Actions. +- [x] Beta Chrome Web Store submission is pending review. - [ ] Beta Chrome Web Store item is private and limited to trusted testers. - [ ] Create/join/update/leave room flow passes through the public beta URL. - [x] YouTube, Bilibili, and basic HTML video smoke tests pass. - [ ] Privacy and tester docs are published or linked from the store listing. -## Verification Before Production +## Deferred Production - [ ] Production backend is deployed from a tagged image. - [ ] Production extension build uses the production backend URL. @@ -225,6 +243,9 @@ VideoTogether Lite backend and Chrome extension. The target release path is: - [x] Rollback path is tested for backend and extension package. - [x] Monitoring/log review process is documented. +Production work resumes only after beta backend deployment, Chrome Web Store +beta approval, and public beta smoke tests pass. + ## References - Chrome Web Store publishing: diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 7341dcc..3055fcd 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,6 @@ # Release Checklist -Use this file for beta and production releases. The backend URL, Chrome Web Store item IDs, and OAuth credentials are environment-specific values. +Use this file for beta and production releases. The backend URL, Chrome Web Store item IDs, and Chrome Web Store service-account credentials are environment-specific values. ## Workflow Runner diff --git a/docs/zh-cn/user-guide.md b/docs/zh-cn/user-guide.md index 4fce8e0..369ae25 100644 --- a/docs/zh-cn/user-guide.md +++ b/docs/zh-cn/user-guide.md @@ -11,7 +11,7 @@ VideoTogether Lite 会在邀请码房间中同步你主动选择并共享的视 3. 启用开发者模式。 4. 加载已解压的 `apps/extension/dist`。 -Alpha 或 beta 测试时,请使用维护者提供的私有 Chrome Web Store 测试链接安装。 +Beta 测试时,请使用维护者提供的私有 Chrome Web Store 测试链接安装。 ## 创建房间 diff --git a/scripts/audit-release-readiness.mjs b/scripts/audit-release-readiness.mjs index 3104601..bdf7e2c 100644 --- a/scripts/audit-release-readiness.mjs +++ b/scripts/audit-release-readiness.mjs @@ -15,11 +15,9 @@ const requiredWorkflowFiles = [ const requiredEnvironmentInputs = [ "BACKEND_PUBLIC_URL", "CADDY_EMAIL", - "CWS_CLIENT_ID", - "CWS_CLIENT_SECRET", "CWS_EXTENSION_ID", "CWS_PUBLISHER_ID", - "CWS_REFRESH_TOKEN", + "CWS_SERVICE_ACCOUNT_JSON", "VPS_HOST", "VPS_SSH_KEY", "VPS_USER" diff --git a/scripts/configure-github-env-inputs.mjs b/scripts/configure-github-env-inputs.mjs index ec96e21..67888e1 100644 --- a/scripts/configure-github-env-inputs.mjs +++ b/scripts/configure-github-env-inputs.mjs @@ -8,6 +8,7 @@ const targetEnvironments = (process.env.TARGET_ENVIRONMENTS ?? defaultEnvironmen .split(",") .map((value) => value.trim()) .filter(Boolean); +const dryRun = process.env.RELEASE_INPUTS_DRY_RUN === "1"; const variableInputs = [ "ALLOWED_ORIGINS", @@ -18,9 +19,7 @@ const variableInputs = [ "ROOM_TTL" ]; const secretInputs = [ - "CWS_CLIENT_ID", - "CWS_CLIENT_SECRET", - "CWS_REFRESH_TOKEN", + "CWS_SERVICE_ACCOUNT_JSON", "VPS_HOST", "VPS_SSH_KEY", "VPS_USER" @@ -56,7 +55,10 @@ async function repoSlug() { function parseDotenv(content) { const values = new Map(); - for (const rawLine of content.split(/\r?\n/)) { + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; const line = rawLine.trim(); if (line === "" || line.startsWith("#")) { continue; @@ -67,17 +69,56 @@ function parseDotenv(content) { } const key = line.slice(0, equals).trim(); let value = line.slice(equals + 1).trim(); - if ( - (value.startsWith("\"") && value.endsWith("\"")) - || (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); + const quote = value[0]; + + if (quote === "\"" || quote === "'") { + if (value.length > 1 && value.endsWith(quote)) { + value = value.slice(1, -1); + } else { + const chunks = [value.slice(1)]; + let closed = false; + for (index += 1; index < lines.length; index += 1) { + const continuation = lines[index].trimEnd(); + if (continuation.endsWith(quote)) { + chunks.push(continuation.slice(0, -1)); + closed = true; + break; + } + chunks.push(lines[index]); + } + if (!closed) { + throw new Error(`Unterminated quoted value for ${key}`); + } + value = chunks.join("\n"); + } } - values.set(key, value.replaceAll("\\n", "\n")); + values.set(key, key.endsWith("_JSON") ? value : value.replaceAll("\\n", "\n")); } return values; } +function validateValue(environmentName, inputName, value) { + if (inputName !== "CWS_SERVICE_ACCOUNT_JSON") { + return true; + } + try { + const credentials = JSON.parse(value); + const missing = ["type", "client_email", "private_key", "token_uri"].filter((key) => !credentials[key]); + if (credentials.type !== "service_account") { + console.error(`${environmentName}: ${inputName} must have type=service_account.`); + return false; + } + if (missing.length > 0) { + console.error(`${environmentName}: ${inputName} is missing ${missing.join(", ")}.`); + return false; + } + return true; + } catch (error) { + console.error(`${environmentName}: ${inputName} is not valid JSON: ${error.message}`); + return false; + } +} + async function loadFileValues() { if (!process.env.RELEASE_INPUTS_FILE) { return new Map(); @@ -154,8 +195,16 @@ for (const environmentName of targetEnvironments) { } continue; } - await setWithRetry("variable", repo, environmentName, inputName, value); - console.log(`Set environment variable ${inputName} for ${environmentName}.`); + if (!validateValue(environmentName, inputName, value)) { + missingCount += 1; + continue; + } + if (dryRun) { + console.log(`Validated environment variable ${inputName} for ${environmentName}.`); + } else { + await setWithRetry("variable", repo, environmentName, inputName, value); + console.log(`Set environment variable ${inputName} for ${environmentName}.`); + } } for (const inputName of secretInputs) { @@ -165,8 +214,16 @@ for (const environmentName of targetEnvironments) { missingCount += 1; continue; } - await setWithRetry("secret", repo, environmentName, inputName, value); - console.log(`Set environment secret ${inputName} for ${environmentName}.`); + if (!validateValue(environmentName, inputName, value)) { + missingCount += 1; + continue; + } + if (dryRun) { + console.log(`Validated environment secret ${inputName} for ${environmentName}.`); + } else { + await setWithRetry("secret", repo, environmentName, inputName, value); + console.log(`Set environment secret ${inputName} for ${environmentName}.`); + } } }