Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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-<repo owner>-pr-<PR number>` — 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 `<prefix>-<PR number>`), 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

9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
135 changes: 135 additions & 0 deletions .github/workflows/preview-reap.yml
Original file line number Diff line number Diff line change
@@ -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-<owner>-pr-<n>` 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 = '<!-- sigma-preview -->';
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,
});
}
}
Loading
Loading