diff --git a/.env.example b/.env.example index bcc8a606..ff3a8214 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,21 @@ SIGMA_D1_NAME= # for local dev; the deploy render substitutes the real id. Per environment. SIGMA_D1_ID= +# --- Storage / index names (deploy render). Leave empty to use the committed production names. --- +# The non-prod guard in deploy.yml REQUIRES all three for any staging/dev target, so an unset value +# can't silently point a non-prod deploy at the live buckets. +SIGMA_CSV_CACHE_NAME= # e.g. sigma-csv-cache-stage +SIGMA_REPORTS_NAME= # e.g. sigma-reports-stage +SIGMA_VECTORIZE_NAME= # e.g. sigma-assistant-stage + +# --- Ephemeral PR previews (see docs/dev-environments.md) --- +# The preview worker name is DERIVED — `sigma--pr-` — so previews from the +# different forks of midt-bg/sigma never collide in the shared Cloudflare account. Nothing to set for +# that to work; scripts/preview-name.mjs reads GITHUB_REPOSITORY_OWNER (Actions provides it). +# +# PREVIEW_WORKER_PREFIX is an OPTIONAL override, only for a shorter URL. It replaces the WHOLE derived +# prefix (the worker is `-`), and must be a valid DNS label. +# Locally it is the easiest way to run the preview scripts outside Actions: +# GITHUB_REPOSITORY_OWNER=ydimitrof node scripts/preview-name.mjs --pr 12 +PREVIEW_WORKER_PREFIX= # e.g. sigma-yo-pr → sigma-yo-pr-12 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cebb18a..ebd55f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,15 @@ jobs: - name: Test if: ${{ !cancelled() }} run: pnpm test -- --coverage + # `pnpm test` runs the workspace (turbo) suites; the repo-root scripts/ live outside any + # workspace and are exercised by node:test instead. Matters most for the preview pipeline + # (preview-name / teardown-remote / reap-previews), whose allowlist logic issues real + # `wrangler delete` calls against a Cloudflare account shared with the other forks — + # docs/dev-environments.md. The glob re-runs the docs/coverage checker tests that have their + # own steps below; they are fast, and one glob is harder to forget than a per-file list. + - name: Script tests + if: ${{ !cancelled() }} + run: pnpm test:scripts # Coverage ratchet (#93): per-workspace lines/branches may not drop below # coverage-baseline.json (0.5pp tolerance). The checker self-tests first, # so the gate is itself gated — same pattern as the docs check below. diff --git a/.github/workflows/preview-reap.yml b/.github/workflows/preview-reap.yml new file mode 100644 index 00000000..98c37c77 --- /dev/null +++ b/.github/workflows/preview-reap.yml @@ -0,0 +1,135 @@ +name: Reap stale previews + +# Enforces the ephemeral preview max-lifetime. Preview workers are deployed by preview.yml on each push +# to an open same-repo PR and torn down when the PR closes/merges. This scheduled job is the backstop + +# TTL: it deletes any preview worker that has gone longer than PREVIEW_MAX_AGE_DAYS (default 5) without a +# redeploy — covering both idle-but-open PR previews and orphans whose close-teardown failed. A new push +# to the PR redeploys ("re-starts") the preview. +# +# SCOPE: the Cloudflare account is SHARED with the other forks of midt-bg/sigma, and the API lists every +# worker on it. The reaper only ever considers this repository's own `sigma--pr-` workers — +# scripts/preview-name.mjs derives that prefix and scripts/reap-previews.mjs filters on it, so this job +# can never delete a sibling fork's live preview. See docs/dev-environments.md. +# +# Runs from `main` (scheduled workflows always do), so it goes live once this merges. Uses the same +# `preview` GitHub Environment as preview.yml. + +on: + schedule: + - cron: '17 3 * * *' # daily at 03:17 UTC + workflow_dispatch: + inputs: + max_age_days: + description: Max preview age (days) before reaping + type: string + default: '5' + apply: + description: Actually delete (uncheck for a dry run) + type: boolean + default: true + +concurrency: + group: preview-reap + cancel-in-progress: false + +permissions: + contents: read + +jobs: + reap: + runs-on: ubuntu-latest + # Listing + deleting a handful of stale workers is quick; cap well under the 360-min default so a + # wedged wrangler delete can't pin a runner for hours on the daily schedule. + timeout-minutes: 10 + environment: preview + permissions: + contents: read + pull-requests: write + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PREVIEW_MAX_AGE_DAYS: ${{ github.event.inputs.max_age_days || '5' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + + # Resolves the SAME prefix preview.yml deploys under (no --pr: the reaper matches the whole family, + # not one PR). Exported via $GITHUB_ENV so reap-previews.mjs sees the resolved value rather than + # re-deriving it — a second derivation is how a renamed preview ends up unreapable. + - name: Compute preview worker prefix + env: + PREVIEW_WORKER_PREFIX: ${{ vars.PREVIEW_WORKER_PREFIX }} + run: node scripts/preview-name.mjs + + - run: pnpm install --frozen-lockfile + + - name: Guard — require preview credentials + run: | + missing=() + [ -z "${CLOUDFLARE_API_TOKEN}" ] && missing+=("CLOUDFLARE_API_TOKEN") + [ -z "${CLOUDFLARE_ACCOUNT_ID}" ] && missing+=("CLOUDFLARE_ACCOUNT_ID") + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::preview Environment is missing: ${missing[*]} — see docs/dev-environments-setup.md." + exit 1 + fi + + - name: Reap preview workers past the max lifetime + id: reap + # Run through `pnpm --filter @sigma/web exec` (cwd = apps/web, hence the ../../ path) so the + # web package's pinned wrangler is on PATH — reap-previews.mjs deletes via teardown-remote.mjs, + # which shells out to `wrangler`. A bare `node` invocation leaves node_modules/.bin off PATH + # and every delete would fail with ENOENT. + # Default to applying; a manual dispatch with apply=false performs a dry run instead. + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.apply }}" = "false" ]; then + pnpm --filter @sigma/web exec node ../../scripts/reap-previews.mjs + else + pnpm --filter @sigma/web exec node ../../scripts/reap-previews.mjs --apply + fi + + - name: Comment on PRs whose preview was reaped + # always() so a partial run that reaped some workers but hard-failed on others (reap step exits + # 1) still notifies the PRs that WERE reaped, instead of skipping on the upstream failure. + if: always() && steps.reap.outputs.reaped_json != '' && steps.reap.outputs.reaped_json != '[]' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REAPED_JSON: ${{ steps.reap.outputs.reaped_json }} + PREVIEW_MAX_AGE_DAYS: ${{ env.PREVIEW_MAX_AGE_DAYS }} + with: + # The (worker, PR) pairing comes from the reaper, which derives it with the same module that + # built the name. Re-deriving it here with a hardcoded pattern is how a renamed preview gets + # deleted with its PR never notified. + script: | + const reaped = JSON.parse(process.env.REAPED_JSON || '[]'); + const marker = ''; + for (const { worker, pr: issue_number } of reaped) { + // Only notify still-open PRs; closed ones were already torn down by preview.yml. + let prData; + try { + prData = await github.rest.pulls.get({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: issue_number, + }); + } catch { + continue; + } + if (prData.data.state !== 'open') continue; + const body = `${marker}\n♻️ Preview \`${worker}\` was reaped after exceeding the ` + + `${process.env.PREVIEW_MAX_AGE_DAYS}-day max lifetime. Push a new commit to redeploy it.`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number, body, + }); + } + } diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..b109c78c --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,260 @@ +name: PR preview (ephemeral sigma worker) + +# Ephemeral per-PR preview of the SSR explorer. Each open PR gets its own Worker at +# https://-..workers.dev, deleted when the PR closes. +# +# Design (see docs/dev-environments.md): +# - Web worker ONLY. The ETL worker is cron-only and WRITES D1 — we never spin a per-PR copy of it. +# - Previews SHARE the long-lived dev D1 + dev R2 buckets (read-only from the preview worker). No +# per-PR data provisioning, no per-PR seed — the preview shows the same data the dev env has. +# - NO migrations. deploy.yml applies schema changes to staging/production; a preview must never run +# them against the shared dev D1 (docs/dev-environments.md). +# - All credentials come from a `preview` GitHub Environment whose SIGMA_D1_ID points at the dev D1. +# Keep that environment WITHOUT required reviewers, or every preview deploy will block. +# +# Naming: the worker name is DERIVED, not configured — `sigma--pr-`, computed by +# scripts/preview-name.mjs. This repo, lyubomir-bozhinov/sigma and midt-bg/sigma all deploy previews into +# ONE shared Cloudflare account, so a bare `sigma-pr-` scheme collides whenever two forks reach the +# same PR number — the second deploy silently overwrites the first, and each repo's reaper deletes the +# other's workers. Deriving from the owner makes that structurally impossible with nothing to configure. +# +# Security: fork PRs cannot read repo secrets, so the deploy/teardown jobs are gated to same-repo +# branches. Fork PRs still get normal CI from ci.yml; they just don't get a live preview. + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# Least-privilege default; the deploy/teardown jobs raise pull-requests:write for their PR comment. +permissions: + contents: read + +jobs: + deploy: + if: >- + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + # Build + single-Worker deploy is a few minutes; cap well under the 360-min default so a wedged + # wrangler or stalled network fails fast instead of holding a runner for hours. + timeout-minutes: 10 + environment: preview + permissions: + contents: read + pull-requests: write + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Shared dev data: SIGMA_D1_ID is the dev database's id (set on the `preview` Environment). + SIGMA_D1_ID: ${{ secrets.SIGMA_D1_ID }} + SIGMA_D1_NAME: ${{ vars.SIGMA_D1_NAME }} + SIGMA_CSV_CACHE_NAME: ${{ vars.SIGMA_CSV_CACHE_NAME }} + # Assistant resources are shared with dev (one Vectorize index + one reports bucket, no per-preview + # seed). The committed defaults name the PRODUCTION resources, which do not exist on the dev + # account — set these on the `preview` Environment or the REPORTS/VECTORIZE bindings fail to resolve. + SIGMA_REPORTS_NAME: ${{ vars.SIGMA_REPORTS_NAME }} + SIGMA_VECTORIZE_NAME: ${{ vars.SIGMA_VECTORIZE_NAME }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + + # Before the install, so a bad owner/prefix fails in seconds rather than after a full build. + # Exported through $GITHUB_ENV rather than interpolated in YAML on purpose: `github.repository_owner` + # preserves the login's original case and a Worker name must be lowercase. PREVIEW_WORKER_PREFIX is + # passed at STEP scope only — the resolved value the script writes is what every later step reads, + # so deploy, teardown and the reaper can never disagree about the name. + - name: Compute preview worker name + env: + PREVIEW_WORKER_PREFIX: ${{ vars.PREVIEW_WORKER_PREFIX }} + run: node scripts/preview-name.mjs --pr "${{ github.event.pull_request.number }}" + + - run: pnpm install --frozen-lockfile + + - name: Guard — require preview credentials + run: | + missing=() + [ -z "${CLOUDFLARE_API_TOKEN}" ] && missing+=("CLOUDFLARE_API_TOKEN") + [ -z "${CLOUDFLARE_ACCOUNT_ID}" ] && missing+=("CLOUDFLARE_ACCOUNT_ID") + [ -z "${SIGMA_D1_ID}" ] && missing+=("SIGMA_D1_ID") + [ -z "${SIGMA_D1_NAME}" ] && missing+=("SIGMA_D1_NAME (var)") + [ -z "${SIGMA_CSV_CACHE_NAME}" ] && missing+=("SIGMA_CSV_CACHE_NAME (var)") + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::preview Environment is missing: ${missing[*]} — see docs/dev-environments-setup.md." + exit 1 + fi + + # Defense in depth: SIGMA_WEB_NAME is derived and can't collide, but the shared-data names come from + # the `preview` Environment. Refuse the production D1/R2 names so a misconfigured environment can't + # point previews at the live `sigma` data. Previews are meant to read the dev D1/R2 only. + - name: Guard — preview must not point at production data + run: | + fail=0 + check() { # $1=value $2=prod default $3=var name + if [ "$1" = "$2" ]; then + echo "::error::$3 must not be the production value '$2' for a preview — set it to the dev resource on the 'preview' Environment (see docs/dev-environments.md)." + fail=1 + fi + } + check "$SIGMA_D1_NAME" sigma SIGMA_D1_NAME + check "$SIGMA_CSV_CACHE_NAME" sigma-csv-cache SIGMA_CSV_CACHE_NAME + check "$SIGMA_REPORTS_NAME" sigma-reports SIGMA_REPORTS_NAME + check "$SIGMA_VECTORIZE_NAME" sigma-assistant SIGMA_VECTORIZE_NAME + [ "$fail" = 0 ] || exit 1 + + - name: Deploy preview worker + id: deploy + run: | + set -o pipefail + # `run deploy` builds, renders wrangler.deploy.json with SIGMA_WEB_NAME/SIGMA_D1_*, and deploys. + pnpm --filter @sigma/web run deploy 2>&1 | tee deploy.log + # The live URL includes the account subdomain (..workers.dev), which + # can't be reconstructed from SIGMA_WEB_NAME alone — so parse it from wrangler's output. If + # parsing fails, leave it empty; the comment step says so rather than posting a dead link. + url="$(grep -Eo 'https://[a-zA-Z0-9.-]+\.workers\.dev' deploy.log | head -n1)" + if [ -z "$url" ]; then + echo "::warning::Could not parse the preview URL from wrangler output; see the deploy log above." + fi + echo "url=$url" >> "$GITHUB_OUTPUT" + + # The BgGPT key is a per-worker-script secret, so a freshly-deployed preview worker does NOT inherit + # it from the dev worker — set it here so /assistant/chat works in the preview (the assistant binds + # the shared dev Vectorize/R2 by name, but the key must be attached per script). Runs AFTER the + # deploy: the worker must exist before a secret can be put on it. Optional + fails OPEN: if + # BGGPT_API_KEY is unset on the `preview` Environment, skip it — the assistant degrades to a + # controlled 503 (UI-only preview), which must NOT fail an otherwise-good preview deploy. + - name: Provision assistant key (BGGPT_API_KEY) on the preview worker + env: + BGGPT_API_KEY: ${{ secrets.BGGPT_API_KEY }} + run: | + if [ -z "${BGGPT_API_KEY}" ]; then + echo "::notice::BGGPT_API_KEY is not set on the 'preview' Environment — the assistant will 503 in this preview (UI only). See docs/dev-environments.md." + exit 0 + fi + printf '%s' "${BGGPT_API_KEY}" | pnpm --filter @sigma/web exec wrangler secret put BGGPT_API_KEY --name "$SIGMA_WEB_NAME" + + - name: Comment preview URL on PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PREVIEW_URL: ${{ steps.deploy.outputs.url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const url = process.env.PREVIEW_URL; + const worker = process.env.SIGMA_WEB_NAME; + const marker = ''; + const headline = url + ? `🌐 **Preview deployed** → ${url}` + : `🌐 **Preview deployed**, but the URL couldn't be auto-detected — see the "Deploy preview worker" step in the [workflow run](${process.env.RUN_URL}).`; + const body = `${marker}\n${headline}\n\n` + + `Worker \`${worker}\` · shares the **dev** D1 (read-only). Updates on each push; removed when this PR closes.`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body, + }); + } + + # Render the live preview location into the run's Job Summary (the box on the Actions run page), + # so the URL is discoverable straight from the workflow run — not only the PR comment. + - name: Preview environment summary + env: + PREVIEW_URL: ${{ steps.deploy.outputs.url }} + run: | + { + echo "## 🌐 Preview environment" + echo + if [ -n "$PREVIEW_URL" ]; then + echo "✅ **Deployed** → [$PREVIEW_URL]($PREVIEW_URL)" + else + echo "⚠️ Deployed, but the URL couldn't be auto-detected — see the **Deploy preview worker** step log." + fi + echo + echo "| | |" + echo "|---|---|" + echo "| Worker | \`$SIGMA_WEB_NAME\` (web/SSR only — no per-PR ETL) |" + echo "| Data | shares the **dev** D1 \`$SIGMA_D1_NAME\` + R2 \`$SIGMA_CSV_CACHE_NAME\` (read-only) |" + echo "| Lifecycle | redeployed on each push · torn down when this PR closes |" + } >> "$GITHUB_STEP_SUMMARY" + + teardown: + if: >- + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + # Teardown must run to completion — never let a racing deploy (same PR, shared workflow-level group) + # cancel it and orphan the preview worker. Own group, no cancellation; the deletion is idempotent. + concurrency: + group: preview-teardown-${{ github.event.pull_request.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + # Just an idempotent worker delete — minutes at most. Cap low vs the 360-min default. + timeout-minutes: 5 + environment: preview + permissions: + contents: read + pull-requests: write + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + + # Same derivation as the deploy job — one module, so a preview can never be deployed under a name + # the teardown allowlist won't match. Exports both PREVIEW_WORKER_PREFIX and SIGMA_WEB_NAME; + # teardown-remote.mjs reads the prefix to build its `-` allowlist. + - name: Compute preview worker name + env: + PREVIEW_WORKER_PREFIX: ${{ vars.PREVIEW_WORKER_PREFIX }} + run: node scripts/preview-name.mjs --pr "${{ github.event.pull_request.number }}" + + - run: pnpm install --frozen-lockfile + + # Deletes only this repository's per-PR worker. The shared dev D1/R2 are never touched, and neither + # is another fork's preview — teardown-remote.mjs refuses protected names and anything outside + # `-`. Run through `pnpm --filter @sigma/web exec` so the web package's pinned + # wrangler is on PATH; the script shells out to it. + - name: Delete preview worker + run: pnpm --filter @sigma/web exec node ../../scripts/teardown-remote.mjs --name "$SIGMA_WEB_NAME" + + - name: Teardown summary + run: | + { + echo "## 🧹 Preview environment" + echo + echo "Worker \`$SIGMA_WEB_NAME\` was torn down (PR closed). The shared dev D1/R2 are untouched." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Note teardown on PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const marker = ''; + const body = `${marker}\n🧹 Preview \`${process.env.SIGMA_WEB_NAME}\` torn down (PR closed).`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, + }); + } diff --git a/docs/README.md b/docs/README.md index 568550ad..dc322da1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ - [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI. - [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор. - [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment. +- [`dev-environments.md`](dev-environments.md) — ephemeral preview среда за всеки PR: жизнен цикъл, защитни бариери при триене и защо имената носят притежателя на репозиторя. +- [`dev-environments-setup.md`](dev-environments-setup.md) — runbook за `preview` средата: secrets, variables и проверка. - [`api.md`](api.md) — публичните данни и машинно четими endpoint-и (CSV/JSON/sitemap), query грамата на филтрите и лицензът — за разработчици, които строят върху данните. - [`accessibility.md`](accessibility.md) — достъпност (WCAG 2.1 AA / EN 301 549): какво покрива платформата и наблюденията за вградената приставка за достъпност. - [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на разговорния аналитичен слой над СИГМА (BgGPT, текст и глас). diff --git a/docs/dev-environments-setup.md b/docs/dev-environments-setup.md new file mode 100644 index 00000000..29853448 --- /dev/null +++ b/docs/dev-environments-setup.md @@ -0,0 +1,126 @@ +# Настройка на `preview` средата + +Copy-paste runbook за еднократното конфигуриране на ephemeral PR preview-тата. За модела и +жизнения цикъл виж [`dev-environments.md`](dev-environments.md); за production/staging — +[`deploy.md`](deploy.md). + +## 0. Предпоставки + +Preview-тата ползват **вече провизионираните** dev ресурси в споделения Cloudflare акаунт — този +репозиторий не създава нищо ново: + +| Ресурс | Име | +|---|---| +| Cloudflare акаунт | `b2abee0097d289c0762fd5b85a61353d` (Info@midt-crew.eu) | +| D1 | `sigma-dev` | +| R2 (CSV кеш) | `sigma-csv-cache-dev` | +| R2 (отчети на асистента) | `sigma-reports-dev` | +| Vectorize | `sigma-assistant-dev` | + +Нужен е **scoped API token** за този акаунт със следните минимални права: + +- Workers Scripts\:Edit — деплой и триене на preview worker-и, `wrangler secret put` +- D1\:Edit — binding-ът на базата +- Workers R2 Storage\:Edit — binding-ите на кофите +- Account Settings\:Read — резолвва акаунта + +> Токенът дава право да се **трие** worker в споделен акаунт. Бариерите в +> `scripts/teardown-remote.mjs` са това, което ограничава триенето до preview-тата на *този* +> репозиторий — виж [`dev-environments.md`](dev-environments.md) §3. + +## 1. GitHub Environment `preview` + +*Settings → Environments → New environment → `preview`*. + +> **Без required reviewers.** Всеки preview деплой минава през тази среда — одобрение би блокирало +> всеки push към всеки отворен PR. + +```bash +gh api --method PUT repos/ydimitrof/sigma/environments/preview +``` + +### Secrets + +| Secret | Стойност | +|---|---| +| `CLOUDFLARE_API_TOKEN` | scoped token от раздел 0 | +| `CLOUDFLARE_ACCOUNT_ID` | `b2abee0097d289c0762fd5b85a61353d` | +| `SIGMA_D1_ID` | `database_id` на `sigma-dev` | +| `BGGPT_API_KEY` | **опционален** — виж по-долу | + +```bash +gh secret set CLOUDFLARE_API_TOKEN --env preview --repo ydimitrof/sigma +gh secret set CLOUDFLARE_ACCOUNT_ID --env preview --repo ydimitrof/sigma +gh secret set SIGMA_D1_ID --env preview --repo ydimitrof/sigma +``` + +`BGGPT_API_KEY` е ключът на доставчика на AI асистента. Secret-ите са **per-worker-script**, затова +ефемерният worker НЕ наследява ключа — `preview.yml` го `wrangler secret put`-ва след деплоя. Ако не е +зададен, деплоят минава, но `/assistant/chat` връща контролирано **503** (preview само с UI). + +### Variables + +| Variable | Стойност | +|---|---| +| `SIGMA_D1_NAME` | `sigma-dev` | +| `SIGMA_CSV_CACHE_NAME` | `sigma-csv-cache-dev` | +| `SIGMA_REPORTS_NAME` | `sigma-reports-dev` | +| `SIGMA_VECTORIZE_NAME` | `sigma-assistant-dev` | + +```bash +gh variable set SIGMA_D1_NAME --env preview --repo ydimitrof/sigma --body sigma-dev +gh variable set SIGMA_CSV_CACHE_NAME --env preview --repo ydimitrof/sigma --body sigma-csv-cache-dev +gh variable set SIGMA_REPORTS_NAME --env preview --repo ydimitrof/sigma --body sigma-reports-dev +gh variable set SIGMA_VECTORIZE_NAME --env preview --repo ydimitrof/sigma --body sigma-assistant-dev +``` + +> **Не задавайте `SIGMA_WEB_NAME`.** Workflow-ът го изчислява от притежателя на репозиторя и номера +> на PR-а (`scripts/preview-name.mjs`). Ръчна стойност би дала едно и също име за всеки PR — тоест +> всеки нов preview би презаписвал предишния. + +> Guard-ът в `preview.yml` отказва деплой, ако някое от `SIGMA_D1_NAME` / `SIGMA_CSV_CACHE_NAME` / +> `SIGMA_REPORTS_NAME` / `SIGMA_VECTORIZE_NAME` съвпада с production default-а — за да не може +> сгрешена среда да насочи preview-тата към живите данни. + +### Опционално: `PREVIEW_WORKER_PREFIX` + +Repository-level variable (не в средата). Задава се само за **по-къс URL**; без нея името се извежда +автоматично и е вече уникално за репозиторя. Стойността замества **целия** изведен префикс — worker-ът +е `<префикс>-<номер на PR>`. + +```bash +gh variable set PREVIEW_WORKER_PREFIX --repo ydimitrof/sigma --body sigma-yo-pr # → sigma-yo-pr-12 +``` + +Стойността трябва да е валиден DNS label (малки букви, цифри, вътрешни тирета) — иначе workflow-ът +спира с грешка, вместо да я пренапише мълчаливо. + +## 2. Проверка + +Без Cloudflare достъп — само изведеното име и предпазните бариери: + +```bash +GITHUB_REPOSITORY_OWNER=ydimitrof node scripts/preview-name.mjs --pr 12 +# → PREVIEW_WORKER_PREFIX=sigma-ydimitrof-pr +# → SIGMA_WEB_NAME=sigma-ydimitrof-pr-12 + +# Чуждите preview-та и дълготрайните worker-и трябва да бъдат ОТКАЗАНИ: +GITHUB_REPOSITORY_OWNER=ydimitrof node scripts/teardown-remote.mjs --name sigma-pr-12 --dry-run +GITHUB_REPOSITORY_OWNER=ydimitrof node scripts/teardown-remote.mjs --name sigma --dry-run + +pnpm test:scripts +``` + +С достъп до акаунта — dry run на reaper-а (нищо не се трие без `--apply`): + +```bash +CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=b2abee0097d289c0762fd5b85a61353d \ +GITHUB_REPOSITORY_OWNER=ydimitrof \ + node scripts/reap-previews.mjs +``` + +Изходът отпечатва колко worker-а има в акаунта и колко от тях съвпадат с `sigma-ydimitrof-pr-<номер>`. +Второто число трябва да включва **само** preview-тата на този репозиторий. + +Накрая: отвори тестов PR и провери, че се появява коментар с URL, че повторен push обновява същия +коментар (а не добавя нов), и че при затваряне worker-ът се трие. diff --git a/docs/dev-environments.md b/docs/dev-environments.md new file mode 100644 index 00000000..10ff307f --- /dev/null +++ b/docs/dev-environments.md @@ -0,0 +1,134 @@ +# Ephemeral PR preview среди + +Този документ описва как работят **ephemeral preview средите за всеки PR**. Допълва +[`deploy.md`](deploy.md) (production / staging) — прочетете първо него за модела на средите и за +rendering механизма (`scripts/wrangler-render.mjs`). + +Целта: **лесно да виждаме всеки PR на живо**, без да преправяме код и без да зареждаме данни наново +за всяка среда. + +## Накратко + +| | staging / production | PR preview (ephemeral) | +|---|---|---| +| Кога | merge в `main` / таг `v*` | автоматично, за всеки отворен PR | +| Worker-и | `sigma` + `sigma-etl` (и `-stage`) | само `sigma--pr-<номер>` (web; **без ETL**) | +| URL | `sigma.midt.bg` / `sigma-stage..workers.dev` | `sigma--pr-<номер>..workers.dev` | +| D1 | собствена база на средата | **споделя** dev базата (read-only от worker-а) | +| Данни | пълен корпус; cron-ът поддържа свежи | наследени от dev — без зареждане per-PR | +| Workflow | `.github/workflows/deploy.yml` | `.github/workflows/preview.yml` | +| GitHub Environment | `staging` / `production` | `preview` | +| Cloudflare Access | да | **не** (`*.workers.dev` е незащитен) | +| Премахване | дълготрайни (не се трият) | при затваряне на PR-а + reaper след 5 дни | + +Защо preview-тата споделят dev базата: D1 няма евтин clone/snapshot, а пълно зареждане отнема ~20 мин +и ~1.4 GB. Затова за PR преглед е безсмислено да зареждаме данни наново — web worker-ът само **чете**, +така че всички preview-та сочат към **същата** dev D1 (`SIGMA_D1_ID` на `preview` средата = id-то на +dev базата). ETL worker-ът **пише** в D1 и е cron-only — затова **не** пускаме негово per-PR копие. + +--- + +## 1. Имената са уникални за всеки репозиторий + +Този репозиторий (`ydimitrof/sigma`), `lyubomir-bozhinov/sigma` и upstream `midt-bg/sigma` са +fork-ове на един проект и деплойват preview-тата си в **един и същ Cloudflare акаунт**. Схема от вида +`sigma-pr-<номер>` затова се сблъсква: PR #12 тук и PR #12 в другия fork дават **едно и също име** — +вторият деплой мълчаливо презаписва първия, а reaper-ът на всеки репозиторий трие worker-ите на +другия. + +Решението: името носи **притежателя на репозиторя**, изведен автоматично, а не конфигуриран. + +``` +префикс = "sigma-" + + "-pr" +worker = <префикс>-<номер на PR> + +ydimitrof/sigma PR #12 → sigma-ydimitrof-pr-12 +lyubomir-bozhinov/sigma PR #12 → sigma-lyubomir-bozhinov-pr-12 +midt-bg/sigma PR #12 → sigma-midt-bg-pr-12 +``` + +`scripts/preview-name.mjs` е **единственият** източник на това име. Deploy-ът, teardown-ът и reaper-ът +го внасят оттам, вместо всеки да носи собствено копие на шаблона — три копия на един regex е точно +начинът, по който преименувано preview остава завинаги неизтрито. + +Три следствия, които правят схемата безопасна: + +- **Няма какво да се забрави.** Изведено, не конфигурирано: променлива с fallback към общ default + връща същия сблъсък при първия, който забрави да я зададе. +- **Reaper-ът е сляп за чуждите preview-та.** `scripts/reap-previews.mjs` изброява **всички** worker-и + в акаунта, но обработва само тези, които съвпадат с `<префикс>-<цифри>` за *този* репозиторий. +- **Заблуден worker се разпознава.** По името се вижда от кой fork е дошъл. + +`PREVIEW_WORKER_PREFIX` (GitHub *variable*) остава като **изричен override** за по-къс URL. Ако е +зададен, той се валидира, а не се пренаписва мълчаливо — деплой и teardown трябва да четат едно и също. + +> Ограничението за дължина е DNS label-ът в `<име>..workers.dev` — 63 символа. GitHub +> login-ите са най-много 39 символа, така че изведеното име стига до 54 в най-лошия случай. +> `previewWorkerName` проверява това и спира деплоя, вместо да произведе недостижим хост. + +--- + +## 2. Жизнен цикъл + +`.github/workflows/preview.yml` се задейства на `pull_request` (`opened`, `synchronize`, `reopened`, +`closed`). + +- **При отваряне/push** → деплойва `sigma--pr-<номер>` (само web), сочещ към споделената dev + D1, и публикува/обновява коментар в PR-а с URL-а. +- **При затваряне** → трие worker-а със `scripts/teardown-remote.mjs` (споделените dev D1/R2 **не** се + пипат — скриптът отказва защитените дълготрайни имена). +- **Авто-почистване (reaper)** → `.github/workflows/preview-reap.yml` се пуска по график (03:17 UTC) и + трие всеки preview, стоял **5 дни** без нов деплой (`PREVIEW_MAX_AGE_DAYS`). Хваща два случая, които + teardown-ът при затваряне изпуска: idle, но още отворени PR preview-та, и orphan-и, чийто teardown е + пропаднал. Нов push към PR-а ре-деплойва preview-то. + +Поведение: + +- **Само същият репозиторий.** PR-и от fork нямат достъп до secrets, затова preview job-овете се + пропускат за fork-ове (fork PR-ите пак минават обикновеното CI от `ci.yml`). +- **Concurrency** per PR с `cancel-in-progress` — бърза поредица от push-ове не трупа деплои. Teardown-ът + е в **собствена** група без cancel: състезаващ се деплой не бива да го отмени и да остави orphan. +- **Без ETL, без данни per-PR** — preview-то показва същите данни като dev. +- **Без миграции.** `deploy.yml` прилага схемни промени към staging/production; preview потокът не + изпълнява миграции — виж [раздел 4](#4-когато-pr-ът-пипа-миграциисхема). + +## 3. Защитни бариери при триене + +`scripts/teardown-remote.mjs` е единственият път, по който CI трие worker. Три бариери: + +1. **Allowlist** — трие се само име, съвпадащо с `<префикс>-<цифри>` за този репозиторий. Задължителните + завършващи цифри са това, което пази `sigma`, `sigma-etl` и подобните от съвпадение при какъвто и да + е префикс. +2. **Denylist** — изричен списък от дълготрайни имена (`sigma`, `sigma-etl`, `sigma-stage`, + `sigma-etl-stage`, `sigma-dev`, `sigma-etl-dev`), отхвърляни независимо от allowlist-а. +3. **Без префикс — без работа.** Ако нито `GITHUB_REPOSITORY_OWNER`, нито `PREVIEW_WORKER_PREFIX` са + налични, скриптът спира с грешка, вместо да предположи общ default. + +Провал на `wrangler delete`, различен от „вече не съществува" (код 10007), **не** се преглъща — иначе +изтекъл worker остава незабелязан. + +## 4. Когато PR-ът пипа миграции/схема + +Preview-то споделя dev базата, затова PR с **нова миграция** не бива да я прилага върху споделената +dev D1 от preview потока. За такива PR-и: прегледай схемната промяна първо локално (`pnpm setup` + +миграции), или провизионирай отделна еднократна D1. Автоматизиран per-PR изолиран D1 (с lightweight +seed) е възможно разширение — не е включено тук, за да останат preview-тата без разходи. + +--- + +## Ограничения и разходи + +Уникалните имена изолират **worker-а**. Следното остава споделено — важно е да се знае: + +- **D1 няма clone/fork/snapshot.** Споделянето на dev базата е умишлено — алтернативата (пълен корпус + per PR, ~20 мин) е скъпа и бавна. Същото важи за R2 кофите и Vectorize индекса на асистента. +- **Rate-limit namespace-ите** (`1001`–`1005` в `apps/web/wrangler.jsonc`) са **account-scoped целочислени + id-та** и се споделят от всяко preview на всеки fork в акаунта — тоест квотите са общи. Приемливо за + preview; при нужда от изолация се параметризират. +- **Cloudflare Access** се конфигурира извън кода (Zero Trust dashboard) и **не** се прилага за + `*.workers.dev` preview URL-и — те са публични; не пускайте чувствително съдържание там. +- **Изисква Workers Paid plan** (Workflows + размера на D1). +- **Почистване**: при затваряне на PR worker-ът се трие автоматично, а reaper-ът трие idle preview-та + след 5 дни без деплой (`PREVIEW_MAX_AGE_DAYS`). + +Настройката на средата е в [`dev-environments-setup.md`](dev-environments-setup.md). diff --git a/package.json b/package.json index 17b09cdd..ae50cb48 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "check:docs:test": "node --test scripts/check-docs.test.mjs", "check:coverage": "node scripts/check-coverage.mjs", "check:coverage:test": "node --test scripts/check-coverage.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs", "format": "prettier --write .", "setup": "node scripts/setup.mjs", "import": "node scripts/import.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12dc4203..e10c2418 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: ws: ^8.21.0 vite@7: ^7.3.5 vite@8: ^8.0.16 - undici: ^7.28.0 + undici: ^7.29.0 '@babel/core': ^7.29.6 postcss: ^8.5.18 valibot: ^1.4.2 @@ -1818,8 +1818,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -3162,7 +3162,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 7.28.0 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -3256,7 +3256,7 @@ snapshots: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.28.0 + undici: 7.29.0 workerd: 1.20260520.1 ws: 8.21.0 youch: 4.1.0-beta.10 @@ -3495,7 +3495,7 @@ snapshots: undici-types@7.24.6: {} - undici@7.28.0: {} + undici@7.29.0: {} unenv@2.0.0-rc.24: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1899cd6a..0e669865 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,10 +17,16 @@ overrides: # (GHSA-vmh5-mc38-953g), WebSocket DoS via fragment-count bypass # (GHSA-vxpw-j846-p89q), cross-origin routing via SOCKS5 pool reuse # (GHSA-hm92-r4w5-c3mj). miniflare's local fetch only; never ships to the Worker. + # undici <7.29.0 — five more, published 2026-08-03, same dev-only paths (jsdom→vitest and + # wrangler→miniflare): cross-user disclosure + request smuggling + # (GHSA-4cwx-7wf7-3272, HIGH), downstream response desync + # (GHSA-8xcm-r25x-g524), cross-user disclosure via WebSocket + # (GHSA-jr45-8vmc-qm54), CRLF injection via blob-like body type + # (GHSA-m8rv-5g2x-5cg5), cookie-attribute injection (GHSA-v3r7-h72x-cjcm). ws: '^8.21.0' vite@7: '^7.3.5' vite@8: '^8.0.16' - undici: '^7.28.0' + undici: '^7.29.0' # @babel/core <7.29.6 — arbitrary file read via sourceMappingURL (GHSA-4x5r-pxfx-6jf8); # dev/build-time only (via @react-router/dev), never ships to the Worker. '@babel/core': '^7.29.6' diff --git a/scripts/preview-name.mjs b/scripts/preview-name.mjs new file mode 100644 index 00000000..6bd6b3f1 --- /dev/null +++ b/scripts/preview-name.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// Ephemeral preview naming — the SINGLE source of truth for what a per-PR preview worker is called. +// +// Every consumer must agree on the name, or a preview leaks: .github/workflows/preview.yml deploys it, +// the teardown job deletes it on PR close, and scripts/reap-previews.mjs deletes it after the max +// lifetime. Three independent copies of a regex is how a renamed preview ends up orphaned forever, so +// the pattern lives here and everything else imports it (see docs/dev-environments.md). +// +// The name carries the REPOSITORY OWNER, not just the PR number: +// +// ydimitrof/sigma PR #12 -> sigma-ydimitrof-pr-12 +// lyubomir-bozhinov/sigma PR #12 -> sigma-lyubomir-bozhinov-pr-12 +// +// This repo and the other forks of midt-bg/sigma deploy previews into ONE shared Cloudflare account. +// A bare `sigma-pr-` scheme collides across forks on the same PR number — the second deploy silently +// overwrites the first, and each repo's reaper deletes the other's workers. The owner segment makes +// collision structurally impossible and makes a stray worker self-identifying on the account. +// +// Derived by default rather than configured: an optional variable that falls back to a shared default +// reintroduces exactly the collision it was meant to prevent whenever someone forgets to set it. +// PREVIEW_WORKER_PREFIX remains as a deliberate override for shorter URLs. +// +// usage: +// node scripts/preview-name.mjs --pr 12 (writes PREVIEW_WORKER_PREFIX + SIGMA_WEB_NAME) +// node scripts/preview-name.mjs (prefix only — for the reaper) + +import { appendFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// The application segment every preview name starts with. Kept separate from the owner so the shape +// stays `--pr-` and reads left-to-right from most to least stable. +export const APP = 'sigma'; + +// A worker name becomes a DNS label in `..workers.dev`, so it is bound by the 63-char +// label limit — not by any Cloudflare-specific cap. Worst case here is 6 + 39 (GitHub's login ceiling) +// + 4 + PR digits, which stays well inside it; assert anyway rather than trust the arithmetic. +export const MAX_LABEL_LENGTH = 63; + +// Lowercase alphanumerics and interior hyphens — the DNS label grammar the workers.dev host must satisfy. +const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +/** + * Fold a GitHub owner login into a DNS-safe segment: lowercase, anything outside `[a-z0-9-]` becomes a + * hyphen, runs of hyphens collapse, and leading/trailing hyphens are trimmed. GitHub logins are already + * `[A-Za-z0-9-]` with no leading/trailing/repeated hyphens, so in practice this only folds case — the + * rest guards the CLI being handed something hand-typed. Throws when nothing usable survives. + */ +export function sanitizeOwner(owner) { + const folded = String(owner ?? '') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + if (!folded) { + throw new Error( + `preview-name: cannot derive a preview prefix from owner "${owner ?? ''}" — set GITHUB_REPOSITORY_OWNER or pass --owner.`, + ); + } + return folded; +} + +/** + * The `--pr` prefix every preview worker in this repo shares. An `override` (the + * PREVIEW_WORKER_PREFIX variable) wins when set, but is validated rather than silently mangled — a + * prefix is a deliberate human choice, and quietly rewriting it would desync deploy from teardown. + */ +export function previewPrefix({ owner, override } = {}) { + const explicit = String(override ?? '').trim(); + if (explicit) { + if (!LABEL_RE.test(explicit)) { + throw new Error( + `preview-name: PREVIEW_WORKER_PREFIX "${explicit}" is not a valid DNS label (lowercase letters, digits and interior hyphens only).`, + ); + } + return explicit; + } + return `${APP}-${sanitizeOwner(owner)}-pr`; +} + +/** Read the prefix straight from the process environment — how teardown and the reaper resolve it. */ +export function previewPrefixFromEnv(env = process.env) { + return previewPrefix({ owner: env.GITHUB_REPOSITORY_OWNER, override: env.PREVIEW_WORKER_PREFIX }); +} + +/** + * `-`. Rejects a non-positive-integer PR number and any result that would not be a + * legal workers.dev host, so a bad name fails here rather than as an opaque Cloudflare API error. + */ +export function previewWorkerName(prefix, prNumber) { + const n = Number(prNumber); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`preview-name: "${prNumber}" is not a valid PR number.`); + } + const name = `${prefix}-${n}`; + if (name.length > MAX_LABEL_LENGTH) { + throw new Error( + `preview-name: "${name}" is ${name.length} chars — over the ${MAX_LABEL_LENGTH}-char DNS label limit for ..workers.dev. Shorten it with PREVIEW_WORKER_PREFIX.`, + ); + } + if (!LABEL_RE.test(name)) { + throw new Error(`preview-name: "${name}" is not a valid DNS label.`); + } + return name; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Matches ONLY ephemeral previews carrying this prefix. The mandatory trailing `-` is what keeps + * long-lived workers (`sigma`, `sigma-etl`, …) from ever matching, whatever the prefix — and the prefix + * itself is what keeps another fork's previews out of this repo's deletion allowlist. + */ +export function ephemeralPreviewRe(prefix) { + return new RegExp(`^${escapeRegExp(prefix)}-\\d+$`); +} + +/** The PR number a preview worker belongs to, or `null` when the name isn't ours. */ +export function previewPrNumber(name, prefix) { + if (typeof name !== 'string') return null; + const match = ephemeralPreviewRe(prefix).exec(name); + return match ? Number(name.slice(prefix.length + 1)) : null; +} + +function flag(args, name) { + const i = args.indexOf(`--${name}`); + if (i >= 0 && i + 1 < args.length) return args[i + 1]; + const inline = args.find((a) => a.startsWith(`--${name}=`)); + return inline ? inline.slice(inline.indexOf('=') + 1) : undefined; +} + +// Emitting through $GITHUB_ENV (rather than interpolating the name in YAML) is deliberate: +// `github.repository_owner` preserves the login's original case, and a Worker name must be lowercase. +function main(argv) { + const args = argv.slice(2); + const owner = flag(args, 'owner') || process.env.GITHUB_REPOSITORY_OWNER; + const pr = flag(args, 'pr'); + + let lines; + try { + const prefix = previewPrefix({ owner, override: process.env.PREVIEW_WORKER_PREFIX }); + lines = [`PREVIEW_WORKER_PREFIX=${prefix}`]; + if (pr !== undefined) lines.push(`SIGMA_WEB_NAME=${previewWorkerName(prefix, pr)}`); + } catch (err) { + console.error(err.message); + process.exit(2); + } + + for (const line of lines) console.log(line); + if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `${lines.join('\n')}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv); +} diff --git a/scripts/preview-name.test.mjs b/scripts/preview-name.test.mjs new file mode 100644 index 00000000..0a24f997 --- /dev/null +++ b/scripts/preview-name.test.mjs @@ -0,0 +1,169 @@ +// Unit tests for the ephemeral-preview naming rules. The property under test is not "the name looks +// right" but "two forks of midt-bg/sigma deploying the same PR number into one shared Cloudflare +// account can never produce the same worker name, and neither repo's cleanup can match the other's +// workers". Everything else here guards the edges that would break that property quietly. +// +// Run: node --test scripts/preview-name.test.mjs + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + APP, + MAX_LABEL_LENGTH, + sanitizeOwner, + previewPrefix, + previewPrefixFromEnv, + previewWorkerName, + ephemeralPreviewRe, + previewPrNumber, +} from './preview-name.mjs'; + +test('sanitizeOwner folds case — github.repository_owner keeps the login casing, worker names must be lowercase', () => { + assert.equal(sanitizeOwner('MidtBG'), 'midtbg'); + assert.equal(sanitizeOwner('ydimitrof'), 'ydimitrof'); + assert.equal(sanitizeOwner('lyubomir-bozhinov'), 'lyubomir-bozhinov'); +}); + +test('sanitizeOwner produces a DNS-safe segment from hand-typed input', () => { + assert.equal(sanitizeOwner('foo_bar'), 'foo-bar'); + assert.equal(sanitizeOwner('--foo--bar--'), 'foo-bar'); + assert.equal(sanitizeOwner('a b.c'), 'a-b-c'); +}); + +test('sanitizeOwner throws when nothing usable survives', () => { + for (const bad of ['', ' ', '---', '___', null, undefined]) { + assert.throws(() => sanitizeOwner(bad), /cannot derive a preview prefix/); + } +}); + +test('previewPrefix derives --pr with no configuration', () => { + assert.equal(previewPrefix({ owner: 'ydimitrof' }), 'sigma-ydimitrof-pr'); + assert.equal(previewPrefix({ owner: 'ydimitrof' }).startsWith(`${APP}-`), true); +}); + +// The whole point of the change: same PR number, different forks, different workers. +test('previewPrefix isolates the forks that share one Cloudflare account', () => { + const owners = ['ydimitrof', 'lyubomir-bozhinov', 'midt-bg']; + const names = owners.map((o) => previewWorkerName(previewPrefix({ owner: o }), 12)); + assert.deepEqual(names, [ + 'sigma-ydimitrof-pr-12', + 'sigma-lyubomir-bozhinov-pr-12', + 'sigma-midt-bg-pr-12', + ]); + assert.equal(new Set(names).size, owners.length); +}); + +test('previewPrefix honours an explicit PREVIEW_WORKER_PREFIX override', () => { + assert.equal(previewPrefix({ owner: 'ydimitrof', override: 'sigma-yo' }), 'sigma-yo'); + assert.equal(previewPrefix({ owner: 'ydimitrof', override: ' sigma-yo ' }), 'sigma-yo'); +}); + +test('previewPrefix ignores a blank override rather than emitting an empty prefix', () => { + assert.equal(previewPrefix({ owner: 'ydimitrof', override: ' ' }), 'sigma-ydimitrof-pr'); + assert.equal(previewPrefix({ owner: 'ydimitrof', override: '' }), 'sigma-ydimitrof-pr'); +}); + +// Mangling an override silently would desync the deploy name from the teardown allowlist. +test('previewPrefix rejects an invalid override instead of rewriting it', () => { + for (const bad of ['Sigma-Yo', 'sigma yo', 'sigma_yo', '-sigma', 'sigma-']) { + assert.throws( + () => previewPrefix({ owner: 'ydimitrof', override: bad }), + /not a valid DNS label/, + ); + } +}); + +test('previewPrefixFromEnv reads owner and override off an env object', () => { + assert.equal( + previewPrefixFromEnv({ GITHUB_REPOSITORY_OWNER: 'ydimitrof' }), + 'sigma-ydimitrof-pr', + ); + assert.equal( + previewPrefixFromEnv({ GITHUB_REPOSITORY_OWNER: 'ydimitrof', PREVIEW_WORKER_PREFIX: 'sig' }), + 'sig', + ); + assert.throws(() => previewPrefixFromEnv({}), /cannot derive a preview prefix/); +}); + +test('previewWorkerName appends the PR number', () => { + assert.equal(previewWorkerName('sigma-ydimitrof-pr', 12), 'sigma-ydimitrof-pr-12'); + assert.equal(previewWorkerName('sigma-ydimitrof-pr', '12'), 'sigma-ydimitrof-pr-12'); +}); + +test('previewWorkerName rejects a non-positive-integer PR number', () => { + for (const bad of [0, -1, 1.5, 'abc', '', null, undefined, '12abc']) { + assert.throws(() => previewWorkerName('sigma-ydimitrof-pr', bad), /not a valid PR number/); + } +}); + +// The ceiling is the workers.dev DNS label, not a Cloudflare limit. GitHub logins cap at 39 chars, +// so the derived form always fits; assert both the headroom and the guard. +test('previewWorkerName stays inside the DNS label limit for the longest possible login', () => { + const maxLogin = 'a'.repeat(39); + const name = previewWorkerName(previewPrefix({ owner: maxLogin }), 99999); + assert.equal(name.length, 54); + assert.ok(name.length <= MAX_LABEL_LENGTH); +}); + +test('previewWorkerName throws rather than emitting an unreachable host', () => { + assert.throws( + () => previewWorkerName(`sigma-${'a'.repeat(60)}-pr`, 1), + /over the 63-char DNS label limit/, + ); +}); + +test('ephemeralPreviewRe matches only -', () => { + const re = ephemeralPreviewRe('sigma-ydimitrof-pr'); + assert.ok(re.test('sigma-ydimitrof-pr-12')); + assert.ok(re.test('sigma-ydimitrof-pr-1')); + assert.equal(re.test('sigma-ydimitrof-pr'), false); + assert.equal(re.test('sigma-ydimitrof-pr-'), false); + assert.equal(re.test('sigma-ydimitrof-pr-12a'), false); + assert.equal(re.test('sigma-ydimitrof-pr-12-13'), false); + assert.equal(re.test('xsigma-ydimitrof-pr-12'), false); +}); + +// The trailing - is the barrier that keeps long-lived workers out of any deletion allowlist. +test('ephemeralPreviewRe never matches a long-lived worker', () => { + const re = ephemeralPreviewRe('sigma-ydimitrof-pr'); + for (const name of ['sigma', 'sigma-etl', 'sigma-stage', 'sigma-etl-stage', 'sigma-dev']) { + assert.equal(re.test(name), false); + } +}); + +// The safety property: this repo's cleanup must be blind to the other forks' previews. +test('ephemeralPreviewRe does not match another fork’s previews on the shared account', () => { + const mine = ephemeralPreviewRe(previewPrefix({ owner: 'ydimitrof' })); + assert.equal(mine.test('sigma-pr-12'), false); + assert.equal(mine.test('sigma-lyubomir-bozhinov-pr-12'), false); + assert.equal(mine.test('sigma-midt-bg-pr-12'), false); + + const theirs = ephemeralPreviewRe(previewPrefix({ owner: 'lyubomir-bozhinov' })); + assert.equal(theirs.test('sigma-ydimitrof-pr-12'), false); +}); + +test('ephemeralPreviewRe escapes regex metacharacters in the prefix', () => { + const re = ephemeralPreviewRe('sigma.pr'); + assert.ok(re.test('sigma.pr-12')); + assert.equal(re.test('sigmaxpr-12'), false); +}); + +test('previewPrNumber recovers the PR number for our previews only', () => { + assert.equal(previewPrNumber('sigma-ydimitrof-pr-12', 'sigma-ydimitrof-pr'), 12); + assert.equal(previewPrNumber('sigma-ydimitrof-pr-4071', 'sigma-ydimitrof-pr'), 4071); + assert.equal(previewPrNumber('sigma-pr-12', 'sigma-ydimitrof-pr'), null); + assert.equal(previewPrNumber('sigma', 'sigma-ydimitrof-pr'), null); + assert.equal(previewPrNumber(null, 'sigma-ydimitrof-pr'), null); + assert.equal(previewPrNumber(12, 'sigma-ydimitrof-pr'), null); +}); + +// The reaper's PR-comment step derives the issue number from the worker name; a hardcoded +// /^sigma-pr-(\d+)$/ silently stops matching under any other prefix (workers deleted, PR never told). +test('previewPrNumber round-trips whatever previewWorkerName produced', () => { + for (const owner of ['ydimitrof', 'lyubomir-bozhinov', 'midt-bg']) { + const prefix = previewPrefix({ owner }); + assert.equal(previewPrNumber(previewWorkerName(prefix, 987), prefix), 987); + } + const override = previewPrefix({ owner: 'ydimitrof', override: 'sigma-yo' }); + assert.equal(previewPrNumber(previewWorkerName(override, 5), override), 5); +}); diff --git a/scripts/reap-previews.mjs b/scripts/reap-previews.mjs new file mode 100644 index 00000000..12dd03fd --- /dev/null +++ b/scripts/reap-previews.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// Reap ephemeral preview workers that have outlived the preview max-lifetime (default 5 days). +// +// Workers have no "stop/start" — a deployed Worker costs nothing while idle, so the lifecycle is: +// start = preview.yml deploys - on each push to an open same-repo PR +// stop = this reaper DELETES - once it has gone PREVIEW_MAX_AGE_DAYS without a redeploy +// re-start = the next push to that PR redeploys it (preview.yml on `synchronize`) +// +// It catches two things merge/close teardown (preview.yml) does not: +// 1. idle-but-open PR previews older than the max age, and +// 2. orphans whose PR already closed but whose teardown step failed. +// +// SCOPE: the Cloudflare account is SHARED with the other forks of midt-bg/sigma, and the listing below +// returns every worker on it. Only workers matching this repository's owner-derived prefix are ever +// considered — see scripts/preview-name.mjs and docs/dev-environments.md. Without that, this repo's +// scheduled reaper would delete another fork's live previews. +// +// Dry-run by default; pass --apply to actually delete. Scheduled by .github/workflows/preview-reap.yml. +// +// env: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID (required); GITHUB_REPOSITORY_OWNER or +// PREVIEW_WORKER_PREFIX (required, for the prefix); PREVIEW_MAX_AGE_DAYS (optional, default 5) +import { appendFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { previewPrNumber, previewPrefixFromEnv } from './preview-name.mjs'; +import { deleteWorker, isEphemeralPreviewName } from './teardown-remote.mjs'; + +const CF_API = 'https://api.cloudflare.com/client/v4'; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Pure: pick the ephemeral preview workers older than maxAgeDays. Unknown/unparseable `modified_on` +// is left alone rather than risk reaping a worker whose age we can't establish. +export function selectStale(scripts, { maxAgeDays, nowMs, prefix }) { + const cutoff = nowMs - maxAgeDays * DAY_MS; + const stale = []; + for (const s of scripts) { + if (!isEphemeralPreviewName(s?.id, prefix)) continue; + const modifiedMs = Date.parse(s.modified_on); + if (!Number.isFinite(modifiedMs)) continue; + if (modifiedMs < cutoff) { + stale.push({ name: s.id, modifiedOn: s.modified_on, ageDays: (nowMs - modifiedMs) / DAY_MS }); + } + } + return stale; +} + +// Bound the page walk so a misbehaving API can never hang the scheduled reaper. +const MAX_PAGES = 1000; + +export async function listWorkerScripts({ accountId, token, fetchImpl = fetch }) { + // The CF API may paginate workers/scripts (~100 per page). Walk every page via the cursor, or a + // busy account silently hides older preview workers from the reaper — leaking them forever. + // Guard against an API that returns a stable/repeating cursor (or ignores the param): bail on a + // cursor we've already seen and cap total pages, so the loop always terminates. + const scripts = []; + const seen = new Set(); + let cursor = ''; + for (let page = 0; page < MAX_PAGES; page += 1) { + const url = `${CF_API}/accounts/${accountId}/workers/scripts${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`; + const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}` } }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.success === false) { + throw new Error( + `Cloudflare API list scripts failed (${res.status}): ${JSON.stringify(body.errors ?? body)}`, + ); + } + scripts.push(...(body.result ?? [])); + cursor = body.result_info?.cursor ?? ''; + if (!cursor || seen.has(cursor)) break; + seen.add(cursor); + } + return scripts; +} + +// Delete each stale preview when `apply`; dry-run just logs. Returns the names actually reaped and a +// hard-failure count. `del` is injected so the apply loop is unit-testable without touching wrangler. +export function reapStale( + stale, + { apply, del = deleteWorker, log = console.log, errLog = console.error } = {}, +) { + const reaped = []; + let hardFailures = 0; + for (const s of stale) { + log(`==> reaping ${s.name} (age ${s.ageDays.toFixed(1)}d, last deploy ${s.modifiedOn})`); + if (!apply) continue; + try { + const result = del(s.name); + // Only an actual delete counts as reaped. 'already-gone' (the worker vanished between list and + // delete) must not trigger a misleading "reaped after Nd" comment on its PR. + if (result === 'deleted') reaped.push(s.name); + } catch (err) { + hardFailures += 1; + errLog(`!! failed to reap ${s.name}: ${err.message}`); + } + } + return { reaped, hardFailures }; +} + +// Pair each reaped worker with the PR it belongs to, so the workflow's comment step never has to +// re-derive the name pattern. Deriving it there is how a renamed preview gets silently deleted with +// its PR never notified — the pattern lives in preview-name.mjs and nowhere else. +export function reapedPullRequests(reaped, prefix) { + const pairs = []; + for (const worker of reaped) { + const pr = previewPrNumber(worker, prefix); + if (pr !== null) pairs.push({ worker, pr }); + } + return pairs; +} + +function arg(args, name) { + const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`)); + if (!hit) return undefined; + const eq = hit.indexOf('='); + return eq === -1 ? true : hit.slice(eq + 1); +} + +async function main(argv) { + const args = argv.slice(2); + const apply = args.includes('--apply'); + // `arg` returns boolean `true` for a bare `--max-age-days` (no `=value`); reject it rather than + // letting Number(true) silently coerce to 1 day. + const rawMaxAge = process.env.PREVIEW_MAX_AGE_DAYS || arg(args, 'max-age-days') || 5; + if (rawMaxAge === true) { + console.error('reap-previews: --max-age-days requires a value, e.g. --max-age-days=5.'); + process.exit(2); + } + const maxAgeDays = Number(rawMaxAge); + if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) { + console.error(`reap-previews: invalid max age "${maxAgeDays}" days.`); + process.exit(2); + } + + const token = process.env.CLOUDFLARE_API_TOKEN; + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + if (!token || !accountId) { + console.error('reap-previews: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are required.'); + process.exit(2); + } + + let prefix; + try { + prefix = previewPrefixFromEnv(); + } catch (err) { + console.error(err.message); + process.exit(2); + } + + const scripts = await listWorkerScripts({ accountId, token }); + const previews = scripts.filter((s) => isEphemeralPreviewName(s?.id, prefix)); + const stale = selectStale(scripts, { maxAgeDays, nowMs: Date.now(), prefix }); + console.log( + `==> ${scripts.length} worker(s) on the account; ${previews.length} match "${prefix}-"; ` + + `${stale.length} older than ${maxAgeDays}d${apply ? '' : ' (dry run — pass --apply to delete)'}`, + ); + + const { reaped, hardFailures } = reapStale(stale, { + apply, + del: (name) => deleteWorker(name, { prefix }), + }); + + // Hand the reaped previews to the workflow so it can notify the corresponding open PRs. + if (process.env.GITHUB_OUTPUT) { + const pairs = reapedPullRequests(reaped, prefix); + appendFileSync( + process.env.GITHUB_OUTPUT, + `reaped=${reaped.join(',')}\nreaped_json=${JSON.stringify(pairs)}\n`, + ); + } + + console.log(`==> done — ${reaped.length} reaped, ${hardFailures} failed.`); + if (hardFailures > 0) process.exit(1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv).catch((err) => { + console.error(err.stack || String(err)); + process.exit(1); + }); +} diff --git a/scripts/reap-previews.test.mjs b/scripts/reap-previews.test.mjs new file mode 100644 index 00000000..9c3f23bb --- /dev/null +++ b/scripts/reap-previews.test.mjs @@ -0,0 +1,200 @@ +// Unit tests for the scheduled preview reaper. It runs unattended against a Cloudflare account SHARED +// with the other forks of midt-bg/sigma, so the tests that matter most are the ones asserting what it +// refuses to touch: long-lived workers, and previews belonging to a different fork. +// +// Run: node --test scripts/reap-previews.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { listWorkerScripts, reapStale, reapedPullRequests, selectStale } from './reap-previews.mjs'; + +const silent = () => {}; +const MINE = 'sigma-ydimitrof-pr'; + +// Fixed reference instant so the test is deterministic (no Date.now()). +const NOW = Date.parse('2026-06-24T12:00:00Z'); +const daysAgo = (n) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString(); + +describe('selectStale', () => { + it('selects only previews older than the max age', () => { + const scripts = [ + { id: 'sigma-ydimitrof-pr-1', modified_on: daysAgo(6) }, // stale + { id: 'sigma-ydimitrof-pr-2', modified_on: daysAgo(4) }, // fresh + { id: 'sigma-ydimitrof-pr-3', modified_on: daysAgo(10) }, // stale + ]; + const stale = selectStale(scripts, { maxAgeDays: 5, nowMs: NOW, prefix: MINE }); + assert.deepEqual( + stale.map((s) => s.name), + ['sigma-ydimitrof-pr-1', 'sigma-ydimitrof-pr-3'], + ); + }); + + it('never selects long-lived / non-preview workers, however old', () => { + const scripts = [ + { id: 'sigma', modified_on: daysAgo(400) }, + { id: 'sigma-etl', modified_on: daysAgo(400) }, + { id: 'sigma-dev', modified_on: daysAgo(400) }, + { id: 'sigma-ydimitrof-pr-9', modified_on: daysAgo(400) }, // only this one qualifies + ]; + const stale = selectStale(scripts, { maxAgeDays: 5, nowMs: NOW, prefix: MINE }); + assert.deepEqual( + stale.map((s) => s.name), + ['sigma-ydimitrof-pr-9'], + ); + }); + + // The reaper lists EVERY worker on the shared account. Reaping a sibling fork's live preview would + // be indistinguishable, from that fork's side, from a random outage. + it("never selects another fork's previews from the shared account", () => { + const scripts = [ + { id: 'sigma-pr-1', modified_on: daysAgo(400) }, + { id: 'sigma-lyubomir-bozhinov-pr-1', modified_on: daysAgo(400) }, + { id: 'sigma-midt-bg-pr-1', modified_on: daysAgo(400) }, + { id: 'sigma-ydimitrof-pr-1', modified_on: daysAgo(400) }, + ]; + const stale = selectStale(scripts, { maxAgeDays: 5, nowMs: NOW, prefix: MINE }); + assert.deepEqual( + stale.map((s) => s.name), + ['sigma-ydimitrof-pr-1'], + ); + }); + + it('leaves previews with an unparseable timestamp alone', () => { + const scripts = [{ id: 'sigma-ydimitrof-pr-1', modified_on: 'not-a-date' }]; + assert.deepEqual(selectStale(scripts, { maxAgeDays: 5, nowMs: NOW, prefix: MINE }), []); + }); + + it('treats exactly-at-the-boundary as not yet stale', () => { + const scripts = [{ id: 'sigma-ydimitrof-pr-1', modified_on: daysAgo(5) }]; + assert.deepEqual(selectStale(scripts, { maxAgeDays: 5, nowMs: NOW, prefix: MINE }), []); + }); +}); + +describe('listWorkerScripts', () => { + it('returns the result array on success', async () => { + const fetchImpl = async () => ({ + ok: true, + json: async () => ({ success: true, result: [{ id: 'sigma-ydimitrof-pr-1' }] }), + }); + const out = await listWorkerScripts({ accountId: 'a', token: 't', fetchImpl }); + assert.deepEqual(out, [{ id: 'sigma-ydimitrof-pr-1' }]); + }); + + it('follows the cursor across pages and concatenates every result', async () => { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + const onFirstPage = !url.includes('cursor='); + return { + ok: true, + json: async () => + onFirstPage + ? { + success: true, + result: [{ id: 'sigma-ydimitrof-pr-1' }], + result_info: { cursor: 'next-page' }, + } + : { + success: true, + result: [{ id: 'sigma-ydimitrof-pr-2' }], + result_info: { cursor: '' }, + }, + }; + }; + const out = await listWorkerScripts({ accountId: 'a', token: 't', fetchImpl }); + assert.deepEqual(out, [{ id: 'sigma-ydimitrof-pr-1' }, { id: 'sigma-ydimitrof-pr-2' }]); + assert.equal(calls.length, 2); + assert.match(calls[1], /cursor=next-page/); + }); + + it('throws with the API error on failure', async () => { + const fetchImpl = async () => ({ + ok: false, + status: 403, + json: async () => ({ success: false, errors: [{ message: 'bad token' }] }), + }); + await assert.rejects( + () => listWorkerScripts({ accountId: 'a', token: 't', fetchImpl }), + /list scripts failed \(403\).*bad token/, + ); + }); + + it('terminates instead of looping on a stable/repeating cursor', async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return { + ok: true, + json: async () => ({ + success: true, + result: [{ id: 'sigma-ydimitrof-pr-1' }], + result_info: { cursor: 'stuck' }, + }), + }; + }; + const out = await listWorkerScripts({ accountId: 'a', token: 't', fetchImpl }); + assert.equal(calls, 2); + assert.deepEqual(out, [{ id: 'sigma-ydimitrof-pr-1' }, { id: 'sigma-ydimitrof-pr-1' }]); + }); +}); + +describe('reapStale', () => { + const stale = [ + { name: 'sigma-ydimitrof-pr-1', ageDays: 6, modifiedOn: 'x' }, + { name: 'sigma-ydimitrof-pr-2', ageDays: 7, modifiedOn: 'y' }, + ]; + + it('does not delete anything in dry-run', () => { + let called = 0; + const result = reapStale(stale, { + apply: false, + del: () => (called += 1), + log: silent, + errLog: silent, + }); + assert.equal(called, 0); + assert.deepEqual(result, { reaped: [], hardFailures: 0 }); + }); + + it('counts only actual deletes as reaped, excluding already-gone', () => { + const del = (name) => (name === 'sigma-ydimitrof-pr-1' ? 'deleted' : 'already-gone'); + const result = reapStale(stale, { apply: true, del, log: silent, errLog: silent }); + assert.deepEqual(result, { reaped: ['sigma-ydimitrof-pr-1'], hardFailures: 0 }); + }); + + it('tallies a hard failure without aborting the rest of the run', () => { + const del = (name) => { + if (name === 'sigma-ydimitrof-pr-1') throw new Error('auth'); + return 'deleted'; + }; + const result = reapStale(stale, { apply: true, del, log: silent, errLog: silent }); + assert.deepEqual(result, { reaped: ['sigma-ydimitrof-pr-2'], hardFailures: 1 }); + }); +}); + +// Regression guard: the workflow's comment step used to re-derive the PR number with a hardcoded +// /^sigma-pr-(\d+)$/, which silently stops matching under any other prefix — workers get deleted and +// the PR is never told. The pairing is computed here, from the same module that builds the name. +describe('reapedPullRequests', () => { + it('pairs each reaped worker with its PR number', () => { + assert.deepEqual(reapedPullRequests(['sigma-ydimitrof-pr-12', 'sigma-ydimitrof-pr-7'], MINE), [ + { worker: 'sigma-ydimitrof-pr-12', pr: 12 }, + { worker: 'sigma-ydimitrof-pr-7', pr: 7 }, + ]); + }); + + it('works for any prefix, including an override', () => { + assert.deepEqual(reapedPullRequests(['midt-pr-3'], 'midt-pr'), [ + { worker: 'midt-pr-3', pr: 3 }, + ]); + }); + + it('drops a name that does not belong to this prefix', () => { + assert.deepEqual(reapedPullRequests(['sigma-pr-12', 'sigma'], MINE), []); + }); + + it('is empty for an empty reap', () => { + assert.deepEqual(reapedPullRequests([], MINE), []); + }); +}); diff --git a/scripts/teardown-remote.mjs b/scripts/teardown-remote.mjs new file mode 100644 index 00000000..70a678ac --- /dev/null +++ b/scripts/teardown-remote.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Delete a deployed Cloudflare Worker by name. Used to tear down ephemeral per-PR preview workers +// (see .github/workflows/preview.yml) once a pull request closes, and by scripts/reap-previews.mjs to +// enforce the preview max-lifetime. Counterpart to teardown.mjs, which only clears LOCAL miniflare +// state — this one talks to the Cloudflare API and removes a real worker. +// +// usage: +// node scripts/teardown-remote.mjs --name sigma-ydimitrof-pr-123 +// node scripts/teardown-remote.mjs --name sigma-ydimitrof-pr-123 --dry-run (default is to apply) +// +// Deliberately scoped to ephemeral preview workers ONLY. Preview environments share the long-lived dev +// D1 and R2 buckets (read-only from the preview worker's perspective), so there is NO per-PR D1/R2 to +// delete — and we must never delete those shared stores from here. Two barriers enforce this: an +// allowlist (only `-` may be deleted, where the prefix carries this repo's owner) and +// an explicit denylist of protected names. The owner-derived prefix is also what stops this repo's +// cleanup from reaching another fork's previews on the shared Cloudflare account — see +// scripts/preview-name.mjs and docs/dev-environments.md. +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { ephemeralPreviewRe, previewPrefixFromEnv } from './preview-name.mjs'; + +// Long-lived workers an ephemeral-cleanup path must NEVER delete, however it is invoked. The allowlist +// below already excludes these; the explicit set is a second barrier and a readable record of intent. +// Mirrors the environments in docs/deploy.md plus the shared dev workers in docs/dev-environments.md. +export const PROTECTED = new Set([ + 'sigma', + 'sigma-etl', + 'sigma-stage', + 'sigma-etl-stage', + 'sigma-dev', + 'sigma-etl-dev', +]); + +// Cloudflare returns code 10007 / "workers.api.error.script_not_found" when the script is already +// gone. Match the error name, or 10007 only in its `[code: 10007]` shape — a bare `\b10007\b` could +// coincidentally match unrelated numbers in wrangler output and mask a real teardown failure. +const NOT_FOUND = /script_not_found|code:?\s*10007\b/i; + +export function isProtected(name) { + return PROTECTED.has(name); +} + +export function isEphemeralPreviewName(name, prefix = previewPrefixFromEnv()) { + return typeof name === 'string' && ephemeralPreviewRe(prefix).test(name); +} + +// Throws unless `name` is a deletable ephemeral preview worker owned by THIS repo. Pure — no side effects. +export function assertDeletable(name, prefix = previewPrefixFromEnv()) { + if (!name) { + throw new Error( + 'teardown-remote: a worker name is required (--name or SIGMA_WEB_NAME).', + ); + } + if (isProtected(name)) { + throw new Error(`teardown-remote: refusing to delete protected long-lived worker "${name}".`); + } + if (!isEphemeralPreviewName(name, prefix)) { + throw new Error( + `teardown-remote: "${name}" is not an ephemeral preview worker of this repository (expected ${prefix}-) — refusing to delete.`, + ); + } +} + +// --force avoids the interactive confirmation prompt. Returns wrangler's stdout. +function defaultExec(name) { + return execFileSync('wrangler', ['delete', '--name', name, '--force'], { encoding: 'utf8' }); +} + +// Delete one ephemeral preview worker. Returns 'deleted' | 'already-gone' | 'dry-run'. +// Throws via assertDeletable for a protected/foreign/invalid name, and rethrows a hard wrangler failure +// (auth, network, wrong account) — those must NOT be swallowed, or a leaked worker goes unnoticed. +export function deleteWorker(name, { dryRun = false, exec = defaultExec, prefix } = {}) { + assertDeletable(name, prefix ?? previewPrefixFromEnv()); + if (dryRun) return 'dry-run'; + try { + const out = exec(name); + if (out) process.stdout.write(out); + return 'deleted'; + } catch (err) { + const output = `${err.stdout || ''}${err.stderr || ''}`; + if (output) process.stderr.write(output); + if (NOT_FOUND.test(output)) return 'already-gone'; + throw err; + } +} + +function main(argv) { + const args = argv.slice(2); + const dryRun = args.includes('--dry-run'); + const flag = (n) => { + const i = args.indexOf(n); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; + }; + const name = flag('--name') || process.env.SIGMA_WEB_NAME; + + let prefix; + try { + prefix = previewPrefixFromEnv(); + assertDeletable(name, prefix); + } catch (err) { + console.error(err.message); + process.exit(name ? 1 : 2); + } + + console.log(`==> wrangler delete --name ${name}${dryRun ? ' (dry run)' : ''}`); + try { + if (deleteWorker(name, { dryRun, prefix }) === 'already-gone') { + console.error(`!! "${name}" not found — already gone; treating teardown as done.`); + } + } catch { + console.error( + `!! delete of "${name}" failed for a reason other than "not found" — the worker may still be live. ` + + `Not masking this; failing so it gets surfaced.`, + ); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv); +} diff --git a/scripts/teardown-remote.test.mjs b/scripts/teardown-remote.test.mjs new file mode 100644 index 00000000..a0e9df14 --- /dev/null +++ b/scripts/teardown-remote.test.mjs @@ -0,0 +1,208 @@ +// Unit tests for the remote-teardown safety barriers. This script issues real `wrangler delete` calls, +// so the allowlist/denylist logic is exercised here rather than discovered in production: every case +// below is a worker that must NOT be deleted, plus the handful that may. +// +// Run: node --test scripts/teardown-remote.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + PROTECTED, + assertDeletable, + deleteWorker, + isEphemeralPreviewName, + isProtected, +} from './teardown-remote.mjs'; + +const MINE = 'sigma-ydimitrof-pr'; + +// Run `fn` with a patched environment, always restoring it so test order can't leak a prefix into the +// explicit-argument suites. +function withEnv(patch, fn) { + const prior = {}; + for (const [k, v] of Object.entries(patch)) { + prior[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + fn(); + } finally { + for (const [k, v] of Object.entries(prior)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +describe('isProtected', () => { + it('flags every long-lived worker', () => { + for (const name of [ + 'sigma', + 'sigma-etl', + 'sigma-stage', + 'sigma-etl-stage', + 'sigma-dev', + 'sigma-etl-dev', + ]) { + assert.equal(isProtected(name), true, name); + } + }); + + it('does not flag ephemeral preview names', () => { + assert.equal(isProtected('sigma-ydimitrof-pr-123'), false); + }); +}); + +describe('isEphemeralPreviewName', () => { + it('matches -', () => { + assert.equal(isEphemeralPreviewName('sigma-ydimitrof-pr-1', MINE), true); + assert.equal(isEphemeralPreviewName('sigma-ydimitrof-pr-99999', MINE), true); + }); + + it('rejects anything that is not exactly -', () => { + for (const name of [ + 'sigma', + 'sigma-dev', + 'sigma-ydimitrof-pr-', // no number + 'sigma-ydimitrof-pr-abc', // non-numeric + 'sigma-ydimitrof-pr-12-x', // trailing junk + 'prod-sigma-ydimitrof-pr-1', // prefix + 'SIGMA-YDIMITROF-PR-1', // wrong case + undefined, + null, + 42, + ]) { + assert.equal(isEphemeralPreviewName(name, MINE), false, String(name)); + } + }); + + // The reason the owner is in the name at all: all forks of midt-bg/sigma deploy previews into one + // shared Cloudflare account, and the daily reaper lists every worker on it. + it("is blind to another fork's previews on the shared account", () => { + assert.equal(isEphemeralPreviewName('sigma-pr-12', MINE), false); + assert.equal(isEphemeralPreviewName('sigma-lyubomir-bozhinov-pr-12', MINE), false); + assert.equal(isEphemeralPreviewName('sigma-midt-bg-pr-12', MINE), false); + }); +}); + +describe('assertDeletable', () => { + it('allows an ephemeral preview worker', () => { + assert.doesNotThrow(() => assertDeletable('sigma-ydimitrof-pr-42', MINE)); + }); + + it('refuses every protected long-lived worker', () => { + for (const name of PROTECTED) { + assert.throws( + () => assertDeletable(name, MINE), + /refusing to delete protected long-lived worker/, + name, + ); + } + }); + + it('refuses a missing name', () => { + assert.throws(() => assertDeletable(undefined, MINE), /a worker name is required/); + assert.throws(() => assertDeletable('', MINE), /a worker name is required/); + }); + + it('refuses a non-preview worker name (allowlist)', () => { + assert.throws( + () => assertDeletable('some-random-worker', MINE), + /not an ephemeral preview worker/, + ); + }); + + it("names the expected prefix when refusing another fork's preview", () => { + assert.throws( + () => assertDeletable('sigma-pr-42', MINE), + /expected sigma-ydimitrof-pr-/, + ); + }); +}); + +describe('deleteWorker', () => { + it('never invokes wrangler in dry-run', () => { + let called = false; + const result = deleteWorker('sigma-ydimitrof-pr-7', { + dryRun: true, + prefix: MINE, + exec: () => (called = true), + }); + assert.equal(result, 'dry-run'); + assert.equal(called, false); + }); + + it('reports "deleted" on success', () => { + assert.equal(deleteWorker('sigma-ydimitrof-pr-7', { prefix: MINE, exec: () => '' }), 'deleted'); + }); + + it('treats a missing script (code 10007) as "already-gone"', () => { + const exec = () => { + const err = new Error('exit 1'); + err.stderr = + 'A request to the Cloudflare API failed. workers.api.error.script_not_found [code: 10007]'; + throw err; + }; + assert.equal(deleteWorker('sigma-ydimitrof-pr-7', { prefix: MINE, exec }), 'already-gone'); + }); + + it('rethrows a hard failure (e.g. auth) instead of masking a leak', () => { + const exec = () => { + const err = new Error('exit 1'); + err.stderr = 'Authentication error [code: 10000]'; + throw err; + }; + assert.throws(() => deleteWorker('sigma-ydimitrof-pr-7', { prefix: MINE, exec }), /exit 1/); + }); + + it('refuses a protected worker even when handed a stub exec', () => { + let called = false; + assert.throws( + () => deleteWorker('sigma-etl', { prefix: MINE, exec: () => (called = true) }), + /refusing to delete protected long-lived worker/, + ); + assert.equal(called, false); + }); + + it("refuses another fork's preview even when handed a stub exec", () => { + let called = false; + assert.throws( + () => deleteWorker('sigma-pr-7', { prefix: MINE, exec: () => (called = true) }), + /not an ephemeral preview worker of this repository/, + ); + assert.equal(called, false); + }); +}); + +describe('prefix resolution from the environment', () => { + it('derives the allowlist from GITHUB_REPOSITORY_OWNER with no configuration', () => { + withEnv({ GITHUB_REPOSITORY_OWNER: 'ydimitrof', PREVIEW_WORKER_PREFIX: undefined }, () => { + assert.equal(isEphemeralPreviewName('sigma-ydimitrof-pr-5'), true); + assert.equal(isEphemeralPreviewName('sigma-pr-5'), false); + assert.doesNotThrow(() => assertDeletable('sigma-ydimitrof-pr-5')); + }); + }); + + it('honours an explicit PREVIEW_WORKER_PREFIX override', () => { + withEnv({ GITHUB_REPOSITORY_OWNER: 'ydimitrof', PREVIEW_WORKER_PREFIX: 'midt-pr' }, () => { + assert.equal(isEphemeralPreviewName('midt-pr-5'), true); + // Still requires the trailing -, so the app's own workers never match. + assert.equal(isEphemeralPreviewName('midt-pr'), false); + // The derived name no longer matches once a different prefix is configured. + assert.equal(isEphemeralPreviewName('sigma-ydimitrof-pr-5'), false); + }); + }); + + // Fail loudly rather than fall back to a shared default: a silent fallback is precisely how two + // forks end up deploying — and deleting — the same worker name. + it('throws instead of guessing when neither owner nor override is available', () => { + withEnv({ GITHUB_REPOSITORY_OWNER: undefined, PREVIEW_WORKER_PREFIX: undefined }, () => { + assert.throws( + () => isEphemeralPreviewName('sigma-ydimitrof-pr-5'), + /cannot derive a preview prefix/, + ); + }); + }); +});