Skip to content

feat: add first-party Tinybird analytics #88

feat: add first-party Tinybird analytics

feat: add first-party Tinybird analytics #88

Workflow file for this run

name: Analytics staging
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/workflows/analytics.yml"
- "package.json"
- "pnpm-lock.yaml"
- "packages/local-docker/**"
- "packages/analytics/**"
- "packages/database/**"
- "packages/env/server.ts"
- "packages/web-backend/src/ProductAnalytics/**"
- "packages/web-backend/src/Tinybird/**"
- "packages/web-backend/src/Organisations/**"
- "packages/web-domain/**"
- "apps/cli/**"
- "apps/desktop/**"
- "apps/web/**"
- "apps/cli/src/analytics.rs"
- "apps/mobile/**"
- "apps/web/actions/analytics/**"
- "apps/web/actions/organization/**"
- "apps/web/__tests__/unit/*analytics*.test.ts"
- "apps/web/__tests__/unit/developer-credits-webhook.test.ts"
- "apps/web/app/Layout/*Analytics*.tsx"
- "apps/web/app/admin/analytics/**"
- "apps/web/app/api/analytics/**"
- "apps/web/app/api/cron/drain-product-analytics-outbox/**"
- "apps/web/app/api/cron/reconcile-product-analytics/**"
- "apps/web/app/api/cron/recover-organization-invite-delivery/**"
- "apps/web/app/api/cron/recover-product-analytics-erasure/**"
- "apps/web/app/api/cron/refresh-product-analytics/**"
- "apps/web/app/api/events/**"
- "apps/web/app/api/invite/accept/route.ts"
- "apps/web/app/api/settings/billing/**"
- "apps/web/app/api/webhooks/stripe/route.ts"
- "apps/web/app/mobile/checkout/**"
- "apps/web/app/api/mobile/**"
- "apps/web/app/utils/analytics.ts"
- "apps/web/app/utils/product-analytics.ts"
- "apps/web/lib/analytics/**"
- "apps/web/lib/organization-invite-delivery.ts"
- "apps/web/proxy.ts"
- "apps/web/vercel.json"
- "apps/web/lib/rate-limit.ts"
- "apps/web/workflows/*product-analytics*"
- "apps/web/workflows/recover-organization-invite-delivery.ts"
- "apps/desktop/src-tauri/src/lib.rs"
- "apps/desktop/src-tauri/src/product_analytics.rs"
- "apps/desktop/src/utils/analytics.ts"
- "apps/desktop/src/utils/product-analytics.ts"
- "apps/desktop/src/utils/*analytics*.test.ts"
- "scripts/analytics/**"
workflow_dispatch:
permissions:
actions: read
contents: read
deployments: read
pull-requests: read
statuses: read
concurrency:
group: >-
${{ ((github.event_name == 'pull_request' && github.event.pull_request.number == 2003 && github.event.pull_request.head.ref == 'codex/first-party-analytics') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/first-party-analytics')) && 'analytics-staging-37b8fef9-817f-4c3c-b21f-218c36a6077d' || format('analytics-staging-out-of-scope-{0}', github.run_id) }}
cancel-in-progress: false
env:
TINYBIRD_WORKSPACE_ID: 37b8fef9-817f-4c3c-b21f-218c36a6077d
jobs:
validate:
name: Validate exact analytics SHA
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.number == 2003 &&
github.event.pull_request.head.ref == 'codex/first-party-analytics') ||
(github.event_name == 'workflow_dispatch' &&
github.ref == 'refs/heads/codex/first-party-analytics')
runs-on: ubuntu-latest
timeout-minutes: 30
services:
mysql:
image: mysql:8.4
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20
env:
EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
EVENT_NUMBER: ${{ github.event.pull_request.number || '' }}
HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
steps:
- name: Check out exact requested SHA
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 1
- uses: ./.github/actions/setup-js
- name: Refuse an out-of-scope event, branch, PR, or checkout
run: node scripts/analytics/staging-ci.js verify-scope --actual-sha "$(git rev-parse HEAD)"
- name: Run analytics contract and regression suites
run: pnpm analytics:test
- name: Prove analytics leases, receipts, and shared-cookie erasure in MySQL
env:
CAP_PRODUCT_ANALYTICS_MYSQL_E2E: "1"
run: >-
pnpm --filter @cap/web exec vitest run
__tests__/e2e/product-analytics-mysql-e2e.test.ts
- name: Validate Docker Compose
run: node scripts/analytics/analytics-cli.js compose-check
- name: Build and test Tinybird datafiles with fixtures
run: node scripts/analytics/analytics-cli.js local
- name: Stop Tinybird Local
if: always()
run: node scripts/analytics/analytics-cli.js local-stop
validate-desktop:
name: Validate desktop analytics outbox
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.number == 2003 &&
github.event.pull_request.head.ref == 'codex/first-party-analytics') ||
(github.event_name == 'workflow_dispatch' &&
github.ref == 'refs/heads/codex/first-party-analytics')
runs-on: macos-14
timeout-minutes: 30
steps:
- name: Check out exact requested SHA
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 1
- name: Install Rust 1.88
uses: dtolnay/rust-toolchain@1.88.0
- uses: ./.github/actions/setup-rust-cache
with:
target: aarch64-apple-darwin
- name: Prepare pinned native dependencies
run: node scripts/setup.js
- name: Create compile-only Tauri sidecars
run: |
target="$(rustc -vV | sed -n 's|host: ||p')"
binaries="apps/desktop/src-tauri/binaries"
install -d "$binaries"
for binary in cap-muxer cap-exporter cap-cli; do
install -m 755 /dev/null "$binaries/$binary-$target"
done
- name: Compile the desktop analytics path
run: cargo check -p cap-desktop --locked
- name: Prove durable desktop analytics behavior
run: cargo test -p cap-desktop product_analytics::tests --lib --locked
deploy-staging:
name: Deploy and prove analytics staging
needs: [validate, validate-desktop]
if: needs.validate.result == 'success' && needs.validate-desktop.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 180
environment: staging
env:
EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
EVENT_NUMBER: ${{ github.event.pull_request.number || '' }}
HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
ANALYTICS_TEST_RUN_ID: run_${{ github.run_id }}_${{ github.run_attempt }}_${{ github.event.pull_request.head.sha || github.sha }}
ANALYTICS_PREVIEW_ACCESS_URL: https://cap-web-git-codex-first-party-analytics-mc-ilroy.vercel.app
steps:
- name: Check out exact requested SHA
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 1
- uses: ./.github/actions/setup-js
- name: Refuse an out-of-scope event, branch, PR, or checkout
run: node scripts/analytics/staging-ci.js verify-scope --actual-sha "$(git rev-parse HEAD)"
- name: Refuse missing, non-staging, or wrong-workspace credentials
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }}
run: node scripts/analytics/staging-ci.js verify-credentials
- name: Wait for successful exact-SHA Vercel preview
id: vercel
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/analytics/staging-ci.js wait-vercel
- name: Refuse a preview bound outside Tinybird staging
env:
ANALYTICS_PREVIEW_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }}
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }}
TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: node scripts/analytics/staging-ci.js attest-preview
- name: Retire only a superseded Tinybird staging predecessor
id: retired-deployment
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js discard-retired-deployment
--artifact "$RUNNER_TEMP/analytics-retired-deployment.json"
- name: Upload retired Tinybird deployment evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-retired-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/analytics-retired-deployment.json
if-no-files-found: error
retention-days: 30
- name: Persist the pre-create Tinybird recovery boundary
env:
VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }}
VERCEL_PREVIEW_ACCESS_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }}
VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js prepare-deployment-boundary
--output "$RUNNER_TEMP/analytics-deployment-boundary.json"
- name: Upload the immutable pre-create recovery boundary
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-00-precreate
path: ${{ runner.temp }}/analytics-deployment-boundary.json
if-no-files-found: error
retention-days: 30
- name: Record Tinybird deployment boundary
run: echo "TINYBIRD_DEPLOYMENT_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "$GITHUB_ENV"
- name: Check Tinybird cloud deployment plan
id: deployment-check
env:
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }}
run: >-
docker compose --file packages/local-docker/docker-compose.yml --profile analytics
run --rm tinybird-cloud-cli --cloud deployment create --allow-destructive-operations --check
- name: Create isolated Tinybird staging deployment
id: create-deployment
env:
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }}
run: |
set -o pipefail
set +e
docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud deployment create --allow-destructive-operations --wait | tee "$RUNNER_TEMP/tinybird-create-output.txt"
create_exit="${PIPESTATUS[0]}"
set -e
node -e 'const fs = require("node:fs"); fs.writeFileSync(process.argv[2], JSON.stringify({ exitCode: Number(process.argv[3]), output: fs.readFileSync(process.argv[1], "utf8") }));' "$RUNNER_TEMP/tinybird-create-output.txt" "$RUNNER_TEMP/tinybird-create-output.json" "$create_exit"
- name: Resolve only the deployment created by this run
id: tinybird
env:
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }}
run: |
docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud --output json deployment ls > "$RUNNER_TEMP/tinybird-deployments.json"
node scripts/analytics/staging-ci.js select-deployment --input "$RUNNER_TEMP/tinybird-deployments.json" --create-output "$RUNNER_TEMP/tinybird-create-output.json" --boundary "$RUNNER_TEMP/analytics-deployment-boundary.json" --minimum-created-at "$TINYBIRD_DEPLOYMENT_STARTED_AT"
- name: Prepare immutable synthetic cleanup state before ingestion
env:
VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }}
VERCEL_PREVIEW_ACCESS_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }}
VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }}
run: >-
node scripts/analytics/staging-ci.js prepare-seed
--run-id "$ANALYTICS_TEST_RUN_ID"
--deployment-id "${{ steps.tinybird.outputs.id }}"
--needs-promotion "${{ steps.tinybird.outputs.needs_promotion }}"
--boundary "$RUNNER_TEMP/analytics-deployment-boundary.json"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Upload the immutable pre-ingestion recovery checkpoint
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-20-preseed
path: |
${{ runner.temp }}/analytics-staging-state.json
${{ runner.temp }}/analytics-staging-report.json
if-no-files-found: error
retention-days: 30
- name: Refuse source mutations after exact-SHA checkout
run: |
git diff --exit-code
test -z "$(git status --porcelain --untracked-files=no)"
- name: Re-run fixture assertions after staging creation
env:
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }}
run: node scripts/analytics/analytics-cli.js test
- name: Prove exact candidate endpoints before promotion
id: verify
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify-preseed
--deployment-id "${{ steps.tinybird.outputs.id }}"
--target "${{ steps.tinybird.outputs.needs_promotion == 'true' && 'staging' || 'live' }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Persist the exact Tinybird promotion plan
id: promotion-plan
if: steps.tinybird.outputs.needs_promotion == 'true'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js prepare-promotion
--deployment-id "${{ steps.tinybird.outputs.id }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
- name: Upload the immutable pre-promotion recovery checkpoint
if: steps.promotion-plan.outcome == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-30-prepromote
path: |
${{ runner.temp }}/analytics-staging-state.json
${{ runner.temp }}/analytics-staging-report.json
if-no-files-found: error
retention-days: 30
- name: Promote the verified staging deployment
id: promote
if: steps.tinybird.outputs.needs_promotion == 'true'
continue-on-error: true
env:
GITHUB_TOKEN: ${{ github.token }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: |
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
git diff --exit-code
test -z "$(git status --porcelain --untracked-files=no)"
node scripts/analytics/staging-ci.js verify-pr-head
node scripts/analytics/staging-ci.js promote-deployment --deployment-id "${{ steps.tinybird.outputs.id }}" --state "$RUNNER_TEMP/analytics-staging-state.json"
- name: Resolve authoritative Tinybird state after the promotion attempt
id: deployment-state
if: always() && steps.tinybird.outcome == 'success'
env:
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }}
run: |
for resolution_attempt in $(seq 1 30); do
if ! docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud --output json deployment ls > "$RUNNER_TEMP/tinybird-final-deployments.json"; then
if [[ "$resolution_attempt" -eq 30 ]]; then
exit 1
fi
sleep 5
continue
fi
recover_pending=false
if [[ "$resolution_attempt" -eq 30 ]]; then
recover_pending=true
fi
if node scripts/analytics/staging-ci.js resolve-deployment-state --input "$RUNNER_TEMP/tinybird-final-deployments.json" --deployment-id "${{ steps.tinybird.outputs.id }}" --recover-pending "$recover_pending"; then
exit 0
else
resolution_exit=$?
fi
if [[ "$resolution_exit" -ne 75 ]]; then
exit "$resolution_exit"
fi
sleep 5
done
exit 1
- name: Refuse to proceed without an authoritative live deployment
if: steps.deployment-state.outputs.promoted != 'true' || steps.promote.outcome == 'failure'
run: exit 1
- name: Seed bounded duplicate and conflict probes into exact live staging
id: seed
env:
VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }}
VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js seed
--run-id "$ANALYTICS_TEST_RUN_ID"
--deployment-id "${{ steps.tinybird.outputs.id }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Upload the immutable post-ingestion recovery checkpoint
if: steps.seed.outcome == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-35-postseed
path: |
${{ runner.temp }}/analytics-staging-state.json
${{ runner.temp }}/analytics-staging-report.json
if-no-files-found: error
retention-days: 30
- name: Verify representative ingestion performance
id: ingestion-budget
run: >-
node scripts/analytics/staging-ci.js verify-ingestion-budget
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove least-privilege staging token scopes
id: token-scopes
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }}
TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify-token-scopes
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove the exact-SHA deployed browser tracker
id: browser-e2e
env:
ANALYTICS_PREVIEW_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }}
ANALYTICS_BROWSER_RUN_ID: ${{ env.ANALYTICS_TEST_RUN_ID }}_preview
ANALYTICS_STATE_PATH: ${{ runner.temp }}/analytics-staging-state.json
ANALYTICS_ARTIFACT_PATH: ${{ runner.temp }}/analytics-staging-report.json
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: |
pnpm --dir apps/chrome-extension exec playwright install --with-deps chromium
pnpm --dir apps/chrome-extension exec playwright test e2e/analytics-staging.spec.ts
- name: Probe the exact-SHA Vercel browser collector and staging rate limit
id: preview-api
env:
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: >-
node scripts/analytics/staging-ci.js probe-preview
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove exact-SHA durable server delivery
id: server-delivery
env:
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: >-
node scripts/analytics/staging-ci.js probe-server
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Rebuild promoted decision and health copies
id: promoted-copies
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js run-copies
--deployment-id "${{ steps.tinybird.outputs.id }}"
--phase promoted
--target live
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Measure populated decision endpoint performance
id: populated-performance
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify
--deployment-id "${{ steps.tinybird.outputs.id }}"
--baseline-deployment-id "${{ steps.promote.outputs.previous_live_id || steps.tinybird.outputs.id }}"
--target live
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove promoted delivery, business values, and decision deduplication
id: verify-promoted
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify-promoted
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove exact staging rollback and restoration
id: rollback-drill
if: steps.verify-promoted.outcome == 'success' && steps.promote.outputs.promoted == 'true'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js drill-rollback
--deployment-id "${{ steps.tinybird.outputs.id }}"
--previous-live-id "${{ steps.promote.outputs.previous_live_id }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Delete the scoped identity through the exact-SHA application path
id: erase-identity
env:
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: >-
node scripts/analytics/staging-ci.js erase-synthetic-identity
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove identity erasure and out-of-scope control preservation
id: verify-erasure
if: steps.erase-identity.outcome == 'success'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify-synthetic-identity-erasure
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Quiesce scheduled and active Copy jobs before final cleanup
id: pause-copies
if: always() && steps.seed.outcome != 'skipped' && steps.deployment-state.outputs.promoted == 'true'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js set-copy-schedules
--action pause
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Delete strictly scoped synthetic raw rows
id: cleanup
if: always() && steps.seed.outcome != 'skipped' && (steps.deployment-state.outputs.target == 'staging' || steps.pause-copies.outcome == 'success')
env:
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: |
if [[ ! -f "$RUNNER_TEMP/analytics-staging-state.json" ]]; then
echo "required=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "required=true" >> "$GITHUB_OUTPUT"
node scripts/analytics/staging-ci.js cleanup \
--deployment-id "${{ steps.tinybird.outputs.id }}" \
--target "${{ steps.deployment-state.outputs.target || (steps.tinybird.outputs.needs_promotion == 'true' && 'staging' || 'live') }}" \
--state "$RUNNER_TEMP/analytics-staging-state.json" \
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Retract synthetic rows from every derived copy
id: cleanup-copies
if: always() && steps.cleanup.outputs.required == 'true' && steps.cleanup.outcome == 'success' && steps.cleanup.outputs.requires_copies == 'true'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js run-copies
--deployment-id "${{ steps.tinybird.outputs.id }}"
--phase cleanup
--target "${{ steps.cleanup.outputs.target }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Prove synthetic cleanup no longer affects queries
id: verify-cleanup
if: always() && steps.cleanup-copies.outcome == 'success'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js verify-cleanup
--deployment-id "${{ steps.tinybird.outputs.id }}"
--target "${{ steps.cleanup-copies.outputs.target }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Resume reviewed Copy schedules
id: resume-copies
if: always() && steps.pause-copies.outcome == 'success'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js set-copy-schedules
--action resume
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Persist finalization eligibility
id: recovery-ready
if: steps.pause-copies.outcome == 'success' && steps.verify-cleanup.outcome == 'success' && steps.rollback-drill.outcome == 'success' && steps.resume-copies.outcome == 'success'
run: >-
node scripts/analytics/staging-ci.js mark-recovery-ready
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Upload the immutable pre-finalization recovery checkpoint
if: steps.recovery-ready.outcome == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-40-ready-to-finalize
path: |
${{ runner.temp }}/analytics-staging-state.json
${{ runner.temp }}/analytics-staging-report.json
if-no-files-found: error
retention-days: 30
- name: Finalize the fully verified staging promotion
id: finalize
if: steps.recovery-ready.outcome == 'success'
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js finalize-promotion
--deployment-id "${{ steps.tinybird.outputs.id }}"
--previous-live-id "${{ steps.promote.outputs.previous_live_id }}"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Restore the previous staging deployment on failure
id: rollback
if: always() && steps.promote.outputs.previous_live_id != '' && steps.finalize.outcome != 'success' && steps.rollback-drill.outputs.rollback_target_usable != 'false' && (steps.seed.outcome == 'skipped' || steps.verify-cleanup.outcome == 'success')
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
run: >-
node scripts/analytics/staging-ci.js rollback-promotion
--deployment-id "${{ steps.tinybird.outputs.id }}"
--previous-live-id "${{ steps.promote.outputs.previous_live_id }}"
--state "$RUNNER_TEMP/analytics-staging-state.json"
--artifact "$RUNNER_TEMP/analytics-staging-report.json"
- name: Discard an unpromoted staging deployment after cleanup
id: discard
if: always() && steps.rollback.outcome != 'success' && (steps.deployment-state.outputs.discard == 'true' || steps.cleanup.outputs.requires_discard == 'true')
env:
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
run: |
discard_args=(--deployment-id "${{ steps.tinybird.outputs.id }}")
if [[ -f "$RUNNER_TEMP/analytics-staging-report.json" ]]; then
discard_args+=(--artifact "$RUNNER_TEMP/analytics-staging-report.json")
fi
node scripts/analytics/staging-ci.js discard-deployment "${discard_args[@]}"
- name: Upload redacted staging evidence
if: always() && steps.cleanup.outputs.required == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-staging-${{ github.event.pull_request.head.sha || github.sha }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/analytics-staging-report.json
if-no-files-found: error
retention-days: 30
recover-staging:
name: Recover interrupted analytics staging
needs: deploy-staging
if: always() && needs.deploy-staging.result != 'success'
runs-on: ubuntu-latest
timeout-minutes: 45
environment: staging
permissions:
actions: read
contents: read
deployments: read
pull-requests: read
env:
EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
EVENT_NUMBER: ${{ github.event.pull_request.number || '' }}
HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
ANALYTICS_TEST_RUN_ID: run_${{ github.run_id }}_${{ github.run_attempt }}_${{ github.event.pull_request.head.sha || github.sha }}
steps:
- name: Check out the interrupted exact SHA
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 1
- name: Locate immutable recovery checkpoints
id: checkpoints
env:
GH_TOKEN: ${{ github.token }}
CHECKPOINT_PREFIX: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-
run: |
count="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" --jq '[.artifacts[] | select(.expired == false and (.name | startswith(env.CHECKPOINT_PREFIX)))] | length')"
if [[ "$count" -eq 0 ]]; then
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "found=true" >> "$GITHUB_OUTPUT"
- name: Download immutable recovery checkpoints
if: steps.checkpoints.outputs.found == 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-*
path: ${{ runner.temp }}/analytics-recovery-checkpoints
- name: Reconcile and recover only the owned staging run
id: recover
if: steps.checkpoints.outputs.found == 'true'
env:
CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }}
TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }}
TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }}
TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }}
TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }}
TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }}
TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }}
run: >-
node scripts/analytics/staging-ci.js recover
--checkpoint-directory "$RUNNER_TEMP/analytics-recovery-checkpoints"
--artifact "$RUNNER_TEMP/analytics-recovery-report.json"
- name: Upload redacted recovery evidence
if: always() && steps.checkpoints.outputs.found == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: analytics-recovery-result-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/analytics-recovery-report.json
${{ runner.temp }}/analytics-recovery-checkpoints/**/analytics-staging-report.json
if-no-files-found: error
retention-days: 30