diff --git a/.claude/settings.json b/.claude/settings.json index 6b98fe8..470ecb4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.4" + "ref": "v0.7.1" } } }, diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 4665681..b674fe3 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -1,3 +1,5 @@ +# Managed by LasVegasForTransit/repository-tooling. Do not edit this file; +# `pnpm check:repository-tooling` fails on local changes. name: Setup Node + pnpm description: Install the repository's pinned pnpm, cache its store, and install from the lockfile. @@ -22,3 +24,32 @@ runs: run: pnpm install --frozen-lockfile env: NODE_AUTH_TOKEN: ${{ github.token }} + + - name: Install reviewed secret scanner + if: ${{ hashFiles('.gitleaks.toml') != '' }} + shell: bash + run: | + case "$RUNNER_OS/$RUNNER_ARCH" in + Linux/X64) + scanner_archive=gitleaks_8.30.1_linux_x64.tar.gz + scanner_sha=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + ;; + Linux/ARM64) + scanner_archive=gitleaks_8.30.1_linux_arm64.tar.gz + scanner_sha=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080 + ;; + *) echo "Unsupported scanner runner: $RUNNER_OS/$RUNNER_ARCH" >&2; exit 1 ;; + esac + scanner_directory="$(mktemp -d "$RUNNER_TEMP/lvbt-gitleaks.XXXXXX")" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/$scanner_archive" \ + --output "$scanner_directory/$scanner_archive" + scanner_actual="$(sha256sum "$scanner_directory/$scanner_archive" | cut -d ' ' -f 1)" + if [ "$scanner_actual" != "$scanner_sha" ]; then + echo 'Scanner archive checksum did NOT match the reviewed release.' >&2 + exit 1 + fi + tar -xzf "$scanner_directory/$scanner_archive" -C "$scanner_directory" gitleaks + chmod +x "$scanner_directory/gitleaks" + test "$("$scanner_directory/gitleaks" version)" = '8.30.1' + echo "$scanner_directory" >> "$GITHUB_PATH" diff --git a/.github/workflows/audit-scheduled.yml b/.github/workflows/audit-scheduled.yml new file mode 100644 index 0000000..cf3a7b1 --- /dev/null +++ b/.github/workflows/audit-scheduled.yml @@ -0,0 +1,20 @@ +name: Audit (scheduled) + +on: + schedule: + - cron: '0 14 * * 1' + workflow_dispatch: + +permissions: + contents: read + packages: read + actions: read + issues: write + +concurrency: + group: shared-audits-${{ github.repository }} + cancel-in-progress: false + +jobs: + audit: + uses: LasVegasForTransit/repository-tooling/.github/workflows/audit.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca7a0cc..5e52570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,3 @@ jobs: - name: Check run: pnpm check - - - name: Dependency audit - run: pnpm audit --audit-level=high - - - name: Secret scan - run: | - set -euo pipefail - docker run --rm -v "$PWD:/repo" -w /repo \ - ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ - git --redact --no-banner --config /repo/.gitleaks.toml diff --git a/.lvbt/tooling.json b/.lvbt/tooling.json new file mode 100644 index 0000000..2f66ea2 --- /dev/null +++ b/.lvbt/tooling.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "audits": { + "workflow": ".github/workflows/audit-scheduled.yml", + "artifactName": "lvbt-audit-report", + "dependencies": { + "local": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + }, + "production": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + } + } + } +} diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index 6a635d4..b682377 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.5.4", - "commit": "323d5f5f968ed69ebcc29252b183fd65d13dc49a", - "contentHash": "25da315541c7a6f94f1ed1712e7b41c7512318af88033a1b3d485515d350ab83", + "release": "v0.7.1", + "commit": "dfc9bddfc0a9c5d596c26496a9e6b6ed732d4ef3", + "contentHash": "9fc709c4ebf7edd8665cbdb81f6b5c73172b6c4fc1b8261173e975a35c359811", "executables": [ "examples/with-astro/.githooks/commit-msg", "examples/with-astro/.githooks/pre-commit", diff --git a/.lvbt/web-platform/examples/with-astro/.claude/settings.json b/.lvbt/web-platform/examples/with-astro/.claude/settings.json index 6b98fe8..470ecb4 100644 --- a/.lvbt/web-platform/examples/with-astro/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-astro/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.4" + "ref": "v0.7.1" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/.github/actions/setup-node-pnpm/action.yml b/.lvbt/web-platform/examples/with-astro/.github/actions/setup-node-pnpm/action.yml index 4665681..b674fe3 100644 --- a/.lvbt/web-platform/examples/with-astro/.github/actions/setup-node-pnpm/action.yml +++ b/.lvbt/web-platform/examples/with-astro/.github/actions/setup-node-pnpm/action.yml @@ -1,3 +1,5 @@ +# Managed by LasVegasForTransit/repository-tooling. Do not edit this file; +# `pnpm check:repository-tooling` fails on local changes. name: Setup Node + pnpm description: Install the repository's pinned pnpm, cache its store, and install from the lockfile. @@ -22,3 +24,32 @@ runs: run: pnpm install --frozen-lockfile env: NODE_AUTH_TOKEN: ${{ github.token }} + + - name: Install reviewed secret scanner + if: ${{ hashFiles('.gitleaks.toml') != '' }} + shell: bash + run: | + case "$RUNNER_OS/$RUNNER_ARCH" in + Linux/X64) + scanner_archive=gitleaks_8.30.1_linux_x64.tar.gz + scanner_sha=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + ;; + Linux/ARM64) + scanner_archive=gitleaks_8.30.1_linux_arm64.tar.gz + scanner_sha=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080 + ;; + *) echo "Unsupported scanner runner: $RUNNER_OS/$RUNNER_ARCH" >&2; exit 1 ;; + esac + scanner_directory="$(mktemp -d "$RUNNER_TEMP/lvbt-gitleaks.XXXXXX")" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/$scanner_archive" \ + --output "$scanner_directory/$scanner_archive" + scanner_actual="$(sha256sum "$scanner_directory/$scanner_archive" | cut -d ' ' -f 1)" + if [ "$scanner_actual" != "$scanner_sha" ]; then + echo 'Scanner archive checksum did NOT match the reviewed release.' >&2 + exit 1 + fi + tar -xzf "$scanner_directory/$scanner_archive" -C "$scanner_directory" gitleaks + chmod +x "$scanner_directory/gitleaks" + test "$("$scanner_directory/gitleaks" version)" = '8.30.1' + echo "$scanner_directory" >> "$GITHUB_PATH" diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/audit-scheduled.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/audit-scheduled.yml new file mode 100644 index 0000000..cf3a7b1 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/audit-scheduled.yml @@ -0,0 +1,20 @@ +name: Audit (scheduled) + +on: + schedule: + - cron: '0 14 * * 1' + workflow_dispatch: + +permissions: + contents: read + packages: read + actions: read + issues: write + +concurrency: + group: shared-audits-${{ github.repository }} + cancel-in-progress: false + +jobs: + audit: + uses: LasVegasForTransit/repository-tooling/.github/workflows/audit.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/ci.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/ci.yml index ca7a0cc..5e52570 100644 --- a/.lvbt/web-platform/examples/with-astro/.github/workflows/ci.yml +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/ci.yml @@ -33,13 +33,3 @@ jobs: - name: Check run: pnpm check - - - name: Dependency audit - run: pnpm audit --audit-level=high - - - name: Secret scan - run: | - set -euo pipefail - docker run --rm -v "$PWD:/repo" -w /repo \ - ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ - git --redact --no-banner --config /repo/.gitleaks.toml diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml index 97d169c..f2cae0b 100644 --- a/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml @@ -1,7 +1,4 @@ -# Deploys every app with a wrangler config on each push to main, after the -# same check pull requests run. Needs the CLOUDFLARE_API_TOKEN (Workers Scripts -# edit) and CLOUDFLARE_ACCOUNT_ID repository secrets. -name: Deploy +name: Deploy staging on: push: @@ -10,33 +7,45 @@ on: permissions: contents: read + actions: read + deployments: write + packages: read concurrency: - group: deploy-${{ github.ref }} + group: staging cancel-in-progress: false jobs: - validate: - name: Validate - uses: ./.github/workflows/ci.yml + build: + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-build.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + app-directory: apps/deploy + artifact-prefix: app-release + secrets: inherit + attest: + needs: build + permissions: + contents: read + actions: read + packages: read + attestations: write + id-token: write + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + artifact-prefix: app-release - deploy: - name: Deploy - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: production - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node + pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Deploy - run: pnpm run deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + staging: + needs: [build, attest] + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-publish.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + target: preview + attestation-prefix: attestation-app-release + release-id: ${{ github.run_id }} + commit: ${{ github.sha }} + artifact-prefix: app-release + app-directory: apps/deploy + acceptance-directory: apps/site + url: ${{ vars.LVBT_PREVIEW_URL }} + smoke-script: worker:smoke + browser-script: worker:test:browser + secrets: inherit diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/promote.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/promote.yml new file mode 100644 index 0000000..2099584 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/promote.yml @@ -0,0 +1,53 @@ +name: Promote app release +run-name: "Promote app: ${{ inputs.request_id || inputs.run_id || 'current preview' }}" + +on: + workflow_dispatch: + inputs: + run_id: + description: Optional successful Deploy staging run ID; omit to select the actual preview + type: string + required: false + request_id: + description: Correlation ID supplied by lvbt promote + type: string + required: false + + expected_version: + description: Optional current production version; required before a release marker exists + type: string + required: false + +permissions: + contents: read + actions: read + deployments: write + packages: read + +concurrency: + group: production + cancel-in-progress: false + +jobs: + source: + if: github.ref == 'refs/heads/main' + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-source.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + run-id: ${{ inputs.run_id }} + secrets: inherit + promote: + needs: source + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-publish.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + target: production + expected-version: ${{ inputs.expected_version }} + attestation-prefix: attestation-app-release + release-id: ${{ needs.source.outputs.release-id }} + commit: ${{ needs.source.outputs.commit }} + artifact-prefix: app-release + app-directory: apps/deploy + acceptance-directory: apps/site + url: ${{ vars.LVBT_PRODUCTION_URL }} + smoke-script: worker:smoke + browser-script: worker:test:browser + secrets: inherit diff --git a/.lvbt/web-platform/examples/with-astro/.gitignore b/.lvbt/web-platform/examples/with-astro/.gitignore index 4cf35a7..94646a7 100644 --- a/.lvbt/web-platform/examples/with-astro/.gitignore +++ b/.lvbt/web-platform/examples/with-astro/.gitignore @@ -7,6 +7,7 @@ blob-report/ **/playwright/.cache/ .turbo/ .wrangler/ +.cloudflare/ .env .env.* !.env.example diff --git a/.lvbt/web-platform/examples/with-astro/.lvbt/tooling.json b/.lvbt/web-platform/examples/with-astro/.lvbt/tooling.json new file mode 100644 index 0000000..556b942 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/.lvbt/tooling.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "release": { + "appDirectory": "apps/deploy", + "artifactSource": "cf-output", + "productionWorker": "lvbt-site", + "previewWorker": "lvbt-site-preview", + "productionUrlEnv": "LVBT_PRODUCTION_URL", + "previewUrlEnv": "LVBT_PREVIEW_URL", + "artifactPrefix": "app-release", + "stagingWorkflow": { + "name": "Deploy staging", + "path": ".github/workflows/deploy.yml", + "branch": "main" + }, + "promotionWorkflow": { + "file": "promote.yml", + "titlePrefix": "Promote app", + "branch": "main" + }, + "workersDevSubdomainEnv": "LVBT_WORKERS_DEV_SUBDOMAIN", + "attestation": { + "signerWorkflow": "LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml", + "signerCommit": "3567f0cb1345e8756eb5d84e0a3ea7b62695125a" + } + }, + "audits": { + "workflow": ".github/workflows/audit-scheduled.yml", + "artifactName": "lvbt-audit-report", + "dependencies": { + "local": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + }, + "production": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + } + } + } +} diff --git a/.lvbt/web-platform/examples/with-astro/AGENTS.md b/.lvbt/web-platform/examples/with-astro/AGENTS.md index b127dba..bd72b53 100644 --- a/.lvbt/web-platform/examples/with-astro/AGENTS.md +++ b/.lvbt/web-platform/examples/with-astro/AGENTS.md @@ -5,20 +5,10 @@ command that fixes it (`pnpm check:fix` repairs everything a machine can). ## Standard commands -Every LVBT repository answers to the same commands: - -| Command | What it does | -| ----------------------------- | -------------------------------------------------------------- | -| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | -| `pnpm preflight` | Confirm the machine can build and deploy this repository | -| `pnpm preflight --production` | Report whether production has everything `platform.json` lists | -| `pnpm bootstrap --production` | Set up whatever production is missing, asking for values | -| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | -| `pnpm check:fix` | Apply formatting and lint fixes | -| `pnpm build` | Build every package | -| `pnpm test` | Run every package's tests | -| `pnpm run deploy` | Build, then `wrangler deploy` every app (deployable repos) | -| `turbo gen workspace` | Scaffold a new package or app | +Follow the shared +[developer workflow](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/reference/developer-workflow.md) +for bootstrap, preflight, validation, audits and explicit staging-to-production promotion. Keep +application-specific checks in Turbo `validate`; change reusable rules upstream. ## Create GitHub issues and pull requests diff --git a/.lvbt/web-platform/examples/with-astro/README.md b/.lvbt/web-platform/examples/with-astro/README.md index 98140f7..aa1cdc3 100644 --- a/.lvbt/web-platform/examples/with-astro/README.md +++ b/.lvbt/web-platform/examples/with-astro/README.md @@ -1,11 +1,10 @@ # LVBT site repository A Turborepo workspace following the LVBT repository standard, with an Astro site under `apps/site` -that deploys to Cloudflare Workers as static assets. It was created with: - -```bash -npx create-turbo@latest --example https://github.com/LasVegasForTransit/repository-tooling/tree/main/examples/with-astro -``` +that deploys to Cloudflare Workers as static assets. Create a repository with +[LasVegasForTransit/template-with-astro](https://github.com/LasVegasForTransit/template-with-astro) +using **Use this template**, then clone your new repository. The generated standard is vendored, so +local setup does not need GitHub Packages authentication. ## Getting started @@ -15,18 +14,25 @@ pnpm check # the same check CI runs pnpm dev # the site at http://127.0.0.1:4321 ``` -Then rename the root package and the Worker in `apps/site/wrangler.jsonc`, set `site` in +Then rename the root package and the Worker in `apps/deploy/cloudflare.config.ts`. Match that name +in `.lvbt/tooling.json` and choose a separate preview Worker name there. Set `site` in `apps/site/astro.config.ts`, and replace the scopes in `.lvbt/commit-scopes.txt` with this -repository's boundaries. +repository's boundaries. Local development needs no publishing credentials. ## Layout - `apps/site` is the Astro site: pages under `src/pages`, layouts under `src/layouts`, Tailwind in `src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/` +- `apps/deploy` holds the `cf` Worker configuration and reads `apps/site/dist` after the site build - `packages/` for libraries the site shares with other apps -`pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; -`.github/workflows/deploy.yml` does the same on every push to `main`. +## Releases + +Follow the common +[web-release setup guide](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/how-to/set-up-a-web-release.md) +for the account, permanent origins, protected preview, scoped credentials, and production readiness. +This app's canonical configuration is `apps/deploy/cloudflare.config.ts`; record its actual +production requirements in `apps/deploy/platform.json` before publishing. Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from [`LasVegasForTransit/repository-tooling`](https://github.com/LasVegasForTransit/repository-tooling). diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/cloudflare.config.ts b/.lvbt/web-platform/examples/with-astro/apps/deploy/cloudflare.config.ts new file mode 100644 index 0000000..9be2e6e --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/cloudflare.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'cf/config'; + +// Set CLOUDFLARE_ACCOUNT_ID in the deploy environment before publishing. +export default defineConfig({ + worker: { + name: 'lvbt-site', + compatibilityDate: '2026-08-31', + previewUrls: true, + assets: { + notFoundHandling: '404-page', + }, + observability: { + enabled: true, + }, + }, +}); diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js b/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js new file mode 100644 index 0000000..fe2b986 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from '@lasvegasfortransit/eslint-config/base'; + +export default config; diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json b/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json new file mode 100644 index 0000000..9d469d8 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json @@ -0,0 +1,21 @@ +{ + "name": "@lasvegasfortransit/deploy", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "test": "node --test", + "validate": "cf deploy --dry-run" + }, + "devDependencies": { + "@lasvegasfortransit/eslint-config": "0.7.1", + "@lasvegasfortransit/site": "workspace:*", + "@lasvegasfortransit/typescript-config": "0.7.1", + "cf": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json b/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json new file mode 100644 index 0000000..424aa9e --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@lasvegasfortransit/typescript-config/base.json", + "include": ["*.config.ts"] +} diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/wrangler.config.ts b/.lvbt/web-platform/examples/with-astro/apps/deploy/wrangler.config.ts new file mode 100644 index 0000000..2cb09ad --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/wrangler.config.ts @@ -0,0 +1,8 @@ +import { defineWranglerConfig } from 'wrangler/experimental-config'; + +export default defineWranglerConfig({ + types: { + generate: false, + }, + assetsDirectory: '../site/dist', +}); diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/package.json b/.lvbt/web-platform/examples/with-astro/apps/site/package.json index 8398cd7..956b857 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -11,7 +11,9 @@ "sync": "astro sync", "check-types": "astro check", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "worker:smoke": "tsx scripts/release-smoke.ts", + "worker:test:browser": "playwright test" }, "dependencies": { "@astrojs/sitemap": "catalog:", @@ -21,10 +23,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.5.4", - "@lasvegasfortransit/playwright-config": "0.5.4", - "@lasvegasfortransit/typescript-config": "0.5.4", - "@lasvegasfortransit/vitest-config": "0.5.4", + "@lasvegasfortransit/eslint-config": "0.7.1", + "@lasvegasfortransit/playwright-config": "0.7.1", + "@lasvegasfortransit/typescript-config": "0.7.1", + "@lasvegasfortransit/vitest-config": "0.7.1", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts b/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts index 5caad84..d66fa4a 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts +++ b/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts @@ -2,15 +2,20 @@ import { defineConfig } from '@playwright/test'; import { foregroundServerEnvironment, sharedConfig } from '@lasvegasfortransit/playwright-config'; -const url = 'http://127.0.0.1:4321'; +const remote = process.env.PLAYWRIGHT_BASE_URL; +const url = remote ?? 'http://127.0.0.1:4321'; export default defineConfig({ ...sharedConfig, use: { ...sharedConfig.use, baseURL: url }, - webServer: { - command: 'pnpm preview', - env: foregroundServerEnvironment, - url, - reuseExistingServer: !process.env.CI, - }, + ...(remote + ? {} + : { + webServer: { + command: 'pnpm preview', + env: foregroundServerEnvironment, + url, + reuseExistingServer: !process.env.CI, + }, + }), }); diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/scripts/release-smoke.ts b/.lvbt/web-platform/examples/with-astro/apps/site/scripts/release-smoke.ts new file mode 100644 index 0000000..7f0b1b6 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/site/scripts/release-smoke.ts @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url'; +import { chromium } from '@playwright/test'; +import { + readReleaseConfiguration, + runReleaseSmoke, +} from '@lasvegasfortransit/web-platform/release'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +await runReleaseSmoke(await readReleaseConfiguration(root), chromium); diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts index 550fd00..7b03297 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts +++ b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/access'; import { expectNoAccessibilityViolations } from '@lasvegasfortransit/playwright-config/accessibility'; import { monitorPageHealth } from '@lasvegasfortransit/playwright-config/page-health'; diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/support/access.ts b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/support/access.ts new file mode 100644 index 0000000..2284fb6 --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/support/access.ts @@ -0,0 +1,15 @@ +import { test as base } from '@playwright/test'; +import { accessCredentials, scopeBrowserAccess } from '@lasvegasfortransit/web-platform/release'; + +export { expect } from '@playwright/test'; +export const test = base.extend({ + context: async ({ context, baseURL }, use) => { + if (baseURL) + await scopeBrowserAccess(context, new URL(baseURL).origin, accessCredentials(process.env)); + try { + await use(context); + } finally { + await context.unrouteAll({ behavior: 'wait' }); + } + }, +}); diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json b/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json index fd1637b..0cd0920 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "@lasvegasfortransit/typescript-config/astro.json", - "include": [".astro/types.d.ts", "src", "tests", "*.config.ts"], + "include": [".astro/types.d.ts", "src", "tests", "scripts", "*.config.ts"], "exclude": ["dist"] } diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc b/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc deleted file mode 100644 index a4aab15..0000000 --- a/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - // The Worker name. Rename it before the first deploy. - "name": "lvbt-site", - "compatibility_date": "2026-08-31", - "preview_urls": true, - "assets": { - "directory": "./dist", - "not_found_handling": "404-page", - }, - "observability": { - "enabled": true, - }, -} diff --git a/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md b/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md index 55588ff..c3eaa61 100644 --- a/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md +++ b/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md @@ -51,7 +51,7 @@ request. The required status is named `Validate`. deploy this repository, with a fix printed for anything missing. **Platform manifest**: `platform.json`, next to an app's production -`wrangler.jsonc`, which lists everything the app needs in production: its database, bucket, bot -check, admin sign-in, email domain, secrets, and the values that must never be set there. +configuration, which lists everything the app needs in production: its database, bucket, bot check, +admin sign-in, email domain, secrets, and the values that must never be set there. `pnpm preflight --production` checks production against it, and `pnpm bootstrap --production` sets up what is missing. diff --git a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md index 3646d2c..b3178d8 100644 --- a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md +++ b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md @@ -1,81 +1,12 @@ # Start here -This tutorial takes you from a fresh clone to a passing check and a first commit. It exists so that -your first hour is spent on the project, not on tooling. You need to be able to run a command in a -terminal; nothing else is assumed. +Follow this repository's [README](../../../README.md) for its application layout and local +configuration. Run `pnpm bootstrap`, then `pnpm dev` to start development. -## Before you start +The shared +[developer workflow](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/reference/developer-workflow.md) +defines validation, contributions, audits, staging and explicit production promotion. Follow the +mandatory contribution helper in [AGENTS.md](../../../AGENTS.md) when opening a pull request. -- Node.js 24.20 or newer on the 24 line (the current long-term support release). Check with - `node --version`; install from [nodejs.org](https://nodejs.org) if needed. -- git, and a GitHub account with access to this repository. -- The GitHub CLI (`gh`), signed in with `gh auth login`. Issues and pull requests are created - through it. - -pnpm (the package manager, see the [glossary](../reference/glossary.md#pnpm)) installs itself from -the version pinned in `package.json` the first time you run it, through Corepack. If -`pnpm --version` fails, run `corepack enable` once. - -## 1. Clone and bootstrap - -```bash -git clone git@github.com:LasVegasForTransit/.git -cd -pnpm bootstrap -``` - -`bootstrap` installs dependencies, points git at the repository's hooks, and runs preflight, which -prints one line per check: - -```text - ok Node.js 24.20.0 satisfies ^24.20.0 - ok pnpm 11.25.0 matches packageManager - ok dependencies node_modules is present - ok git hooks core.hooksPath is .githooks - ok commit scopes .lvbt/commit-scopes.txt is present - ok GitHub CLI gh is installed and signed in - ok Cloudflare wrangler is signed in; deployables: apps/site -preflight: all 7 checks passed -``` - -A failing line prints the command that fixes it. Run it, then `pnpm preflight` again. - -## 2. Run the check - -```bash -pnpm check -``` - -This is the same command CI runs: formatting, documentation links, the repository-shape rules, then -lint, typecheck, and tests for every package. On a fresh clone it passes. When it fails, the output -names the package and file; `pnpm check:fix` repairs everything a machine can (formatting and -auto-fixable lint findings). - -## 3. Make a change and commit it - -Edit a file, then: - -```bash -git add -git commit -``` - -The commit hook checks the message. Subjects look like `type(scope): description` where `type` is -one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `style`, `revert`, and -the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or `fix` -commit also needs a body that says what changed for a person using the product and why. The hook -tells you exactly what to change when it rejects a message. - -## 4. Open a pull request - -Push your branch and open the pull request with the GitHub CLI or the web UI. The pull request -template asks for a TL;DR, an overview of changes, and follow-ups. CI runs `pnpm check` as the -`Validate` status, and a maintainer reviews. - -## Where to go next - -- `pnpm dev` starts the app; `pnpm build` builds it; `pnpm run deploy` publishes it to Cloudflare - (`.github/workflows/deploy.yml` does that on every push to `main`). -- The [glossary](../reference/glossary.md) defines every tool and acronym used here. -- `AGENTS.md` at the repository root is the contract for coding agents; it is a good summary of the - rules for people too. +The [glossary](../reference/glossary.md) explains the tools this application uses. Extend the shared +standard through application checks and configuration; implement reusable process changes upstream. diff --git a/.lvbt/web-platform/examples/with-astro/package.json b/.lvbt/web-platform/examples/with-astro/package.json index 0dbfd39..cc8fcf7 100644 --- a/.lvbt/web-platform/examples/with-astro/package.json +++ b/.lvbt/web-platform/examples/with-astro/package.json @@ -17,14 +17,19 @@ "check": "pnpm format:check && markdownlint-cli2 && lvbt check && turbo run lint check-types test validate", "check:fix": "pnpm format && markdownlint-cli2 --fix && turbo run lint -- --fix", "prepare": "git config --local core.hooksPath .githooks 2>/dev/null || true", - "deploy": "lvbt deploy" + "deploy": "lvbt deploy", + "audit": "lvbt audit", + "promote": "lvbt promote", + "postinstall": "lvbt setup-record", + "validate": "lvbt check secrets", + "security:dependencies": "pnpm audit --audit-level=high" }, "lint-staged": { "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.4", - "@lasvegasfortransit/prettier-config": "0.5.4", + "@lasvegasfortransit/cli": "0.7.1", + "@lasvegasfortransit/prettier-config": "0.7.1", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", @@ -32,7 +37,9 @@ "prettier-plugin-astro": "catalog:", "turbo": "catalog:", "typescript": "catalog:", - "wrangler": "catalog:" + "wrangler": "catalog:", + "tsx": "catalog:", + "@lasvegasfortransit/web-platform": "0.7.1" }, "packageManager": "pnpm@11.25.0", "engines": { diff --git a/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml b/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml index c4b29ca..f931807 100644 --- a/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml +++ b/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml @@ -16,8 +16,16 @@ minimumReleaseAge: 1440 # Temporary transitive pins for published security fixes not yet selected by # their direct dependents. overrides: - sharp: 0.35.4 + brace-expansion: 5.0.12 + braces: npm:@dieub/braces-depth-guard@3.0.3-pn.3 + devalue: 5.9.4 + fast-uri: 3.1.8 + http-cache-semantics: 4.3.0 + sharp: 0.35.5 + 'undici@^7.0.0': 7.29.1 + 'undici@^8.0.0': 8.10.2 smol-toml: 1.8.0 + source-map-js: 1.2.2 svgo: 4.1.0 # The organization version baseline. Every LVBT repository carries this same @@ -61,5 +69,9 @@ catalog: typescript-eslint: 8.69.0 vite: 8.2.2 vitest: 4.1.11 - wrangler: 4.127.1 + cf: 1.0.0-beta.5 + wrangler: 4.141.0 zod: 4.5.4 + +# Preflight is read-only; bootstrap validates before installing. +verifyDepsBeforeRun: false diff --git a/.lvbt/web-platform/examples/with-astro/turbo.json b/.lvbt/web-platform/examples/with-astro/turbo.json index 8b2b298..b8d7e9e 100644 --- a/.lvbt/web-platform/examples/with-astro/turbo.json +++ b/.lvbt/web-platform/examples/with-astro/turbo.json @@ -2,6 +2,7 @@ "$schema": "https://turborepo.dev/schema.json", "ui": "tui", "globalEnv": ["CI"], + "globalDependencies": [".lvbt/web-platform.json", ".lvbt/tooling.json", ".github/workflows/**"], "tasks": { "build": { "dependsOn": ["^build"], @@ -36,6 +37,20 @@ "dependsOn": ["build"], "cache": false, "persistent": true + }, + "//#validate": { + "dependsOn": ["//#security:dependencies"], + "cache": false + }, + "//#security:dependencies": { + "cache": false } - } + }, + "globalPassThroughEnv": [ + "PLAYWRIGHT_BASE_URL", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + "LVBT_PRODUCTION_URL", + "LVBT_PREVIEW_URL" + ] } diff --git a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json index 6b98fe8..470ecb4 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.4" + "ref": "v0.7.1" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/actions/setup-node-pnpm/action.yml b/.lvbt/web-platform/examples/with-vite-react/.github/actions/setup-node-pnpm/action.yml index 4665681..b674fe3 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.github/actions/setup-node-pnpm/action.yml +++ b/.lvbt/web-platform/examples/with-vite-react/.github/actions/setup-node-pnpm/action.yml @@ -1,3 +1,5 @@ +# Managed by LasVegasForTransit/repository-tooling. Do not edit this file; +# `pnpm check:repository-tooling` fails on local changes. name: Setup Node + pnpm description: Install the repository's pinned pnpm, cache its store, and install from the lockfile. @@ -22,3 +24,32 @@ runs: run: pnpm install --frozen-lockfile env: NODE_AUTH_TOKEN: ${{ github.token }} + + - name: Install reviewed secret scanner + if: ${{ hashFiles('.gitleaks.toml') != '' }} + shell: bash + run: | + case "$RUNNER_OS/$RUNNER_ARCH" in + Linux/X64) + scanner_archive=gitleaks_8.30.1_linux_x64.tar.gz + scanner_sha=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + ;; + Linux/ARM64) + scanner_archive=gitleaks_8.30.1_linux_arm64.tar.gz + scanner_sha=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080 + ;; + *) echo "Unsupported scanner runner: $RUNNER_OS/$RUNNER_ARCH" >&2; exit 1 ;; + esac + scanner_directory="$(mktemp -d "$RUNNER_TEMP/lvbt-gitleaks.XXXXXX")" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/$scanner_archive" \ + --output "$scanner_directory/$scanner_archive" + scanner_actual="$(sha256sum "$scanner_directory/$scanner_archive" | cut -d ' ' -f 1)" + if [ "$scanner_actual" != "$scanner_sha" ]; then + echo 'Scanner archive checksum did NOT match the reviewed release.' >&2 + exit 1 + fi + tar -xzf "$scanner_directory/$scanner_archive" -C "$scanner_directory" gitleaks + chmod +x "$scanner_directory/gitleaks" + test "$("$scanner_directory/gitleaks" version)" = '8.30.1' + echo "$scanner_directory" >> "$GITHUB_PATH" diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/audit-scheduled.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/audit-scheduled.yml new file mode 100644 index 0000000..cf3a7b1 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/audit-scheduled.yml @@ -0,0 +1,20 @@ +name: Audit (scheduled) + +on: + schedule: + - cron: '0 14 * * 1' + workflow_dispatch: + +permissions: + contents: read + packages: read + actions: read + issues: write + +concurrency: + group: shared-audits-${{ github.repository }} + cancel-in-progress: false + +jobs: + audit: + uses: LasVegasForTransit/repository-tooling/.github/workflows/audit.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/ci.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/ci.yml index ca7a0cc..5e52570 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/ci.yml +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/ci.yml @@ -33,13 +33,3 @@ jobs: - name: Check run: pnpm check - - - name: Dependency audit - run: pnpm audit --audit-level=high - - - name: Secret scan - run: | - set -euo pipefail - docker run --rm -v "$PWD:/repo" -w /repo \ - ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ - git --redact --no-banner --config /repo/.gitleaks.toml diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml index 97d169c..f8259c4 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml @@ -1,7 +1,4 @@ -# Deploys every app with a wrangler config on each push to main, after the -# same check pull requests run. Needs the CLOUDFLARE_API_TOKEN (Workers Scripts -# edit) and CLOUDFLARE_ACCOUNT_ID repository secrets. -name: Deploy +name: Deploy staging on: push: @@ -10,33 +7,45 @@ on: permissions: contents: read + actions: read + deployments: write + packages: read concurrency: - group: deploy-${{ github.ref }} + group: staging cancel-in-progress: false jobs: - validate: - name: Validate - uses: ./.github/workflows/ci.yml + build: + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-build.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + app-directory: apps/deploy + artifact-prefix: app-release + secrets: inherit + attest: + needs: build + permissions: + contents: read + actions: read + packages: read + attestations: write + id-token: write + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + artifact-prefix: app-release - deploy: - name: Deploy - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: production - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node + pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Deploy - run: pnpm run deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + staging: + needs: [build, attest] + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-publish.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + target: preview + attestation-prefix: attestation-app-release + release-id: ${{ github.run_id }} + commit: ${{ github.sha }} + artifact-prefix: app-release + app-directory: apps/deploy + acceptance-directory: apps/app + url: ${{ vars.LVBT_PREVIEW_URL }} + smoke-script: worker:smoke + browser-script: worker:test:browser + secrets: inherit diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/promote.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/promote.yml new file mode 100644 index 0000000..0cf30a4 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/promote.yml @@ -0,0 +1,53 @@ +name: Promote app release +run-name: "Promote app: ${{ inputs.request_id || inputs.run_id || 'current preview' }}" + +on: + workflow_dispatch: + inputs: + run_id: + description: Optional successful Deploy staging run ID; omit to select the actual preview + type: string + required: false + request_id: + description: Correlation ID supplied by lvbt promote + type: string + required: false + + expected_version: + description: Optional current production version; required before a release marker exists + type: string + required: false + +permissions: + contents: read + actions: read + deployments: write + packages: read + +concurrency: + group: production + cancel-in-progress: false + +jobs: + source: + if: github.ref == 'refs/heads/main' + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-source.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + run-id: ${{ inputs.run_id }} + secrets: inherit + promote: + needs: source + uses: LasVegasForTransit/repository-tooling/.github/workflows/release-publish.yml@3567f0cb1345e8756eb5d84e0a3ea7b62695125a + with: + target: production + expected-version: ${{ inputs.expected_version }} + attestation-prefix: attestation-app-release + release-id: ${{ needs.source.outputs.release-id }} + commit: ${{ needs.source.outputs.commit }} + artifact-prefix: app-release + app-directory: apps/deploy + acceptance-directory: apps/app + url: ${{ vars.LVBT_PRODUCTION_URL }} + smoke-script: worker:smoke + browser-script: worker:test:browser + secrets: inherit diff --git a/.lvbt/web-platform/examples/with-vite-react/.gitignore b/.lvbt/web-platform/examples/with-vite-react/.gitignore index 65717d9..393f057 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.gitignore +++ b/.lvbt/web-platform/examples/with-vite-react/.gitignore @@ -7,6 +7,7 @@ blob-report/ **/playwright/.cache/ .turbo/ .wrangler/ +.cloudflare/ .env .env.* !.env.example diff --git a/.lvbt/web-platform/examples/with-vite-react/.lvbt/tooling.json b/.lvbt/web-platform/examples/with-vite-react/.lvbt/tooling.json new file mode 100644 index 0000000..114d3a6 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/.lvbt/tooling.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "release": { + "appDirectory": "apps/deploy", + "artifactSource": "cf-output", + "productionWorker": "lvbt-app", + "previewWorker": "lvbt-app-preview", + "productionUrlEnv": "LVBT_PRODUCTION_URL", + "previewUrlEnv": "LVBT_PREVIEW_URL", + "artifactPrefix": "app-release", + "stagingWorkflow": { + "name": "Deploy staging", + "path": ".github/workflows/deploy.yml", + "branch": "main" + }, + "promotionWorkflow": { + "file": "promote.yml", + "titlePrefix": "Promote app", + "branch": "main" + }, + "workersDevSubdomainEnv": "LVBT_WORKERS_DEV_SUBDOMAIN", + "attestation": { + "signerWorkflow": "LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml", + "signerCommit": "3567f0cb1345e8756eb5d84e0a3ea7b62695125a" + } + }, + "audits": { + "workflow": ".github/workflows/audit-scheduled.yml", + "artifactName": "lvbt-audit-report", + "dependencies": { + "local": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + }, + "production": { + "command": ["pnpm", "audit", "--prod", "--audit-level=high", "--json"], + "format": "pnpm" + } + } + } +} diff --git a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md index b127dba..bd72b53 100644 --- a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md +++ b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md @@ -5,20 +5,10 @@ command that fixes it (`pnpm check:fix` repairs everything a machine can). ## Standard commands -Every LVBT repository answers to the same commands: - -| Command | What it does | -| ----------------------------- | -------------------------------------------------------------- | -| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | -| `pnpm preflight` | Confirm the machine can build and deploy this repository | -| `pnpm preflight --production` | Report whether production has everything `platform.json` lists | -| `pnpm bootstrap --production` | Set up whatever production is missing, asking for values | -| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | -| `pnpm check:fix` | Apply formatting and lint fixes | -| `pnpm build` | Build every package | -| `pnpm test` | Run every package's tests | -| `pnpm run deploy` | Build, then `wrangler deploy` every app (deployable repos) | -| `turbo gen workspace` | Scaffold a new package or app | +Follow the shared +[developer workflow](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/reference/developer-workflow.md) +for bootstrap, preflight, validation, audits and explicit staging-to-production promotion. Keep +application-specific checks in Turbo `validate`; change reusable rules upstream. ## Create GitHub issues and pull requests diff --git a/.lvbt/web-platform/examples/with-vite-react/README.md b/.lvbt/web-platform/examples/with-vite-react/README.md index 98140f7..8a07e54 100644 --- a/.lvbt/web-platform/examples/with-vite-react/README.md +++ b/.lvbt/web-platform/examples/with-vite-react/README.md @@ -1,32 +1,38 @@ -# LVBT site repository +# LVBT app repository -A Turborepo workspace following the LVBT repository standard, with an Astro site under `apps/site` -that deploys to Cloudflare Workers as static assets. It was created with: - -```bash -npx create-turbo@latest --example https://github.com/LasVegasForTransit/repository-tooling/tree/main/examples/with-astro -``` +A Turborepo workspace following the LVBT repository standard, with a Vite React app under `apps/app` +that deploys to Cloudflare Workers as static assets. Create a repository with +[LasVegasForTransit/template-with-vite-react](https://github.com/LasVegasForTransit/template-with-vite-react) +using **Use this template**, then clone your new repository. The generated standard is vendored, so +local setup does not need GitHub Packages authentication. ## Getting started ```bash pnpm bootstrap # install, wire git hooks, run preflight pnpm check # the same check CI runs -pnpm dev # the site at http://127.0.0.1:4321 +pnpm dev # the app at http://127.0.0.1:5173 ``` -Then rename the root package and the Worker in `apps/site/wrangler.jsonc`, set `site` in -`apps/site/astro.config.ts`, and replace the scopes in `.lvbt/commit-scopes.txt` with this -repository's boundaries. +Then rename the root package and the Worker in `apps/deploy/cloudflare.config.ts`. Match that name +in `.lvbt/tooling.json` and choose a separate preview Worker name there. Replace the scopes in +`.lvbt/commit-scopes.txt` with this repository's boundaries. Local development needs no publishing +credentials. ## Layout -- `apps/site` is the Astro site: pages under `src/pages`, layouts under `src/layouts`, Tailwind in - `src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/` +- `apps/app` is the Vite app: source under `src/`, unit tests under `tests/`, and end-to-end tests + under `tests/e2e/` +- `apps/deploy` holds the `cf` Worker configuration and reads `apps/app/dist` after the app build - `packages/` for libraries the site shares with other apps -`pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; -`.github/workflows/deploy.yml` does the same on every push to `main`. +## Releases + +Follow the common +[web-release setup guide](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/how-to/set-up-a-web-release.md) +for the account, permanent origins, protected preview, scoped credentials, and production readiness. +This app's canonical configuration is `apps/deploy/cloudflare.config.ts`; record its actual +production requirements in `apps/deploy/platform.json` before publishing. Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from [`LasVegasForTransit/repository-tooling`](https://github.com/LasVegasForTransit/repository-tooling). diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json index 1acf948..11e8fe6 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json @@ -10,17 +10,19 @@ "lint": "eslint . --max-warnings 0", "check-types": "tsc --noEmit", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "worker:smoke": "tsx scripts/release-smoke.ts", + "worker:test:browser": "playwright test" }, "dependencies": { "react": "catalog:", "react-dom": "catalog:" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.5.4", - "@lasvegasfortransit/playwright-config": "0.5.4", - "@lasvegasfortransit/typescript-config": "0.5.4", - "@lasvegasfortransit/vitest-config": "0.5.4", + "@lasvegasfortransit/eslint-config": "0.7.1", + "@lasvegasfortransit/playwright-config": "0.7.1", + "@lasvegasfortransit/typescript-config": "0.7.1", + "@lasvegasfortransit/vitest-config": "0.7.1", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts index a4a6fc2..6d9105c 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts @@ -2,10 +2,13 @@ import { defineConfig } from '@playwright/test'; import { sharedConfig } from '@lasvegasfortransit/playwright-config'; -const url = 'http://127.0.0.1:4173'; +const remote = process.env.PLAYWRIGHT_BASE_URL; +const url = remote ?? 'http://127.0.0.1:4173'; export default defineConfig({ ...sharedConfig, use: { ...sharedConfig.use, baseURL: url }, - webServer: { command: 'pnpm preview', url, reuseExistingServer: !process.env.CI }, + ...(remote + ? {} + : { webServer: { command: 'pnpm preview', url, reuseExistingServer: !process.env.CI } }), }); diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/scripts/release-smoke.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/scripts/release-smoke.ts new file mode 100644 index 0000000..7f0b1b6 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/scripts/release-smoke.ts @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url'; +import { chromium } from '@playwright/test'; +import { + readReleaseConfiguration, + runReleaseSmoke, +} from '@lasvegasfortransit/web-platform/release'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +await runReleaseSmoke(await readReleaseConfiguration(root), chromium); diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts index f6f2d85..5189051 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/access'; import { expectNoAccessibilityViolations } from '@lasvegasfortransit/playwright-config/accessibility'; import { monitorPageHealth } from '@lasvegasfortransit/playwright-config/page-health'; diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/support/access.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/support/access.ts new file mode 100644 index 0000000..2284fb6 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/support/access.ts @@ -0,0 +1,15 @@ +import { test as base } from '@playwright/test'; +import { accessCredentials, scopeBrowserAccess } from '@lasvegasfortransit/web-platform/release'; + +export { expect } from '@playwright/test'; +export const test = base.extend({ + context: async ({ context, baseURL }, use) => { + if (baseURL) + await scopeBrowserAccess(context, new URL(baseURL).origin, accessCredentials(process.env)); + try { + await use(context); + } finally { + await context.unrouteAll({ behavior: 'wait' }); + } + }, +}); diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json index 9a770fe..d718c70 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["vite/client", "node"] }, - "include": ["src", "tests", "*.config.ts"] + "include": ["src", "tests", "scripts", "*.config.ts"] } diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc b/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc deleted file mode 100644 index 9c876a5..0000000 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - // The Worker name. Rename it before the first deploy. - "name": "lvbt-app", - "compatibility_date": "2026-08-31", - "preview_urls": true, - "assets": { - "directory": "./dist", - "not_found_handling": "single-page-application", - }, - "observability": { - "enabled": true, - }, -} diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts new file mode 100644 index 0000000..f5541d9 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'cf/config'; + +// Set CLOUDFLARE_ACCOUNT_ID in the deploy environment before publishing. +export default defineConfig({ + worker: { + name: 'lvbt-app', + compatibilityDate: '2026-08-31', + previewUrls: true, + assets: { + notFoundHandling: 'single-page-application', + }, + observability: { + enabled: true, + }, + }, +}); diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js new file mode 100644 index 0000000..fe2b986 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from '@lasvegasfortransit/eslint-config/base'; + +export default config; diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json new file mode 100644 index 0000000..3fa5396 --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json @@ -0,0 +1,21 @@ +{ + "name": "@lasvegasfortransit/deploy", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "test": "node --test", + "validate": "cf deploy --dry-run" + }, + "devDependencies": { + "@lasvegasfortransit/eslint-config": "0.7.1", + "@lasvegasfortransit/app": "workspace:*", + "@lasvegasfortransit/typescript-config": "0.7.1", + "cf": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json new file mode 100644 index 0000000..424aa9e --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@lasvegasfortransit/typescript-config/base.json", + "include": ["*.config.ts"] +} diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts new file mode 100644 index 0000000..fa36cac --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts @@ -0,0 +1,8 @@ +import { defineWranglerConfig } from 'wrangler/experimental-config'; + +export default defineWranglerConfig({ + types: { + generate: false, + }, + assetsDirectory: '../app/dist', +}); diff --git a/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md b/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md index 55588ff..c3eaa61 100644 --- a/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md +++ b/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md @@ -51,7 +51,7 @@ request. The required status is named `Validate`. deploy this repository, with a fix printed for anything missing. **Platform manifest**: `platform.json`, next to an app's production -`wrangler.jsonc`, which lists everything the app needs in production: its database, bucket, bot -check, admin sign-in, email domain, secrets, and the values that must never be set there. +configuration, which lists everything the app needs in production: its database, bucket, bot check, +admin sign-in, email domain, secrets, and the values that must never be set there. `pnpm preflight --production` checks production against it, and `pnpm bootstrap --production` sets up what is missing. diff --git a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md index dc39c88..b3178d8 100644 --- a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md +++ b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md @@ -1,81 +1,12 @@ # Start here -This tutorial takes you from a fresh clone to a passing check and a first commit. It exists so that -your first hour is spent on the project, not on tooling. You need to be able to run a command in a -terminal; nothing else is assumed. +Follow this repository's [README](../../../README.md) for its application layout and local +configuration. Run `pnpm bootstrap`, then `pnpm dev` to start development. -## Before you start +The shared +[developer workflow](https://github.com/LasVegasForTransit/repository-tooling/blob/main/docs/reference/developer-workflow.md) +defines validation, contributions, audits, staging and explicit production promotion. Follow the +mandatory contribution helper in [AGENTS.md](../../../AGENTS.md) when opening a pull request. -- Node.js 24.20 or newer on the 24 line (the current long-term support release). Check with - `node --version`; install from [nodejs.org](https://nodejs.org) if needed. -- git, and a GitHub account with access to this repository. -- The GitHub CLI (`gh`), signed in with `gh auth login`. Issues and pull requests are created - through it. - -pnpm (the package manager, see the [glossary](../reference/glossary.md#pnpm)) installs itself from -the version pinned in `package.json` the first time you run it, through Corepack. If -`pnpm --version` fails, run `corepack enable` once. - -## 1. Clone and bootstrap - -```bash -git clone git@github.com:LasVegasForTransit/.git -cd -pnpm bootstrap -``` - -`bootstrap` installs dependencies, points git at the repository's hooks, and runs preflight, which -prints one line per check: - -```text - ok Node.js 24.20.0 satisfies ^24.20.0 - ok pnpm 11.25.0 matches packageManager - ok dependencies node_modules is present - ok git hooks core.hooksPath is .githooks - ok commit scopes .lvbt/commit-scopes.txt is present - ok GitHub CLI gh is installed and signed in - ok Cloudflare wrangler is signed in; deployables: apps/app -preflight: all 7 checks passed -``` - -A failing line prints the command that fixes it. Run it, then `pnpm preflight` again. - -## 2. Run the check - -```bash -pnpm check -``` - -This is the same command CI runs: formatting, documentation links, the repository-shape rules, then -lint, typecheck, and tests for every package. On a fresh clone it passes. When it fails, the output -names the package and file; `pnpm check:fix` repairs everything a machine can (formatting and -auto-fixable lint findings). - -## 3. Make a change and commit it - -Edit a file, then: - -```bash -git add -git commit -``` - -The commit hook checks the message. Subjects look like `type(scope): description` where `type` is -one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `style`, `revert`, and -the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or `fix` -commit also needs a body that says what changed for a person using the product and why. The hook -tells you exactly what to change when it rejects a message. - -## 4. Open a pull request - -Push your branch and open the pull request with the GitHub CLI or the web UI. The pull request -template asks for a TL;DR, an overview of changes, and follow-ups. CI runs `pnpm check` as the -`Validate` status, and a maintainer reviews. - -## Where to go next - -- `pnpm dev` starts the app; `pnpm build` builds it; `pnpm run deploy` publishes it to Cloudflare - (`.github/workflows/deploy.yml` does that on every push to `main`). -- The [glossary](../reference/glossary.md) defines every tool and acronym used here. -- `AGENTS.md` at the repository root is the contract for coding agents; it is a good summary of the - rules for people too. +The [glossary](../reference/glossary.md) explains the tools this application uses. Extend the shared +standard through application checks and configuration; implement reusable process changes upstream. diff --git a/.lvbt/web-platform/examples/with-vite-react/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json index a9a48da..3b251f9 100644 --- a/.lvbt/web-platform/examples/with-vite-react/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/package.json @@ -17,21 +17,28 @@ "check": "pnpm format:check && markdownlint-cli2 && lvbt check && turbo run lint check-types test validate", "check:fix": "pnpm format && markdownlint-cli2 --fix && turbo run lint -- --fix", "prepare": "git config --local core.hooksPath .githooks 2>/dev/null || true", - "deploy": "lvbt deploy" + "deploy": "lvbt deploy", + "audit": "lvbt audit", + "promote": "lvbt promote", + "postinstall": "lvbt setup-record", + "validate": "lvbt check secrets", + "security:dependencies": "pnpm audit --audit-level=high" }, "lint-staged": { "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.4", - "@lasvegasfortransit/prettier-config": "0.5.4", + "@lasvegasfortransit/cli": "0.7.1", + "@lasvegasfortransit/prettier-config": "0.7.1", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", "prettier": "catalog:", "turbo": "catalog:", "typescript": "catalog:", - "wrangler": "catalog:" + "wrangler": "catalog:", + "tsx": "catalog:", + "@lasvegasfortransit/web-platform": "0.7.1" }, "packageManager": "pnpm@11.25.0", "engines": { diff --git a/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml b/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml index c4b29ca..f931807 100644 --- a/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml +++ b/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml @@ -16,8 +16,16 @@ minimumReleaseAge: 1440 # Temporary transitive pins for published security fixes not yet selected by # their direct dependents. overrides: - sharp: 0.35.4 + brace-expansion: 5.0.12 + braces: npm:@dieub/braces-depth-guard@3.0.3-pn.3 + devalue: 5.9.4 + fast-uri: 3.1.8 + http-cache-semantics: 4.3.0 + sharp: 0.35.5 + 'undici@^7.0.0': 7.29.1 + 'undici@^8.0.0': 8.10.2 smol-toml: 1.8.0 + source-map-js: 1.2.2 svgo: 4.1.0 # The organization version baseline. Every LVBT repository carries this same @@ -61,5 +69,9 @@ catalog: typescript-eslint: 8.69.0 vite: 8.2.2 vitest: 4.1.11 - wrangler: 4.127.1 + cf: 1.0.0-beta.5 + wrangler: 4.141.0 zod: 4.5.4 + +# Preflight is read-only; bootstrap validates before installing. +verifyDepsBeforeRun: false diff --git a/.lvbt/web-platform/examples/with-vite-react/turbo.json b/.lvbt/web-platform/examples/with-vite-react/turbo.json index fdc643b..c28c294 100644 --- a/.lvbt/web-platform/examples/with-vite-react/turbo.json +++ b/.lvbt/web-platform/examples/with-vite-react/turbo.json @@ -2,6 +2,7 @@ "$schema": "https://turborepo.dev/schema.json", "ui": "tui", "globalEnv": ["CI"], + "globalDependencies": [".lvbt/web-platform.json", ".lvbt/tooling.json", ".github/workflows/**"], "tasks": { "build": { "dependsOn": ["^build"], @@ -33,6 +34,20 @@ "dependsOn": ["build"], "cache": false, "persistent": true + }, + "//#validate": { + "dependsOn": ["//#security:dependencies"], + "cache": false + }, + "//#security:dependencies": { + "cache": false } - } + }, + "globalPassThroughEnv": [ + "PLAYWRIGHT_BASE_URL", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + "LVBT_PRODUCTION_URL", + "LVBT_PREVIEW_URL" + ] } diff --git a/.lvbt/web-platform/packages/cli/catalog.json b/.lvbt/web-platform/packages/cli/catalog.json index 4940880..65e6cfc 100644 --- a/.lvbt/web-platform/packages/cli/catalog.json +++ b/.lvbt/web-platform/packages/cli/catalog.json @@ -1,6 +1,19 @@ { "packageManager": "pnpm@11.25.0", "node": "^24.20.0", + "overrides": { + "brace-expansion": "5.0.12", + "braces": "npm:@dieub/braces-depth-guard@3.0.3-pn.3", + "devalue": "5.9.4", + "fast-uri": "3.1.8", + "http-cache-semantics": "4.3.0", + "sharp": "0.35.5", + "undici@^7.0.0": "7.29.1", + "undici@^8.0.0": "8.10.2", + "smol-toml": "1.8.0", + "source-map-js": "1.2.2", + "svgo": "4.1.0" + }, "catalog": { "@axe-core/playwright": "4.13.0", "@astrojs/check": "0.9.10", @@ -38,7 +51,8 @@ "typescript-eslint": "8.69.0", "vite": "8.2.2", "vitest": "4.1.11", - "wrangler": "4.127.1", + "cf": "1.0.0-beta.5", + "wrangler": "4.141.0", "zod": "4.5.4" } } diff --git a/.lvbt/web-platform/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index 447dc57..c150bff 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.5.4", + "version": "0.7.1", "description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the production platform setup, the shared git hooks, and the lvbt-contributions agent plugin.", "license": "MIT", "type": "module", @@ -21,9 +21,17 @@ "hooks", "plugins", "catalog.json", - "platform.schema.json" + "platform.schema.json", + "tooling.schema.json" ], "engines": { "node": ">=24" + }, + "dependencies": { + "tsx": "4.23.13" + }, + "exports": { + "./env": "./src/lib/env-file.mjs", + "./local": "./src/lib/local-environment.mjs" } } diff --git a/.lvbt/web-platform/packages/cli/platform.schema.json b/.lvbt/web-platform/packages/cli/platform.schema.json index 5cf923b..8df4c2e 100644 --- a/.lvbt/web-platform/packages/cli/platform.schema.json +++ b/.lvbt/web-platform/packages/cli/platform.schema.json @@ -20,47 +20,81 @@ "minLength": 1, "description": "What people call the production site, such as lvwwd.org. Reports and token names use it." }, - "cloudflare": { "$ref": "#/$defs/cloudflare" }, + "cloudflare": { + "$ref": "#/$defs/cloudflare" + }, "d1": { "type": "array", "description": "D1 databases the production Worker binds.", - "items": { "$ref": "#/$defs/d1" } + "items": { + "$ref": "#/$defs/d1" + } }, "r2": { "type": "array", "description": "R2 buckets the production Worker binds.", - "items": { "$ref": "#/$defs/r2" } + "items": { + "$ref": "#/$defs/r2" + } }, "turnstile": { "type": "array", "description": "Turnstile widgets, each feeding a site-key var and a secret on the Worker.", - "items": { "$ref": "#/$defs/turnstile" } + "items": { + "$ref": "#/$defs/turnstile" + } }, "access": { "type": "array", "description": "Cloudflare Access applications that put paths behind a sign-in, each feeding the team-domain and audience secrets the Worker checks.", - "items": { "$ref": "#/$defs/access" } + "items": { + "$ref": "#/$defs/access" + } }, "email": { "type": "array", "description": "Domains the Worker sends email from, with the DNS records their provider needs.", - "items": { "$ref": "#/$defs/email" } + "items": { + "$ref": "#/$defs/email" + } }, "secrets": { "type": "array", "description": "Secret values, on the Worker or in a GitHub environment. Values never appear in this file.", - "items": { "$ref": "#/$defs/secret" } + "items": { + "$ref": "#/$defs/secret" + } }, "vars": { "type": "array", "description": "Plain-text Worker vars that must be in the wrangler config's vars.", - "items": { "$ref": "#/$defs/var" } + "items": { + "$ref": "#/$defs/var" + } + }, + "github": { + "$ref": "#/$defs/github" }, - "github": { "$ref": "#/$defs/github" }, "forbidden": { "type": "array", "description": "Names that must never be set in production, as a secret or a var.", - "items": { "$ref": "#/$defs/forbidden" } + "items": { + "$ref": "#/$defs/forbidden" + } + }, + "analyticsEngine": { + "type": "array", + "description": "Production Analytics Engine dataset bindings; readiness compares repository and deployed Worker settings, without writing events.", + "items": { + "$ref": "#/$defs/analyticsEngine" + } + }, + "rateLimits": { + "type": "array", + "description": "Production Worker rate-limit namespaces and settings. Namespace identifiers share counters across Workers in an account.", + "items": { + "$ref": "#/$defs/rateLimit" + } } }, "$defs": { @@ -77,25 +111,41 @@ "type": "array", "minItems": 1, "description": "Numbered, click-by-click steps to find or create the value. Write each as one complete sentence. Never ask for a second copy before the first is pasted; the last step copies this value for the prompt.", - "items": { "type": "string", "minLength": 1 } + "items": { + "type": "string", + "minLength": 1 + } }, "cloudflare": { "type": "object", "additionalProperties": false, - "required": ["accountId", "zone", "worker"], + "required": ["zone", "worker"], "properties": { "accountId": { "type": "string", "pattern": "^[0-9a-f]{32}$", "description": "The Cloudflare account that owns the Worker and the zone." }, + "accountIdEnv": { + "const": "CLOUDFLARE_ACCOUNT_ID", + "description": "Read the account ID from CLOUDFLARE_ACCOUNT_ID at production check or setup time." + }, "zone": { "type": "object", "additionalProperties": false, - "required": ["name", "id"], + "required": ["name"], "properties": { - "name": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+$" }, - "id": { "type": "string", "pattern": "^[0-9a-f]{32}$" } + "name": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+$" + }, + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "idEnv": { + "$ref": "#/$defs/name" + } } }, "worker": { @@ -107,6 +157,26 @@ "type": "string", "minLength": 1, "description": "The production wrangler config, relative to this file. Defaults to wrangler.jsonc." + }, + "cloudflareConfig": { + "type": "string", + "minLength": 1, + "pattern": "(^|/)cloudflare\\.config\\.ts$", + "description": "The canonical cloudflare.config.ts project config, relative to this file. When set, preflight and deploy use cf instead of Wrangler." + }, + "domains": { + "type": "array", + "minItems": 1, + "description": "Public custom domains that must belong to the production Worker and declared zone.", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+$" + } + }, + "environment": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$", + "description": "Explicit primary deployment mode for this manifest. Every Worker, binding, var and secret requirement refers to this mode; omission selects production." } } }, @@ -115,12 +185,22 @@ "additionalProperties": false, "required": ["binding", "name"], "properties": { - "binding": { "$ref": "#/$defs/name" }, - "name": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "binding": { + "$ref": "#/$defs/name" + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, "migrations": { "type": "string", "minLength": 1, "description": "The migrations directory, relative to this file. Every .sql file in it must be applied." + }, + "environment": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "Named deployment mode containing this binding. Omit for production." } } }, @@ -129,8 +209,18 @@ "additionalProperties": false, "required": ["binding", "name"], "properties": { - "binding": { "$ref": "#/$defs/name" }, - "name": { "type": "string", "pattern": "^[a-z0-9-]+$" } + "binding": { + "$ref": "#/$defs/name" + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "environment": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "Named deployment mode containing this binding. Omit for production." + } } }, "turnstile": { @@ -146,7 +236,10 @@ "domains": { "type": "array", "minItems": 1, - "items": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+$" } + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+$" + } }, "mode": { "enum": ["managed", "non-interactive", "invisible"], @@ -183,7 +276,10 @@ "type": "array", "minItems": 1, "description": "Host and path pairs such as example.org/admin and example.org/admin/*. A path does not cover the paths under it, and a wildcard does not cover its parent, so list both.", - "items": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+(/\\S*)?$" } + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+(/\\S*)?$" + } }, "sessionDuration": { "type": "string", @@ -212,7 +308,10 @@ "emails": { "type": "array", "minItems": 1, - "items": { "type": "string", "pattern": "^[^@\\s]+@[a-z0-9.-]+\\.[a-z]+$" } + "items": { + "type": "string", + "pattern": "^[^@\\s]+@[a-z0-9.-]+\\.[a-z]+$" + } } } }, @@ -240,6 +339,10 @@ "enum": ["resend"], "description": "The sending service. It decides which DNS records are checked." }, + "dnsProfile": { + "enum": ["ses", "forge"], + "description": "The DNS layout Resend shows for this domain. Defaults to the legacy SES MX/SPF layout; choose forge for Resend's rsend and send CNAME records." + }, "region": { "type": "string", "pattern": "^[a-z]{2}-[a-z]+-[0-9]$", @@ -256,13 +359,17 @@ "additionalProperties": false, "required": ["name", "purpose"], "properties": { - "name": { "$ref": "#/$defs/name" }, + "name": { + "$ref": "#/$defs/name" + }, "purpose": { "type": "string", "minLength": 1, "description": "What production uses it for, in one sentence." }, - "use": { "$ref": "#/$defs/use" }, + "use": { + "$ref": "#/$defs/use" + }, "neededFor": { "type": "string", "minLength": 1, @@ -272,14 +379,19 @@ "type": "array", "minItems": 1, "description": "Where the value is stored: worker, or github:. Defaults to worker.", - "items": { "type": "string", "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" } + "items": { + "type": "string", + "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" + } }, "url": { "type": "string", "pattern": "^https://", "description": "The page to open first. Setup offers to open it." }, - "steps": { "$ref": "#/$defs/steps" }, + "steps": { + "$ref": "#/$defs/steps" + }, "generate": { "type": "boolean", "description": "Setup mints a random value instead of asking for one." @@ -301,6 +413,15 @@ "type": "string", "minLength": 1, "description": "What a valid value looks like, shown when a pasted value does not match." + }, + "listOnly": { + "type": "boolean", + "description": "Document a future requirement without creating or rotating a credential." + }, + "afterSet": { + "type": "string", + "minLength": 1, + "description": "Instructions for a maintainer to update an external consumer after every declared target holds the credential. Setup offers one explicit, default-off copy of the stored value." } } }, @@ -309,10 +430,20 @@ "additionalProperties": false, "required": ["name", "purpose"], "properties": { - "name": { "$ref": "#/$defs/name" }, - "purpose": { "type": "string", "minLength": 1 }, - "use": { "$ref": "#/$defs/use" }, - "neededFor": { "type": "string", "minLength": 1 } + "name": { + "$ref": "#/$defs/name" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "use": { + "$ref": "#/$defs/use" + }, + "neededFor": { + "type": "string", + "minLength": 1 + } } }, "github": { @@ -324,6 +455,17 @@ "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", "description": "owner/name of the repository whose environments hold github: secrets." + }, + "variables": { + "type": "array", + "description": "Required public repository Actions variables. Values may be compared, but setup never overwrites them automatically.", + "items": { + "$ref": "#/$defs/githubVariable" + } + }, + "governance": { + "type": "boolean", + "description": "Report organization governance using the shared policy; new requirements warn in 0.7." } } }, @@ -332,7 +474,9 @@ "additionalProperties": false, "required": ["name", "reason"], "properties": { - "name": { "$ref": "#/$defs/name" }, + "name": { + "$ref": "#/$defs/name" + }, "reason": { "type": "string", "minLength": 1, @@ -341,13 +485,88 @@ "targets": { "type": "array", "minItems": 1, - "items": { "type": "string", "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" } + "items": { + "type": "string", + "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" + } }, "severity": { "enum": ["error", "warning"], "description": "error fails the check; warning only reports it. Defaults to error." } } + }, + "githubVariable": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purpose"], + "properties": { + "name": { + "$ref": "#/$defs/name" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "use": { + "$ref": "#/$defs/use" + }, + "from": { + "enum": ["cloudflare.accountId"] + }, + "environment": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "The GitHub environment that owns this variable; omit for repository variables." + } + } + }, + "analyticsEngine": { + "type": "object", + "additionalProperties": false, + "required": ["binding", "name"], + "properties": { + "binding": { + "$ref": "#/$defs/name" + }, + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "rateLimit": { + "type": "object", + "additionalProperties": false, + "required": ["binding", "namespace", "simple"], + "properties": { + "binding": { + "$ref": "#/$defs/name" + }, + "namespace": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "simple": { + "type": "object", + "additionalProperties": false, + "required": ["limit", "period"], + "properties": { + "limit": { + "type": "integer", + "minimum": 1 + }, + "period": { + "type": "integer", + "enum": [10, 60] + } + } + } + } } } } diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json index 981b151..38058b2 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "lvbt-contributions", - "version": "0.5.4", - "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", + "version": "0.7.1", + "description": "Create readable LVBT contributions and reconcile trusted audit and recurring issues through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", "url": "https://lasvegasfortransit.org" }, "repository": "https://github.com/LasVegasForTransit/repository-tooling", "license": "MIT", - "keywords": ["github", "issues", "pull-requests"] + "keywords": ["github", "issues", "pull-requests", "audits", "recurring-reports"] } diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json index 34a7e6f..9e69a2a 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json @@ -1,19 +1,19 @@ { "name": "lvbt-contributions", - "version": "0.5.4", - "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", + "version": "0.7.1", + "description": "Create readable LVBT contributions and reconcile trusted audit and recurring issues through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", "url": "https://lasvegasfortransit.org" }, "repository": "https://github.com/LasVegasForTransit/repository-tooling", "license": "MIT", - "keywords": ["github", "issues", "pull-requests"], + "keywords": ["github", "issues", "pull-requests", "audits", "recurring-reports"], "hooks": "./hooks/codex-hooks.json", "interface": { "displayName": "LVBT Contributions", "shortDescription": "Create readable issues and pull requests", - "longDescription": "Guides agents through the LVBT issue and pull request workflow and blocks direct creation that bypasses the shared templates.", + "longDescription": "Guides LVBT contributions, verifies audit and recurring workflow evidence before issue lifecycle updates, and blocks direct creation that bypasses the shared templates.", "developerName": "Las Vegans for Better Transit", "category": "Developer Tools", "capabilities": ["Write"], diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs index 4ad064c..e6eb3ea 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs @@ -1,8 +1,11 @@ #!/usr/bin/env node +import { hasLabels } from '../../../src/lib/contributions/issue-apply.mjs'; + import { readFile } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import { commitSubjectError } from './validate-commit-subject.mjs'; +import { ownershipLabel } from '../../../src/lib/contributions/ownership.mjs'; const placeholderPattern = /\[(?:describe|optional|subheading|more subheadings|future issue title)[^\]]*\]/i; @@ -85,8 +88,36 @@ function verifyStored(expected, stored) { } const options = parseArguments(process.argv.slice(2)); +if (options.kind === 'recurring') { + try { + const { reportRecurring } = await import('../../../src/lib/contributions/recurring.mjs'); + const result = await reportRecurring({ + cwd: process.cwd(), + input: options.input, + dryRun: options.dryRun, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(0); + } catch (error) { + fail(error.message, 2); + } +} +if (options.kind === 'audit') { + try { + const { reportAudit } = await import('../../../src/lib/audit/report.mjs'); + const result = await reportAudit({ + cwd: process.cwd(), + input: options.input, + dryRun: options.dryRun, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(0); + } catch (error) { + fail(error.message, 2); + } +} if (!['issue', 'pr'].includes(options.kind)) { - fail('Usage: github-create issue|pr [options]', 2); + fail('Usage: github-create issue|pr|audit|recurring [options]', 2); } if (!options.title?.trim()) fail('A non-empty title is required.'); if (options.title !== options.title.trim()) fail('The title must be trimmed.'); @@ -123,12 +154,51 @@ if (options.kind === 'issue') { } } +let labels; +if (options.recurring_key || options.recurring_labels) { + if ( + options.kind !== 'issue' || + !/^[a-z0-9][a-z0-9-]*$/.test(options.recurring_key ?? '') || + options.audit_check || + options.audit_target + ) + fail('Recurring ownership requires one valid issue key.', 2); + let additional; + try { + additional = JSON.parse(options.recurring_labels ?? '[]'); + } catch { + fail('Invalid recurring labels.', 2); + } + if ( + !Array.isArray(additional) || + additional.some( + (name) => + typeof name !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9 .:_-]{0,49}$/.test(name) || + ownershipLabel(name), + ) + ) + fail('Invalid recurring labels.', 2); + labels = [ + ...new Set([label, 'recurring-owned', `recurring:${options.recurring_key}`, ...additional]), + ]; +} +if (options.audit_check || options.audit_target) { + if (options.kind !== 'issue' || options.type !== 'bug') + fail('Audit ownership requires a bug issue.', 2); + if (!['links', 'lighthouse', 'dependencies'].includes(options.audit_check)) + fail('Invalid audit check.', 2); + if (!['local', 'production'].includes(options.audit_target)) fail('Invalid audit target.', 2); + labels = [label, 'audit-owned', `audit:${options.audit_check}`, `target:${options.audit_target}`]; +} + const preview = { valid: true, kind: options.kind, title: options.title, body, ...(label ? { label } : {}), + ...(labels ? { labels } : {}), }; if (options.dryRun) { process.stdout.write( @@ -146,11 +216,22 @@ if (options.kind === 'issue') { options.title, '--body-file', options.body_file, - '--label', - label, + ...(labels ?? [label]).flatMap((value) => ['--label', value]), + ...(options.repo ? ['--repo', options.repo] : []), ]); - const stored = JSON.parse(run('gh', ['issue', 'view', url, '--json', 'number,title,body,url'])); + const stored = JSON.parse( + run('gh', [ + 'issue', + 'view', + url, + '--json', + labels ? 'number,title,body,url,labels' : 'number,title,body,url', + ...(options.repo ? ['--repo', options.repo] : []), + ]), + ); verifyStored(preview, stored); + if (labels && !hasLabels(stored.labels, labels)) + fail('GitHub stored issue labels differ from the verified preview.', 2); preview.number = stored.number; preview.url = stored.url; } else { diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/skills/github-contribution/SKILL.md b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/skills/github-contribution/SKILL.md index 3d8c189..f0bb61e 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/skills/github-contribution/SKILL.md +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/skills/github-contribution/SKILL.md @@ -42,3 +42,59 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/github-create.mjs" pr \ Add `--json` for machine-readable output. Remove `--dry-run` only after the preview is correct. Do not replace the helper with `gh issue create`, `gh pr create`, `gh api`, or a GitHub connector. + +## Verified audit automation + +Scheduled and manual audit workflows on the default branch use the same helper: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/github-create.mjs" audit \ + --input lvbt-audit-report.json --dry-run --json +``` + +Inspect the complete actions, titles, bug headings, reproduction commands, findings, and artifact +links before removing `--dry-run`. The helper validates the GitHub run, its configured workflow, and +the downloaded report artifact before changing issues. It rejects stale runs and ambiguous +ownership. Each check and target owns one issue with the visible `audit-owned`, `audit:`, and +`target:` labels. Failure updates or reopens that issue; a corresponding verified passing +result closes it. Errors, skipped checks, and absent results never close an issue. The attempt's +measurement job must have successful checkout, setup and enabled prerequisites, a completed audit +command, and successful evidence upload. Failed build, browser installation, cancelled, timed-out or +skipped execution cannot establish recovery. A failed audit command may still report actual findings +when its retained results agree with that command's outcome. + +The workflow must serialize reporting with one concurrency group per repository and must upload +`lvbt-audit-report.json` to the configured audit artifact before reporting. Local execution and +untrusted pull request runs may produce reports but cannot maintain audit issues. Writes require the +current executor's repository, run, attempt, commit, branch and event to match the verified +evidence. Declarations must match `.lvbt/tooling.json` at that verified source commit. Read-only +local previews may inspect trusted evidence; altered local declarations cannot change its owner. + +## Verified recurring contributions + +Product report workflows use the same helper to maintain declared recurring issues: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/github-create.mjs" recurring \ + --input lvbt-recurring-issues.json --dry-run --json +``` + +Declare each stable key under `contributions.recurring` in `.lvbt/tooling.json`, with its trusted +workflow, evidence artifact name, readable title, bug or feature type, and visible labels. Set +`pin: true` only for a report that should remain pinned. Preserve the template headings in each +complete body. The helper checks the actual default-branch schedule or manual run, attempt, source +commit, latest run, and exact uploaded contribution actions before previewing or writing. + +Upload `lvbt-recurring-issues.json` before reporting and serialize issue maintenance with one +repository concurrency group. Inspect the complete preview before removing `--dry-run`. An `open` +action creates, updates or reopens its owned issue. Only an explicit verified `resolved` action +closes the corresponding issue. Missing, errored and skipped actions never close issues. Every write +verifies stored title, body, state, labels and the requested pin. Reporting failures fail the +workflow. The visible `recurring-owned` and `recurring:` labels identify ownership; pull +requests and other automation's issues cannot be maintained by this route. + +For a reviewed migration, `adoptExisting: true` permits adopting one exact title and configured +label match. The helper requires GitHub Actions bot authorship, no other automation ownership and an +update timestamp no later than the verified run's start. Ambiguous or unverifiable adoption fails; +it never silently replaces the existing stable issue. Local and pull request runs cannot maintain +recurring issues. diff --git a/.lvbt/web-platform/packages/cli/src/cli.mjs b/.lvbt/web-platform/packages/cli/src/cli.mjs index 93fe194..8de5a90 100755 --- a/.lvbt/web-platform/packages/cli/src/cli.mjs +++ b/.lvbt/web-platform/packages/cli/src/cli.mjs @@ -7,7 +7,7 @@ * lvbt preflight confirm this machine can build and deploy the repository; * with --production, also report production's readiness * lvbt check the shared repository-shape rules (filenames, contract, debt) - * lvbt deploy build, then `wrangler deploy` for every app that has a config + * lvbt deploy build, then deploy every configured Cloudflare app * * A repository's package.json maps its standard scripts to these, so * `pnpm bootstrap`, `pnpm preflight`, `pnpm check`, and `pnpm run deploy` behave @@ -16,12 +16,20 @@ */ import { CliError, parseArguments } from './lib/arguments.mjs'; import { check } from './lib/check/index.mjs'; -import { bootstrap, deploy, preflight } from './lib/operate.mjs'; +import { bootstrap, deploy, preflight, setupRecord } from './lib/operate.mjs'; +import { audit } from './lib/audit/index.mjs'; +import { release } from './lib/release/index.mjs'; +import { promote } from './lib/release/promote.mjs'; const usage = `Usage: lvbt bootstrap [--production [--filter ] [--rotate [,...]]] lvbt preflight [--production [--filter ]] - lvbt check [filenames|contract|debt|platform ...] [--staged] + lvbt setup-record (pnpm postinstall only) + lvbt check [standard|filenames|contract|debt|platform ...] [--staged] + lvbt audit [links|lighthouse|dependencies] [--target local|production] [--json] [--output file] + lvbt audit report --input file [--dry-run] + lvbt release [--app name] [options] + lvbt promote [--app name] [--run-id id] lvbt deploy [--filter ] [--dry-run] Options: @@ -31,17 +39,27 @@ Options: --filter For deploy and --production: only the app directory named (apps/site) --rotate For bootstrap --production: replace the named secrets' stored values on every target. Without it, a value that is already set is kept - --dry-run For deploy: build, then run wrangler deploy --dry-run + --dry-run For deploy: build, then run each Cloudflare CLI with --dry-run `; -const commands = { bootstrap, preflight, check, deploy }; +const commands = { + bootstrap, + preflight, + check, + deploy, + audit, + promote, + release, + 'setup-record': setupRecord, +}; try { const { command, options } = parseArguments(process.argv.slice(2)); if (command === undefined || command === 'help') { process.stdout.write(usage); } else if (command in commands) { - await commands[command]({ cwd: process.cwd(), options }); + const result = await commands[command]({ cwd: process.cwd(), options }); + if (Number.isInteger(result)) process.exitCode = result; } else { throw new CliError(`Unknown command "${command}".\n${usage}`, 2); } diff --git a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs index 896adf7..fd88121 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs @@ -5,8 +5,42 @@ export class CliError extends Error { } } -const flags = new Set(['--dry-run', '--staged', '--production', '--help']); -const valued = new Set(['--filter', '--rotate']); +const flags = new Set([ + '--dry-run', + '--staged', + '--production', + '--help', + '--json', + '--protected', + '--public', + '--wait-for-propagation', +]); +const valued = new Set([ + '--action', + '--pr', + '--publication-mode', + '--protection', + '--app', + '--filter', + '--rotate', + '--target', + '--output', + '--input', + '--run-id', + '--expected-version', + '--directory', + '--commit', + '--release-id', + '--version', + '--repository', + '--run-file', + '--artifact-hash', + '--attestation-directory', + '--candidate-directory', + '--activation', + '--verification', + '--url', +]); /** ` [positional...] [--flag] [--option value]`. Unknown options are an error. */ export function parseArguments(argv) { @@ -16,11 +50,11 @@ export function parseArguments(argv) { for (let index = 0; index < rest.length; index += 1) { const argument = rest[index]; - if (argument === '--dry-run') options.dryRun = true; - else if (argument === '--staged') options.staged = true; - else if (argument === '--production') options.production = true; - else if (argument === '--help') return { command: 'help', options }; - else if (valued.has(argument)) { + if (argument === '--help') return { command: 'help', options }; + if (flags.has(argument)) { + const key = { '--dry-run': 'dryRun' }[argument] ?? argument.slice(2); + options[key] = true; + } else if (valued.has(argument)) { const value = rest[index + 1]; if (value === undefined || value.startsWith('--')) { throw new CliError(`Missing value for ${argument}.`, 2); diff --git a/.lvbt/web-platform/packages/cli/src/lib/audit/adapters.mjs b/.lvbt/web-platform/packages/cli/src/lib/audit/adapters.mjs new file mode 100644 index 0000000..1d2310e --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/audit/adapters.mjs @@ -0,0 +1,187 @@ +const fail = (message) => { + throw new Error(message); +}; +const finding = (title, diagnostic, location, url) => ({ + title, + diagnostic, + ...(location ? { location } : {}), + ...(url ? { url } : {}), +}); + +function structuredLinks(data) { + if (!Array.isArray(data.results) || (!data.results.length && !(data.checked > 0))) + fail('Link output has no checked URLs or results.'); + if ( + data.results.some( + (entry) => typeof entry.url !== 'string' || !['pass', 'fail', 'error'].includes(entry.status), + ) + ) + fail('Link results are malformed.'); + return { + status: data.results.some((entry) => entry.status === 'error') + ? 'error' + : data.results.some((entry) => entry.status === 'fail') + ? 'fail' + : 'pass', + findings: data.results + .filter((entry) => entry.status !== 'pass') + .map((entry) => + finding( + `Broken link: ${entry.url}`, + entry.diagnostic ?? 'Link validation failed.', + entry.source, + entry.url, + ), + ), + }; +} + +function lychee(data) { + if (!Number.isFinite(data.total) || data.total <= 0 || !Number.isFinite(data.errors)) + fail('Lychee output has no valid audit evidence.'); + const findings = Object.entries(data.error_map ?? {}).flatMap(([source, entries]) => + entries.map((entry) => + finding( + `Broken link: ${entry.url}`, + `${entry.status?.code ?? 'error'} ${entry.status?.text ?? 'Link validation failed.'}`, + source, + entry.url, + ), + ), + ); + if (data.errors > 0 && !findings.length) + findings.push( + finding('Link check failed', `${data.errors} links failed; see the raw audit artifact.`), + ); + return { status: data.errors > 0 ? 'fail' : 'pass', findings }; +} + +function lhci(data) { + const assertions = Array.isArray(data) ? data : data.assertions; + if ( + !Array.isArray(assertions) || + !assertions.length || + assertions.some((entry) => typeof entry.passed !== 'boolean') + ) + fail('LHCI output has no valid assertion results.'); + const failures = assertions.filter((entry) => !entry.passed && entry.level !== 'warn'); + return { + status: failures.length ? 'fail' : 'pass', + findings: failures.map((entry) => + finding( + `Lighthouse: ${entry.auditId ?? entry.name ?? 'assertion'}`, + `Actual ${entry.actual}; expected ${entry.operator ?? '>='} ${entry.expected}.`, + entry.url, + ), + ), + }; +} + +function lighthouse(data) { + if ( + !data.categories || + !Object.values(data.categories).some((category) => Number.isFinite(category.score)) + ) + fail('Lighthouse report has no valid category scores.'); + if (data.runtimeError) + return { + status: 'error', + findings: [ + finding( + 'Lighthouse could not complete', + data.runtimeError.message ?? data.runtimeError.code, + ), + ], + }; + const failed = Object.entries(data.categories).filter( + ([, category]) => Number.isFinite(category.score) && category.score < 0.9, + ); + const findings = failed.map(([name, category]) => + finding( + `Lighthouse ${name} score below 90`, + `${Math.round(category.score * 100)}/100; expected at least 90/100.`, + data.finalUrl, + ), + ); + if (failed.length) + for (const [id, entry] of Object.entries(data.audits ?? {})) { + if (Number.isFinite(entry.score) && entry.score < 0.9) + findings.push( + finding( + entry.title ?? id, + entry.displayValue ?? entry.description ?? `Score ${entry.score}.`, + data.finalUrl, + ), + ); + } + return { status: failed.length ? 'fail' : 'pass', findings }; +} + +function modernDependencies(data) { + const findings = []; + for (const [name, entry] of Object.entries(data.vulnerabilities ?? {})) { + for (const advisory of entry.via ?? []) { + if (typeof advisory === 'string') continue; + findings.push( + finding( + `${advisory.severity ?? entry.severity}: ${name} — ${advisory.title}`, + `Affected ${advisory.range ?? entry.range}; ${entry.fixAvailable ? 'a fix is available.' : 'review dependency remediation.'}`, + entry.nodes?.join(', '), + advisory.url, + ), + ); + } + } + return findings; +} + +function dependencies(data) { + const counts = data.metadata?.vulnerabilities; + if ( + !counts || + !Object.values(counts).length || + Object.values(counts).some((count) => !Number.isFinite(count) || count < 0) + ) + fail('pnpm audit output has no valid vulnerability evidence.'); + const findings = Object.values(data.advisories ?? {}).map((entry) => + finding( + `${entry.severity}: ${entry.module_name} — ${entry.title}`, + `${entry.vulnerable_versions ?? ''}. ${entry.recommendation ?? 'Update to a patched version.'}`, + entry.findings?.flatMap((match) => match.paths ?? []).join(', '), + entry.url, + ), + ); + findings.push(...modernDependencies(data)); + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + if (total && !findings.length) + findings.push( + finding( + 'Dependency vulnerabilities detected', + `${total} vulnerabilities reported; inspect the raw pnpm audit artifact for dependency paths and remediation.`, + ), + ); + return { status: total ? 'fail' : 'pass', findings }; +} + +export function parseAudit(check, format, data, exitCode = 0) { + if (!data || typeof data !== 'object') fail('Audit output must contain JSON evidence.'); + const adapters = { + links: { links: structuredLinks, lychee }, + lighthouse: { lighthouse, lhci }, + dependencies: { pnpm: dependencies }, + }; + const adapter = adapters[check]?.[format]; + if (!adapter) fail(`Unsupported audit adapter: ${check}/${format}.`); + const result = adapter(data); + if (exitCode !== 0 && result.status === 'pass') + return { + status: 'error', + findings: [ + finding( + 'Audit tool failed', + `Tool exited with status ${exitCode} despite reporting no findings.`, + ), + ], + }; + return result; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/audit/execution.mjs b/.lvbt/web-platform/packages/cli/src/lib/audit/execution.mjs new file mode 100644 index 0000000..416d2a9 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/audit/execution.mjs @@ -0,0 +1,82 @@ +const measurement = 'Execute configured audits and retain every result'; +const retained = 'Retain normalized and raw evidence'; +const optional = new Set([ + 'Install product audit browser', + 'Build local audit inputs', + 'Install pinned link checker', +]); +function complete(step, conclusions) { + return step?.status === 'completed' && conclusions.includes(step.conclusion); +} +function oneStep(steps, name) { + const matches = steps.filter((step) => step.name === name); + if (matches.length !== 1) + throw new Error('Audit execution step evidence is missing or ambiguous.'); + return matches[0]; +} + +function selectedJob(response) { + if ( + !Array.isArray(response.jobs) || + !Number.isSafeInteger(response.total_count) || + response.total_count !== response.jobs.length || + response.total_count >= 100 + ) + throw new Error('Audit execution job evidence is missing or truncated.'); + const matches = response.jobs.filter((job) => + job.steps?.some((step) => step.name === measurement), + ); + if (matches.length !== 1) + throw new Error('Audit execution job evidence is missing or ambiguous.'); + return matches[0]; +} +function verifyJobIdentity(job, report) { + if ( + String(job.run_id) !== String(report.run.id) || + job.head_sha !== report.commit || + job.status !== 'completed' || + !['success', 'failure'].includes(job.conclusion) + ) + throw new Error('Audit execution does not match the completed source run.'); +} +function verifyPrerequisites(steps, command) { + for (const name of ['Checkout executed source', 'Setup Node + pnpm']) { + const step = oneStep(steps, name); + if (step.number >= command.number || !complete(step, ['success'])) + throw new Error('Audit execution prerequisite did not succeed.'); + } + for (const step of steps.filter((step) => step.number < command.number)) { + if (!complete(step, optional.has(step.name) ? ['success', 'skipped'] : ['success'])) + throw new Error('Audit execution prerequisite did not succeed.'); + } +} +function verifySteps(job, report) { + const numbers = job.steps.map((step) => step.number); + if ( + numbers.some((number) => !Number.isSafeInteger(number) || number < 1) || + new Set(numbers).size !== numbers.length + ) + throw new Error('Audit execution step ordering is invalid.'); + const command = oneStep(job.steps, measurement); + const artifact = oneStep(job.steps, retained); + if ( + !complete(command, ['success', 'failure']) || + !complete(artifact, ['success']) || + artifact.number <= command.number + ) + throw new Error('Audit execution or retained evidence did not complete.'); + const failed = report.results.some((result) => result.status !== 'pass'); + if (command.conclusion !== (failed ? 'failure' : 'success')) + throw new Error('Audit execution conclusion disagrees with the retained results.'); + verifyPrerequisites(job.steps, command); +} +/** Findings may fail the audit command; failed prerequisites cannot establish passing evidence. */ +export async function verifyAuditExecution(report, gh) { + const response = await gh([ + 'api', + `repos/${report.repository}/actions/runs/${report.run.id}/attempts/${report.run.attempt}/jobs?per_page=100`, + ]); + const job = selectedJob(response); + verifyJobIdentity(job, report); + verifySteps(job, report); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/audit/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/audit/index.mjs new file mode 100644 index 0000000..cbc5bba --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/audit/index.mjs @@ -0,0 +1,193 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, writeFile, mkdir, realpath, rm, lstat } from 'node:fs/promises'; +import path from 'node:path'; +import { parseAudit } from './adapters.mjs'; +export { parseAudit } from './adapters.mjs'; + +export const checks = ['links', 'lighthouse', 'dependencies']; +export async function execute(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + return { + status: result.status ?? 127, + stdout: result.stdout ?? '', + stderr: result.error?.message ?? result.stderr ?? '', + }; +} + +async function existingAncestor(value) { + try { + return await realpath(value); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + const entry = await lstat(value).catch(() => null); + if (entry) throw new Error('Audit output contains an unresolved symlink.', { cause: error }); + return existingAncestor(path.dirname(value)); + } +} + +async function withinRepository(cwd, value, directory = false) { + if (typeof value !== 'string' || path.isAbsolute(value)) + throw new Error('Audit paths must be repository relative.'); + const resolved = path.resolve(cwd, value); + if (resolved !== cwd && !resolved.startsWith(`${cwd}${path.sep}`)) + throw new Error('Audit path escapes outside the repository.'); + // Existing symlinks must not permit commands or outputs outside the repository. + const canonical = directory ? await realpath(resolved) : await existingAncestor(resolved); + const root = await realpath(cwd); + if (canonical !== root && !canonical.startsWith(`${root}${path.sep}`)) + throw new Error('Audit path escapes outside the repository through a symlink.'); + return resolved; +} + +function runIdentity(env, commit) { + if (!env.GITHUB_RUN_ID) return null; + return { + id: env.GITHUB_RUN_ID, + attempt: Number(env.GITHUB_RUN_ATTEMPT ?? 1), + repository: env.GITHUB_REPOSITORY ?? '', + headSha: commit, + headBranch: env.GITHUB_REF_NAME ?? '', + event: env.GITHUB_EVENT_NAME ?? '', + workflow: + env.GITHUB_WORKFLOW_REF?.split('@')[0]?.replace(`${env.GITHUB_REPOSITORY}/`, '') ?? '', + url: `https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, + }; +} + +async function executeCheck({ cwd, env, run, name, adapter, result }) { + const commandCwd = adapter.cwd ? await withinRepository(cwd, adapter.cwd, true) : cwd; + const output = adapter.output ? await withinRepository(cwd, adapter.output) : null; + result.command = adapter.command; + if (adapter.output) result.artifacts.push(adapter.output); + if (output) await rm(output, { force: true }); + const response = await run(adapter.command[0], adapter.command.slice(1), { + cwd: commandCwd, + env: { ...env, ...adapter.env }, + }); + if (response.stderr.trim()) result.diagnostics.push(response.stderr.trim()); + const content = output ? await readFile(output, 'utf8') : response.stdout; + if (!content.trim()) + throw new Error(`Audit tool produced no JSON evidence (exit ${response.status}).`); + const parsed = parseAudit(name, adapter.format, JSON.parse(content), response.status); + result.status = parsed.status; + result.findings = parsed.findings.map((finding) => ({ + ...finding, + reproduction: adapter.command.join(' '), + artifacts: result.artifacts, + })); +} + +async function runCheck({ cwd, config, configError, name, target, env, run }) { + const result = { + check: name, + target, + status: 'error', + command: [], + findings: [], + diagnostics: [], + artifacts: [], + }; + try { + if (configError) throw new Error(configError); + if (!checks.includes(name)) throw new Error(`Unknown audit check: ${name}.`); + if (!['local', 'production'].includes(target)) + throw new Error(`Unknown audit target: ${target}.`); + const adapter = + config.audits?.[name]?.[target] ?? + (name === 'dependencies' + ? { command: ['pnpm', 'audit', '--prod', '--audit-level=low', '--json'], format: 'pnpm' } + : null); + if (!adapter) throw new Error(`No ${name} audit configured for ${target}.`); + if ( + !Array.isArray(adapter.command) || + !adapter.command.length || + adapter.command.some((argument) => typeof argument !== 'string' || !argument.length) + ) + throw new Error('Audit command must be a non-empty argument array.'); + await executeCheck({ cwd, env, run, name, adapter, result }); + } catch (error) { + result.status = 'error'; + result.diagnostics.push(error.message); + } + return result; +} + +export async function runAudits({ + cwd, + config = {}, + configError, + check, + target = 'local', + env = process.env, + execute: run = execute, +}) { + cwd = path.resolve(cwd); + const commitResult = await run('git', ['rev-parse', 'HEAD'], { cwd, env }); + const commit = commitResult.status === 0 ? commitResult.stdout.trim() : (env.GITHUB_SHA ?? null); + const report = { + version: 1, + repository: env.GITHUB_REPOSITORY ?? null, + commit, + run: runIdentity(env, commit), + target, + results: [], + }; + const requested = check + ? [check] + : checks.filter((name) => name === 'dependencies' || config.audits?.[name]?.[target]); + for (const name of requested) + report.results.push(await runCheck({ cwd, config, configError, name, target, env, run })); + return report; +} + +export async function audit({ cwd, options }) { + const positionals = options.positional?.filter((value) => value !== 'audit') ?? []; + if (positionals[0] === 'report') { + const { reportAudit } = await import('./report.mjs'); + const result = await reportAudit({ + cwd, + input: options.input, + dryRun: options.dryRun ?? options['dry-run'] ?? false, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } + const { readTooling } = await import('../tooling.mjs'); + let config = {}; + let configError; + try { + config = await readTooling(cwd); + } catch (error) { + configError = error.message; + } + if (positionals.length > 1) configError = 'Audit accepts at most one check name.'; + const report = await runAudits({ + cwd, + config, + configError, + check: positionals[0], + target: options.target ?? 'local', + }); + const serialized = `${JSON.stringify(report, null, 2)}\n`; + if (options.output) { + const destination = path.resolve(cwd, options.output); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, serialized); + } + process.stdout.write( + options.json + ? serialized + : report.results + .map( + (result) => + `${result.check} (${result.target}): ${result.status}${result.diagnostics.length ? ` — ${result.diagnostics.join('; ')}` : ''}\n`, + ) + .join(''), + ); + return report.results.some((result) => result.status !== 'pass') ? 1 : 0; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/audit/report.mjs b/.lvbt/web-platform/packages/cli/src/lib/audit/report.mjs new file mode 100644 index 0000000..3ffb785 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/audit/report.mjs @@ -0,0 +1,229 @@ +import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { checks, execute } from './index.mjs'; +import { + verifyExecutor, + verifySourceConfiguration, +} from '../contributions/trusted-configuration.mjs'; +import { foreignOwnership } from '../contributions/ownership.mjs'; +import { latestIssueEvidence } from '../contributions/issue-evidence.mjs'; +import { idNumber, successful, verifiedRun } from '../contributions/trusted-report.mjs'; +import { verifyAuditExecution } from './execution.mjs'; +import { applyIssueActions } from '../contributions/issue-apply.mjs'; + +const helper = new URL( + '../../../plugins/lvbt-contributions/scripts/github-create.mjs', + import.meta.url, +).pathname; +const titleNames = { links: 'Links', lighthouse: 'Lighthouse', dependencies: 'Dependency' }; +const labelNames = (result) => [ + 'bug', + 'audit-owned', + `audit:${result.check}`, + `target:${result.target}`, +]; +const normalize = (body) => body.replaceAll('\r\n', '\n').trimEnd(); + +function validateResult(result, report, seen) { + const key = `${result.check}:${result.target}`; + const valid = [ + checks.includes(result.check), + result.target === report.target, + ['pass', 'fail', 'error', 'skipped'].includes(result.status), + Array.isArray(result.findings), + Array.isArray(result.artifacts), + Array.isArray(result.command), + !seen.has(key), + ]; + if (valid.some((value) => !value)) + throw new Error('Audit report contains invalid or duplicate results.'); + if (result.status === 'pass' && result.findings.length) + throw new Error('Passing audit cannot contain failure findings.'); + if ( + result.status === 'fail' && + (!result.findings.length || + result.findings.some( + (finding) => typeof finding.title !== 'string' || typeof finding.diagnostic !== 'string', + )) + ) + throw new Error('Failed audit requires concrete findings.'); + seen.add(key); +} +function validate(report) { + if (!report.run) throw new Error('Audit report has no trusted workflow provenance.'); + const valid = [ + report.version === 1, + /^[\w.-]+\/[\w.-]+$/.test(report.repository ?? ''), + /^[a-f\d]{40}$/i.test(report.commit ?? ''), + /^\d+$/.test(String(report.run.id)), + Number.isSafeInteger(report.run.attempt), + report.run.attempt >= 1, + ['local', 'production'].includes(report.target), + Array.isArray(report.results), + ]; + if (valid.some((value) => !value) || !report.results.length) + throw new Error('Audit report has no valid trusted provenance or results.'); + const seen = new Set(); + for (const result of report.results) validateResult(result, report, seen); +} +function issueBody(report, result) { + const command = result.command.join(' '); + const findings = result.findings + .map( + (finding) => + `- ${finding.title}: ${finding.diagnostic}${finding.location ? ` Location: ${finding.location}.` : ''}${finding.url ? ` See ${finding.url}.` : ''}`, + ) + .join('\n'); + return `# Steps to reproduce\n\nRun \`${command.replaceAll('`', '\\`')}\` against ${result.target} at commit ${report.commit}.\n\n# Expected behavior\n\nThe ${result.check} audit passes without findings.\n\n# Actual behavior\n\n${result.status === 'pass' ? 'The verified audit now passes.' : findings}\n\n# Additional context\n\nAudit owner: LVBT shared audit automation.\n\nVerified run: ${report.run.id} (attempt ${report.run.attempt}).\n\nWorkflow: [${report.run.workflow}](${report.run.url}).\n\nCommit: [${report.commit}](https://github.com/${report.repository}/commit/${report.commit}).\n\nArtifacts: [Download the verified audit evidence](${report.artifactUrl}).${result.artifacts.length ? ` Raw files: ${result.artifacts.join(', ')}.` : ''}\n`; +} +function ownedIssue(issues, labels, result, report) { + const matches = issues.filter( + (issue) => + !issue.pull_request && + labels + .slice(1) + .every((name) => issue.labels.some((label) => label.name.toLowerCase() === name)), + ); + if (matches.length > 1) + throw new Error(`Multiple audit-owned issues match ${result.check}/${result.target}.`); + const issue = matches[0]; + if (!issue) return undefined; + if (foreignOwnership(issue.labels, labels.slice(1))) + throw new Error('Audit issue has ambiguous foreign automation ownership.'); + const previous = latestIssueEvidence(issue.body); + if ( + previous && + (idNumber(previous[1]) > idNumber(report.run.id) || + (previous[1] === String(report.run.id) && Number(previous[2]) > report.run.attempt)) + ) + throw new Error('Audit result is stale relative to the issue evidence.'); + return issue; +} +function lifecycleAction(result, issue) { + if (result.status === 'pass') return 'close'; + if (!issue) return 'create'; + return issue.state === 'CLOSED' ? 'reopen' : 'update'; +} +function actionsFor(report, issues) { + if (!Array.isArray(issues)) throw new Error('GitHub returned invalid issue data.'); + if (issues.length >= 1000) + throw new Error('Audit-owned issue search is truncated; refusing ambiguous updates.'); + const actions = []; + for (const result of report.results) { + if (!['pass', 'fail'].includes(result.status)) continue; + const labels = labelNames(result); + const issue = ownedIssue(issues, labels, result, report); + if (result.status === 'pass' && (!issue || issue.state === 'CLOSED')) continue; + actions.push({ + action: lifecycleAction(result, issue), + check: result.check, + target: result.target, + title: `${titleNames[result.check]} audit fails in ${result.target}`, + body: issueBody(report, result), + labels, + ...(issue ? { number: issue.number, url: issue.url } : {}), + }); + } + return actions; +} +const bodyFile = (directory, action) => path.join(directory, `${action.check}-${action.target}.md`); +function helperArguments(action, report, file) { + return [ + helper, + 'issue', + '--type', + 'bug', + '--title', + action.title, + '--body-file', + file, + '--repo', + report.repository, + '--audit-check', + action.check, + '--audit-target', + action.target, + '--json', + ]; +} +async function previewActions(actions, report, directory, { run, cwd }) { + for (const action of actions) { + const file = bodyFile(directory, action); + await writeFile(file, action.body); + const output = await successful( + run, + process.execPath, + [...helperArguments(action, report, file), '--dry-run'], + { cwd, message: 'Audit contribution preview failed.' }, + ); + const checked = JSON.parse(output); + if ( + checked.title !== action.title || + normalize(checked.body) !== normalize(action.body) || + !isDeepStrictEqual(checked.labels, action.labels) + ) + throw new Error('Contribution helper preview differs from the audit action.'); + } +} +export async function reportAudit({ + cwd, + input, + dryRun = false, + config, + environment = process.env, + execute: run = execute, +}) { + if (!input) throw new Error('Audit reporting requires --input.'); + if (!config) { + const { readTooling } = await import('../tooling.mjs'); + config = await readTooling(cwd); + } + const report = JSON.parse(await readFile(path.resolve(cwd, input), 'utf8')); + validate(report); + verifyExecutor(report, environment, dryRun); + const { gh, artifactUrl } = await verifiedRun({ + report, + workflow: config.audits?.workflow ?? '.github/workflows/audits.yml', + artifactName: config.audits?.artifactName ?? 'lvbt-audit-report', + filename: 'lvbt-audit-report.json', + run, + cwd, + }); + await verifySourceConfiguration(report, config, gh, 'audits'); + await verifyAuditExecution(report, gh); + const issues = await gh([ + 'issue', + 'list', + '--repo', + report.repository, + '--state', + 'all', + '--label', + 'audit-owned', + '--limit', + '1000', + '--json', + 'number,title,body,state,labels,url', + ]); + const actions = actionsFor({ ...report, artifactUrl }, issues); + const preview = { valid: true, dryRun, repository: report.repository, run: report.run, actions }; + if (!actions.length) return preview; + const directory = await mkdtemp(path.join(tmpdir(), 'lvbt-audit-issues-')); + try { + await previewActions(actions, report, directory, { run, cwd }); + if (!dryRun) + await applyIssueActions(actions, report.repository, { + gh, + run, + cwd, + bodyFile: (action) => bodyFile(directory, action), + helperArguments: (action, repository, file) => + helperArguments(action, { repository }, file), + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + return preview; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs index c0b8cf4..a5baac5 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs @@ -1,5 +1,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { catalogEntries, overrideProblems } from './workspace-policy.mjs'; +import { turboCacheRequired } from './turbo-cache.mjs'; +export { catalogEntries } from './workspace-policy.mjs'; /** * Every workspace package declares the tasks the standard runs, every @@ -21,6 +24,7 @@ const IGNORED = new Set([ 'test-results', '.turbo', '.wrangler', + '.cloudflare', '.astro', ]); @@ -70,23 +74,6 @@ function workspaceGlobs(root) { return globs; } -/** The default `catalog:` of pnpm-workspace.yaml as name → version, without a YAML dependency. */ -export function catalogEntries(text) { - const entries = {}; - let inCatalog = false; - for (const line of text.split(/\r?\n/)) { - if (/^catalog:\s*$/.test(line)) { - inCatalog = true; - continue; - } - if (!inCatalog || /^\s*(?:#.*)?$/.test(line)) continue; - if (/^\S/.test(line)) break; - const match = /^\s+(['"]?)([^'"\s:]+)\1:\s*(['"]?)([^'"\s#]+)\3/.exec(line); - if (match) entries[match[2]] = match[4]; - } - return entries; -} - /** * Shared catalog versions belong to the standard; a repository adds entries but never re-pins one. * These are warnings until standard v0.6.0, which moves the entries and fails on any that differ. @@ -102,6 +89,24 @@ function catalogWarnings(root) { ); } +function overrideFindings(root) { + const standard = JSON.parse(readFileSync(STANDARD_CATALOG, 'utf8')).overrides ?? {}; + const source = readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); + const metadata = path.join(root, '.lvbt/web-platform.json'); + const release = existsSync(metadata) + ? JSON.parse(readFileSync(metadata, 'utf8')).release + : `v${JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version}`; + const required = turboCacheRequired(release); + const problems = overrideProblems(source, standard); + return { + ok: !required || problems.length === 0, + lines: problems.map( + (problem) => + `${required ? 'error' : 'warning'}: ${problem} Run pnpm standards:update (required from v0.8.0).`, + ), + }; +} + function packageDirectories(root) { const directories = []; for (const glob of workspaceGlobs(root)) { @@ -231,10 +236,17 @@ export function checkContract({ cwd }) { lines.push(...dependencyFailures(cwd, directory)); } lines.push(...astroFailures(cwd, directories)); + let policy; + try { + const overrides = overrideFindings(cwd); + policy = { ok: overrides.ok, lines: [...catalogWarnings(cwd), ...overrides.lines] }; + } catch (error) { + policy = { ok: false, lines: [error.message] }; + } return { name: 'contract', - ok: lines.length === 0, - lines: [...lines, ...catalogWarnings(cwd)], + ok: lines.length === 0 && policy.ok, + lines: [...lines, ...policy.lines], fix: 'add the missing script, move test material under tests/, set the range to "catalog:" and add the version to pnpm-workspace.yaml, or run `pnpm standards:update` to wire an Astro package\'s "sync" task before lint', }; } diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/filenames.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/filenames.mjs index 6d2eefd..840ebbb 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/filenames.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/filenames.mjs @@ -13,7 +13,8 @@ const MODULE_FILE = /^(?:apps|packages)\/[^/]+\/(src|tests)\/(.+)$/; const SOURCE_FILE = /^[^.]+\.[^.]+$/; // Conventions that carry a second dot on purpose: CSS modules, tool config // files (Astro requires src/content.config.ts), and placeholder files. -const CONVENTIONAL_SOURCE = /^(?:[^.]+\.(?:module\.[a-z]+|config\.[a-z]+)|\.gitkeep)$/; +const CONVENTIONAL_SOURCE = + /^(?:[^.]+\.(?:module\.[a-z]+|config\.[a-z]+|d\.(?:ts|mts|cts))|\.gitkeep)$/; // Documentation beside tests describes them and is not a suite. const DOCUMENT = /^[A-Z][A-Z0-9-]*\.md$/; // Astro routes files by name, and an endpoint such as src/pages/robots.txt.ts diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs index 4eb2215..c35e01c 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs @@ -3,20 +3,27 @@ import { checkContract } from './contract.mjs'; import { checkDebt } from './debt.mjs'; import { checkFilenames } from './filenames.mjs'; import { checkPlatform } from './platform.mjs'; +import { checkStandard } from './standard.mjs'; +import { checkSecrets } from './secrets.mjs'; const checks = { + standard: checkStandard, filenames: checkFilenames, contract: checkContract, debt: checkDebt, platform: checkPlatform, + secrets: checkSecrets, }; /** * `lvbt check [name...]`: the repository-shape rules every LVBT repository * shares, run together by `pnpm check`. Each result names its fix. */ -export function check({ cwd, options }) { - const names = options.positional.length > 0 ? options.positional : Object.keys(checks); +export async function check({ cwd, options }) { + const names = + options.positional.length > 0 + ? options.positional + : Object.keys(checks).filter((name) => name !== 'secrets'); const unknown = names.filter((name) => !(name in checks)); if (unknown.length > 0) { throw new CliError( @@ -27,7 +34,7 @@ export function check({ cwd, options }) { let failed = 0; for (const name of names) { - const result = checks[name]({ cwd, staged: options.staged }); + const result = await checks[name]({ cwd, staged: options.staged }); process.stdout.write(` ${result.ok ? 'ok ' : 'FAIL'} ${name}\n`); for (const line of result.lines) process.stdout.write(` ${line}\n`); if (!result.ok) { diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/secrets.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/secrets.mjs new file mode 100644 index 0000000..b7eb657 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/secrets.mjs @@ -0,0 +1,172 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +// Reviewed official OCI image labels identify this digest as v8.30.1. Native execution must match. +export const GITLEAKS_VERSION = '8.30.1'; +export const GITLEAKS_IMAGE = + 'ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f'; +const PROBE_TIMEOUT = 5000; +const FIX = `Install Gitleaks ${GITLEAKS_VERSION}, or start Docker so the reviewed pinned image can run; never skip a configured secret scan.`; + +function execute(command, args, { cwd, env, timeout }) { + return spawnSync(command, args, { + cwd, + env, + timeout, + killSignal: 'SIGKILL', + encoding: 'utf8', + maxBuffer: 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} +function scanner(cwd, env) { + const native = execute('gitleaks', ['version'], { cwd, env, timeout: PROBE_TIMEOUT }); + if (native.status === 0 && native.stdout.trim().replace(/^v/u, '') === GITLEAKS_VERSION) + return { + tool: 'gitleaks', + detail: `Gitleaks ${GITLEAKS_VERSION} matches the reviewed scanner`, + }; + const docker = execute('docker', ['info', '--format', '{{.ServerVersion}}'], { + cwd, + env, + timeout: PROBE_TIMEOUT, + }); + if (docker.status === 0) + return { + tool: 'docker', + detail: `Docker is ready for the pinned Gitleaks ${GITLEAKS_VERSION} image`, + }; + return { + detail: `No exact Gitleaks ${GITLEAKS_VERSION} native scanner or available Docker daemon`, + }; +} + +/** Read-only availability check. Repositories without product scanner rules need no scanner. */ +export function secretScannerFinding({ cwd, env = process.env }) { + if (!existsSync(path.join(cwd, '.gitleaks.toml'))) return undefined; + const selected = scanner(cwd, env); + return { + ok: Boolean(selected.tool), + label: 'secret scanner', + detail: selected.detail, + ...(!selected.tool ? { fix: FIX } : {}), + }; +} + +/** Explicit expensive check, run as an uncached required Turbo validation dependency. */ +export function checkSecrets({ cwd, env = process.env, timeout = 120000 }) { + const result = { name: 'secrets', ok: true, lines: [], fix: FIX }; + const config = path.join(cwd, '.gitleaks.toml'); + if (!existsSync(config)) + return { ...result, lines: ['no .gitleaks.toml; secret scan is not configured'] }; + const history = execute('git', ['rev-parse', '--is-shallow-repository'], { + cwd, + env, + timeout: PROBE_TIMEOUT, + }); + if (history.status !== 0 || history.stdout.trim() !== 'false') + return { + ...result, + ok: false, + lines: ['Full Git history is required; repository history is shallow or unavailable.'], + fix: 'Fetch full history (git fetch --unshallow); CI checkout must use fetch-depth: 0.', + }; + const selected = scanner(cwd, env); + if (!selected.tool) return { ...result, ok: false, lines: [selected.detail] }; + const container = `lvbt-secret-scan-${randomUUID()}`; + const args = + selected.tool === 'gitleaks' + ? ['git', '--redact', '--no-banner', '--config', config] + : dockerArguments(container, cwd, env); + if (!args) + return { + ...result, + ok: false, + lines: ['Git metadata is unavailable; Docker cannot verify full repository history.'], + }; + const scan = execute(selected.tool, args, { cwd, env, timeout }); + if (selected.tool === 'docker' && (scan.error || scan.signal)) + execute('docker', ['rm', '--force', container], { cwd, env, timeout: PROBE_TIMEOUT }); + return scanEvidence(scan, result); +} + +function dockerArguments(container, cwd, env) { + const directory = (flag) => { + const probe = execute('git', ['rev-parse', '--path-format=absolute', flag], { + cwd, + env, + timeout: PROBE_TIMEOUT, + }); + return probe.status === 0 ? probe.stdout.trim() : undefined; + }; + const common = directory('--git-common-dir'); + const git = directory('--git-dir'); + if (!common || !git || !path.isAbsolute(common) || !path.isAbsolute(git)) return undefined; + const location = (file) => { + const relative = path.relative(cwd, file); + return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) + ? file + : path.posix.join('/repo', relative.split(path.sep).join('/')); + }; + // A linked worktree's .git points outside its tree. Expose only Git metadata read-only, + // at its existing absolute path so Git's relative commondir pointers remain valid. + const mounts = location(common) === common ? ['-v', `${common}:${common}:ro`] : []; + return [ + 'run', + '--rm', + '--name', + container, + '-v', + `${cwd}:/repo:ro`, + ...mounts, + '-e', + `GIT_DIR=${location(git)}`, + '-e', + `GIT_COMMON_DIR=${location(common)}`, + '-w', + '/repo', + GITLEAKS_IMAGE, + 'git', + '--redact', + '--no-banner', + '--config', + '/repo/.gitleaks.toml', + ]; +} + +function scanEvidence(scan, result) { + // Never echo scanner stdout/stderr: even failed or substituted executables cannot expose values. + if (scan.error?.code === 'ETIMEDOUT') + return { + ...result, + ok: false, + lines: ['The full-history secret scan timed out; no passing evidence was produced.'], + }; + if (scan.error || scan.signal) + return { + ...result, + ok: false, + lines: ['The secret scanner could not complete; no passing evidence was produced.'], + }; + if (scan.status === 1) + return { + ...result, + ok: false, + lines: ['Secrets were detected in Git history.'], + fix: 'Remove exposed values from history and rotate affected credentials; rerun pnpm check.', + }; + if (scan.status !== 0) + return { + ...result, + ok: false, + lines: [`The secret scanner failed (exit ${scan.status}); no passing evidence was produced.`], + }; + return { + ...result, + lines: [ + `Full-history redacted secret scan passed using reviewed Gitleaks ${GITLEAKS_VERSION}.`, + ], + }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/standard.d.mts b/.lvbt/web-platform/packages/cli/src/lib/check/standard.d.mts new file mode 100644 index 0000000..98de98d --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/standard.d.mts @@ -0,0 +1,9 @@ +export const STANDARD_COMMANDS: Record; +export function standardCommandsFor(options?: { vendored?: boolean }): Record; +export function standardCommands(cwd: string): Record; +export function checkStandard(options: { cwd: string }): Promise<{ + name: string; + ok: boolean; + lines: string[]; + fix: string; +}>; diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/standard.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/standard.mjs new file mode 100644 index 0000000..48d08b3 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/standard.mjs @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { readTooling } from '../tooling.mjs'; +import { turboCacheProblem, turboCacheRequired } from './turbo-cache.mjs'; + +export const STANDARD_COMMANDS = { + bootstrap: 'lvbt bootstrap', + preflight: 'lvbt preflight', + check: + 'pnpm format:check && markdownlint-cli2 && lvbt check && turbo run lint check-types test validate', + audit: 'lvbt audit', +}; +export function standardCommands(cwd) { + return standardCommandsFor({ + vendored: existsSync(path.join(cwd, '.lvbt/web-platform/packages/cli/src/cli.mjs')), + }); +} +export function standardCommandsFor({ vendored = false } = {}) { + if (!vendored) return STANDARD_COMMANDS; + const prefix = 'node .lvbt/web-platform/packages/cli/src/cli.mjs'; + return { + ...STANDARD_COMMANDS, + bootstrap: `${prefix} bootstrap`, + preflight: `${prefix} preflight`, + }; +} +export async function checkStandard({ cwd }) { + const lines = []; + let ok = true; + let release = null; + try { + readTooling(cwd); + } catch (error) { + lines.push(error.message); + ok = false; + } + const file = path.join(cwd, 'package.json'); + const pkg = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; + lines.push(...commandWarnings(cwd, pkg)); + const warning = workspaceWarning(cwd); + if (warning) lines.push(warning); + if (existsSync(path.join(cwd, '.lvbt/web-platform.json'))) { + try { + const vendor = path.join(cwd, '.lvbt/web-platform/standards'); + const { verifyPreset } = await import( + pathToFileURL(path.join(vendor, 'web-platform.ts')).href + ); + const { ownedFileDrift } = await import( + pathToFileURL(path.join(vendor, 'owned-files.ts')).href + ); + const metadata = await verifyPreset(cwd); + release = metadata.release; + const drift = await ownedFileDrift(cwd, metadata.release); + if (drift.length) { + lines.push(...drift); + ok = false; + } + if (metadata.release === null) + lines.push( + 'warning: this standard is an unpublished snapshot; adopt the published release after consumer validation.', + ); + } catch (error) { + lines.push(error.message); + ok = false; + } + } + const cache = cacheCheck(cwd, release); + lines.push(...cache.lines); + ok = ok && cache.ok; + return { + name: 'standard', + ok, + lines, + fix: 'Run pnpm standards:update for shared files; make shared behavior changes in repository-tooling, then migrate application-owned configuration.', + }; +} + +function cacheCheck(cwd, release) { + const turbo = path.join(cwd, 'turbo.json'); + const problem = turboCacheProblem(existsSync(turbo) ? readFileSync(turbo, 'utf8') : null); + const required = turboCacheRequired(release); + return { + ok: !problem || !required, + lines: problem ? [`${required ? 'error' : 'warning'}: ${problem} (required from v0.8.0).`] : [], + }; +} + +function workspaceWarning(cwd) { + const workspace = path.join(cwd, 'pnpm-workspace.yaml'); + if ( + existsSync(workspace) && + !/^(?:verifyDepsBeforeRun|'verifyDepsBeforeRun'|"verifyDepsBeforeRun")\s*:\s*false(?:\s*#.*)?\s*$/mu.test( + readFileSync(workspace, 'utf8'), + ) + ) + return 'warning: set verifyDepsBeforeRun: false in pnpm-workspace.yaml so preflight cannot auto-install (pnpm 11 ignores this setting in .npmrc).'; +} + +function commandWarnings(cwd, pkg) { + if (pkg.name === 'lvbt-repository-tooling') return []; + return Object.entries(standardCommands(cwd)).flatMap(([name, command]) => + pkg.scripts?.[name] === command + ? [] + : [ + `warning: root ${name} must be "${command}"; adopt the shared command and move application steps into the standard extension points (required from v0.8.0).`, + ], + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.d.mts b/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.d.mts new file mode 100644 index 0000000..e1275c5 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.d.mts @@ -0,0 +1,6 @@ +export const TURBO_GLOBAL_DEPENDENCIES: string[]; +export function parseTurboCache(source: string): Record & { + globalDependencies?: string[]; +}; +export function turboCacheProblem(source: string | null | undefined): string | undefined; +export function turboCacheRequired(release: string | null | undefined): boolean; diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.mjs new file mode 100644 index 0000000..f8aa19a --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/turbo-cache.mjs @@ -0,0 +1,47 @@ +/** Root inputs shared by every workspace task, including checks that read vendored rules. */ +export const TURBO_GLOBAL_DEPENDENCIES = [ + '.lvbt/web-platform.json', + '.lvbt/tooling.json', + '.github/workflows/**', +]; + +export function parseTurboCache(source) { + let config; + try { + config = JSON.parse(source); + } catch { + throw new Error( + 'turbo.json must contain valid JSON before the shared cache policy can update it.', + ); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) + throw new Error('turbo.json must contain an object.'); + if ( + config.globalDependencies !== undefined && + (!Array.isArray(config.globalDependencies) || + config.globalDependencies.some((entry) => typeof entry !== 'string')) + ) + throw new Error('turbo.json globalDependencies must be an array of strings.'); + return config; +} + +export function turboCacheProblem(source) { + if (source === null || source === undefined) + return 'turbo.json is missing; declare shared globalDependencies so tooling and workflow changes invalidate workspace checks.'; + try { + const config = parseTurboCache(source); + const missing = TURBO_GLOBAL_DEPENDENCIES.filter( + (entry) => !config.globalDependencies?.includes(entry), + ); + if (missing.length) + return `turbo.json globalDependencies is missing ${missing.join(', ')}; run pnpm standards:update to add shared cache inputs while preserving product tasks.`; + } catch (error) { + return error.message; + } +} + +/** The migration warns for unpublished snapshots and 0.7; adopted 0.8+ releases enforce it. */ +export function turboCacheRequired(release) { + const version = /^v(\d+)\.(\d+)\./u.exec(release ?? ''); + return Boolean(version && (Number(version[1]) > 0 || Number(version[2]) >= 8)); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.d.mts b/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.d.mts new file mode 100644 index 0000000..a1eb895 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.d.mts @@ -0,0 +1,15 @@ +export interface ScalarRecord { + key: string; + value: string; + index: number; + valueStart: number; + valueLength: number; + quote: string; +} +export function scalarSection( + source: string, + name: string, +): { entries: Record; records: ScalarRecord[]; start: number; end: number }; +export function catalogEntries(source: string): Record; +export function overrideProblems(source: string, policy: Record): string[]; +export function updateOverrides(source: string, policy: Record): string; diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.mjs new file mode 100644 index 0000000..02fde97 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/workspace-policy.mjs @@ -0,0 +1,184 @@ +function invalid(section, index, reason) { + throw new Error( + `pnpm-workspace.yaml ${section}, line ${index + 1}: ${reason}. Use a scalar mapping with two-space indentation.`, + ); +} + +function quotedToken(text, section, index) { + const quote = text[0]; + let cursor = 1; + while (cursor < text.length) { + if (quote === '"' && text[cursor] === '\\') { + cursor += 2; + continue; + } + if (text[cursor] !== quote) { + cursor++; + continue; + } + if (quote === "'" && text[cursor + 1] === "'") { + cursor += 2; + continue; + } + const raw = text.slice(0, cursor + 1); + try { + return { + value: quote === '"' ? JSON.parse(raw) : raw.slice(1, -1).replaceAll("''", "'"), + raw, + }; + } catch { + invalid(section, index, 'unsupported quoted scalar'); + } + } + invalid(section, index, 'unterminated quoted scalar'); +} + +function scalar(text, section, index) { + const token = /^["']/u.test(text) + ? quotedToken(text, section, index) + : { raw: text.match(/^[^\s]+/u)?.[0] ?? '' }; + const value = token.value ?? token.raw; + const rest = text.slice(token.raw.length); + if ( + !value || + (token.value === undefined && /(?:^(?:[,\]{}[&*!|>@%`#]|[?:-]$)|:$)/u.test(value)) || + !/^(?:\s+#.*|\s*)$/u.test(rest) + ) + invalid(section, index, 'unsupported or malformed scalar value'); + return { value, raw: token.raw }; +} + +function mappingKey(text, section, index) { + const token = /^["']/u.test(text) ? quotedToken(text, section, index) : undefined; + const plain = token ? undefined : /^([^:]+):(?:\s|$)/u.exec(text); + const key = token?.value ?? plain?.[1].trim(); + const rest = token ? text.slice(token.raw.length) : text.slice(plain?.[1].length ?? 0); + if ( + !key || + (!token && /(?:^(?:[,\]{}[&*!|>@%`]|[?-]\s|<<$)|\s#)/u.test(key)) || + !/^:\s+/u.test(rest) + ) + invalid(section, index, 'unsupported mapping key or missing scalar'); + const gap = /^:\s+/u.exec(rest)[0]; + return { key, offset: (token?.raw.length ?? plain[1].length) + gap.length }; +} + +function mappingEntry(line, section, index) { + if (!/^ {2}\S/u.test(line)) invalid(section, index, 'unsupported indentation or nested mapping'); + const { key, offset } = mappingKey(line.slice(2), section, index); + const valueStart = 2 + offset; + const value = scalar(line.slice(valueStart), section, index); + return { + key, + value: value.value, + index, + valueStart, + valueLength: value.raw.length, + quote: /^["']/u.test(value.raw) ? value.raw[0] : '', + }; +} + +function sectionHeader(line, name) { + return new RegExp(`^(?:${name}|'${name}'|"${name}")(?:\\s|:|$)`, 'u').test(line); +} + +function validateHeader(line, name, index, start) { + if (start !== -1) invalid(name, index, 'duplicate section'); + if (!new RegExp(`^(?:${name}|'${name}'|"${name}")\\s*:(?:\\s+#.*|\\s*)$`, 'u').test(line)) + invalid(name, index, 'unsupported section syntax'); +} + +/** Supported scalar subset only; no guessed YAML interpretation in controlled sections. */ +export function scalarSection(source, name) { + const lines = source.split(/\r?\n/u); + const entries = {}; + const records = []; + let start = -1; + let end = lines.length - (lines.at(-1) === '' ? 1 : 0); + let active = false; + for (const [index, line] of lines.entries()) { + if (sectionHeader(line, name)) { + validateHeader(line, name, index, start); + start = index; + active = true; + continue; + } + if (!active || /^\s*(?:#.*)?$/u.test(line)) continue; + if (/^\S/u.test(line)) { + end = index; + active = false; + continue; + } + const entry = mappingEntry(line, name, index); + if (Object.hasOwn(entries, entry.key)) invalid(name, index, `duplicate key "${entry.key}"`); + Object.defineProperty(entries, entry.key, { value: entry.value, enumerable: true }); + records.push(entry); + } + return { entries, records, start, end }; +} + +/** Compatibility export for callers using the original catalog parser. */ +export function catalogEntries(source) { + return scalarSection(source, 'catalog').entries; +} + +function auditedOverrides(policy) { + if ( + !policy || + typeof policy !== 'object' || + Array.isArray(policy) || + Object.entries(policy).some( + ([key, value]) => + !key || /[\r\n]/u.test(key) || typeof value !== 'string' || !value || /[\r\n]/u.test(value), + ) + ) + throw new Error('Shared catalog.json overrides must be a scalar string mapping.'); + return policy; +} + +export function overrideProblems(source, policy) { + const { entries } = scalarSection(source, 'overrides'); + return Object.entries(auditedOverrides(policy)).flatMap(([key, value]) => + Object.hasOwn(entries, key) && entries[key] === value + ? [] + : [ + `pnpm-workspace.yaml overrides "${key}" is ${Object.hasOwn(entries, key) ? `"${entries[key]}"` : 'missing'}; shared audited policy requires "${value}".`, + ], + ); +} + +function replacementValue(value, quote) { + if (quote === "'") return `'${value.replaceAll("'", "''")}'`; + return quote === '"' ? JSON.stringify(value) : value; +} + +function quotedYamlScalar(value) { + // Shared Prettier uses singleQuote. Double quotes avoid YAML apostrophe escaping where needed. + return value.includes("'") || [...value].some((character) => character.charCodeAt(0) < 32) + ? JSON.stringify(value) + : `'${value}'`; +} + +/** Change shared pins alone; leave app-only overrides, comments and other pnpm fields intact. */ +export function updateOverrides(source, policy) { + scalarSection(source, 'catalog'); + const section = scalarSection(source, 'overrides'); + const canonical = auditedOverrides(policy); + const lines = source.split(/\r?\n/u); + for (const record of section.records) { + if (!Object.hasOwn(canonical, record.key) || canonical[record.key] === record.value) continue; + const line = lines[record.index]; + lines[record.index] = + line.slice(0, record.valueStart) + + replacementValue(canonical[record.key], record.quote) + + line.slice(record.valueStart + record.valueLength); + } + const additions = Object.entries(canonical) + .filter(([key]) => !Object.hasOwn(section.entries, key)) + .map(([key, value]) => ` ${quotedYamlScalar(key)}: ${quotedYamlScalar(value)}`); + if (additions.length) { + if (section.start === -1) lines.splice(section.end, 0, 'overrides:', ...additions); + else lines.splice(section.end, 0, ...additions); + } + return lines.join(source.includes('\r\n') ? '\r\n' : '\n'); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-apply.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-apply.mjs new file mode 100644 index 0000000..f2a822b --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-apply.mjs @@ -0,0 +1,144 @@ +import { successful } from './trusted-report.mjs'; +const normalize = (value) => + typeof value === 'string' ? value.replaceAll('\r\n', '\n').trimEnd() : undefined; +export const sameLabel = (left, right) => + typeof left === 'string' && + typeof right === 'string' && + left.toLowerCase() === right.toLowerCase(); +export const hasLabels = (labels, names) => + Array.isArray(labels) && + names.every((name) => labels.some((label) => sameLabel(label?.name, name))); +async function pinIssue(action, repository, gh) { + const [owner, name] = repository.split('/'); + const query = + 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){id isPinned}}}'; + const args = [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${owner}`, + '-f', + `name=${name}`, + '-F', + `number=${action.number}`, + ]; + const issue = (await gh(args)).data?.repository?.issue; + if (typeof issue?.id !== 'string' || typeof issue.isPinned !== 'boolean') + throw new Error('Could not verify contribution issue pin state.'); + if (!issue.isPinned) { + await gh([ + 'api', + 'graphql', + '-f', + 'query=mutation($id:ID!){pinIssue(input:{issueId:$id}){issue{id}}}', + '-f', + `id=${issue.id}`, + ]); + if ((await gh(args)).data?.repository?.issue?.isPinned !== true) + throw new Error('GitHub did not store the requested contribution issue pin.'); + } +} +async function ensureLabels(actions, repository, context) { + const labels = await context.gh([ + 'label', + 'list', + '--repo', + repository, + '--limit', + '1000', + '--json', + 'name', + ]); + if ( + !Array.isArray(labels) || + labels.length >= 1000 || + labels.some((label) => typeof label?.name !== 'string' || !label.name) + ) + throw new Error('Contribution label inventory is invalid or truncated.'); + for (const name of new Map( + actions.flatMap((action) => action.labels).map((name) => [name.toLowerCase(), name]), + ).values()) + if (!labels.some((label) => sameLabel(label.name, name))) + await successful( + context.run, + 'gh', + [ + 'label', + 'create', + name, + '--repo', + repository, + '--color', + 'B60205', + '--description', + 'Maintained by verified LVBT contribution automation.', + ], + { cwd: context.cwd, message: 'Contribution label creation failed.' }, + ); +} +async function storedIssue(action, repository, gh) { + const stored = await gh([ + 'issue', + 'view', + String(action.number), + '--repo', + repository, + '--json', + 'number,title,body,state,labels,url', + ]); + if ( + stored.title !== action.title || + normalize(stored.body) !== normalize(action.body) || + stored.state !== (action.action === 'close' ? 'CLOSED' : 'OPEN') || + !hasLabels(stored.labels, action.labels) + ) + throw new Error( + 'GitHub stored contribution issue metadata differently from the verified preview.', + ); + action.url = stored.url; +} +export async function applyIssueActions(actions, repository, context) { + const { run, gh, cwd } = context; + await ensureLabels(actions, repository, context); + for (const action of actions) { + const file = context.bodyFile(action); + if (action.action === 'create') { + const created = JSON.parse( + await successful(run, process.execPath, context.helperArguments(action, repository, file), { + cwd, + message: 'Contribution creation failed.', + }), + ); + action.number = created.number; + } else { + await successful( + run, + 'gh', + [ + 'issue', + 'edit', + String(action.number), + '--repo', + repository, + '--title', + action.title, + '--body-file', + file, + ...action.labels.flatMap((name) => ['--add-label', name]), + ], + { cwd, message: 'Contribution issue update failed.' }, + ); + if (['close', 'reopen'].includes(action.action)) + await successful( + run, + 'gh', + ['issue', action.action, String(action.number), '--repo', repository], + { cwd, message: 'Contribution issue state update failed.' }, + ); + } + await storedIssue(action, repository, gh); + if (action.pin) await pinIssue(action, repository, gh); + } +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-evidence.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-evidence.mjs new file mode 100644 index 0000000..3376a3a --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/issue-evidence.mjs @@ -0,0 +1,5 @@ +/** The generated evidence follows report content; earlier quoted run identifiers are not its owner. */ +export function latestIssueEvidence(body) { + if (typeof body !== 'string') return undefined; + return [...body.matchAll(/^Verified run: (\d+) \(attempt (\d+)\)\.$/gm)].at(-1); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/ownership.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/ownership.mjs new file mode 100644 index 0000000..d018606 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/ownership.mjs @@ -0,0 +1,7 @@ +export function ownershipLabel(name) { + const value = name.toLowerCase(); + return value.endsWith('-owned') || /^(audit|recurring|target):/.test(value); +} +export function foreignOwnership(labels, allowed) { + return labels.some(({ name }) => ownershipLabel(name) && !allowed.includes(name.toLowerCase())); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring-apply.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring-apply.mjs new file mode 100644 index 0000000..e7469ca --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring-apply.mjs @@ -0,0 +1,11 @@ +import path from 'node:path'; +import { applyIssueActions } from './issue-apply.mjs'; +import { helperArguments } from './recurring.mjs'; + +export async function applyRecurring(actions, repository, directory, context) { + return await applyIssueActions(actions, repository, { + ...context, + bodyFile: (action) => path.join(directory, `${action.key}.md`), + helperArguments, + }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring.mjs new file mode 100644 index 0000000..f20e437 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/recurring.mjs @@ -0,0 +1,268 @@ +import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { execute } from '../audit/index.mjs'; +import { idNumber, successful, verifiedRun } from './trusted-report.mjs'; +import { applyRecurring } from './recurring-apply.mjs'; +import { ownershipLabel, foreignOwnership } from './ownership.mjs'; +import { latestIssueEvidence } from './issue-evidence.mjs'; +import { verifyExecutor, verifySourceConfiguration } from './trusted-configuration.mjs'; + +const helper = new URL( + '../../../plugins/lvbt-contributions/scripts/github-create.mjs', + import.meta.url, +).pathname; +const slug = /^[a-z0-9][a-z0-9-]*$/; +const normalize = (body) => body.replaceAll('\r\n', '\n').trimEnd(); +export function recurringLabels(key, declaration) { + return [ + ...new Set([ + declaration.type === 'bug' ? 'bug' : 'enhancement', + 'recurring-owned', + `recurring:${key}`, + ...declaration.labels, + ]), + ]; +} +function validLabels(labels) { + return ( + Array.isArray(labels) && + labels.every( + (label) => + typeof label === 'string' && + /^[A-Za-z0-9][A-Za-z0-9 .:_-]{0,49}$/.test(label) && + !ownershipLabel(label), + ) + ); +} +function validateDeclaration(key, item) { + const allowed = ['workflow', 'artifactName', 'title', 'type', 'labels', 'pin', 'adoptExisting']; + if (!item || typeof item !== 'object') + throw new Error(`Invalid recurring contribution declaration: ${key}.`); + const valid = [ + slug.test(key), + Object.keys(item).every((field) => allowed.includes(field)), + /^\.github\/workflows\/[a-z0-9-]+\.ya?ml$/.test(item.workflow), + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(item.artifactName), + typeof item.title === 'string' && + Boolean(item.title.trim()) && + item.title === item.title.trim(), + ['bug', 'feature'].includes(item.type), + validLabels(item.labels), + item.pin === undefined || typeof item.pin === 'boolean', + item.adoptExisting === undefined || typeof item.adoptExisting === 'boolean', + ]; + if (valid.includes(false)) throw new Error(`Invalid recurring contribution declaration: ${key}.`); +} +function declarations(config, workflow) { + const values = config.contributions?.recurring; + if (!values || typeof values !== 'object' || Array.isArray(values)) + throw new Error('Configure contributions.recurring in .lvbt/tooling.json.'); + for (const [key, item] of Object.entries(values)) validateDeclaration(key, item); + const matching = Object.values(values).filter((item) => item.workflow === workflow); + if (!matching.length || new Set(matching.map((item) => item.artifactName)).size !== 1) + throw new Error('Recurring workflow requires one declared evidence artifact.'); + return { values, artifactName: matching[0].artifactName }; +} +function validateResult(result, keys) { + const bodyRequired = ['open', 'resolved'].includes(result.status); + if ( + !slug.test(result.key) || + keys.has(result.key) || + !['open', 'resolved', 'error', 'skipped'].includes(result.status) || + (bodyRequired && (typeof result.body !== 'string' || !result.body.trim())) + ) + throw new Error('Invalid or duplicate recurring contribution action.'); + keys.add(result.key); +} +function validate(report) { + const run = report.run; + if ( + report.version !== 1 || + !/^[\w.-]+\/[\w.-]+$/.test(report.repository ?? '') || + !/^[a-f0-9]{40}$/.test(report.commit ?? '') || + !run || + !/^\d+$/.test(String(run.id)) || + !Number.isSafeInteger(run.attempt) || + run.attempt < 1 || + !Array.isArray(report.results) + ) + throw new Error('Recurring contribution actions require valid trusted workflow provenance.'); + const keys = new Set(); + for (const result of report.results) validateResult(result, keys); +} +function legacyIssue(issues, declaration, runStartedAt) { + if (!declaration.adoptExisting) return undefined; + if (!declaration.labels.length) + throw new Error('Legacy adoption requires configured ownership labels.'); + const matches = issues.filter( + (issue) => + !issue.pull_request && + issue.title === declaration.title && + declaration.labels.every((name) => + issue.labels?.some((label) => label.name.toLowerCase() === name.toLowerCase()), + ), + ); + if (matches.length > 1) + throw new Error('Multiple legacy issues match the recurring declaration.'); + const issue = matches[0]; + if (!issue) return undefined; + if (foreignOwnership(issue.labels, [])) + throw new Error('Legacy issue has other automation ownership.'); + if ( + issue.author?.is_bot !== true || + !['github-actions', 'github-actions[bot]'].includes(issue.author.login) + ) + throw new Error('Legacy adoption requires verified GitHub Actions bot authorship.'); + const updated = Date.parse(issue.updatedAt); + const started = Date.parse(runStartedAt); + if (!Number.isFinite(updated) || !Number.isFinite(started) || updated > started) + throw new Error('Legacy adoption is stale or its update time cannot be verified.'); + return issue; +} +function ownedIssue(issues, key, context) { + const { report, declaration, runStartedAt } = context; + const matches = issues.filter( + (issue) => + !issue.pull_request && + ['recurring-owned', `recurring:${key}`].every((name) => + issue.labels?.some((label) => label.name.toLowerCase() === name), + ), + ); + if (matches.length > 1) throw new Error(`Multiple recurring-owned issues match ${key}.`); + const issue = matches[0] ?? legacyIssue(issues, declaration, runStartedAt); + if (issue && foreignOwnership(issue.labels, ['recurring-owned', `recurring:${key}`])) + throw new Error('Recurring issue has ambiguous foreign automation ownership.'); + const previous = latestIssueEvidence(issue?.body); + if ( + previous && + (idNumber(previous[1]) > idNumber(report.run.id) || + (previous[1] === String(report.run.id) && Number(previous[2]) > report.run.attempt)) + ) + throw new Error('Recurring action is stale relative to the issue evidence.'); + return issue; +} +function lifecycleAction(status, issue) { + if (status === 'resolved') return 'close'; + if (!issue) return 'create'; + return issue.state === 'CLOSED' ? 'reopen' : 'update'; +} +function actionsFor(report, values, issues, evidence) { + if (!Array.isArray(issues) || issues.length >= 1000) + throw new Error('Recurring issue inventory is invalid or truncated.'); + const actions = []; + for (const result of report.results) { + const declaration = values[result.key]; + if (!declaration || declaration.workflow !== report.run.workflow) + throw new Error(`Undeclared recurring contribution: ${result.key}.`); + if (['error', 'skipped'].includes(result.status)) continue; + const issue = ownedIssue(issues, result.key, { + report, + declaration, + runStartedAt: evidence.runStartedAt, + }); + if (result.status === 'resolved' && (!issue || issue.state === 'CLOSED')) continue; + actions.push({ + key: result.key, + action: lifecycleAction(result.status, issue), + type: declaration.type, + title: declaration.title, + body: `${normalize(result.body)}\n\nContribution owner: LVBT recurring ${result.key} automation.\n\nVerified run: ${report.run.id} (attempt ${report.run.attempt}).\n\nWorkflow: [${report.run.workflow}](${report.run.url}).\n\nCommit: [${report.commit}](https://github.com/${report.repository}/commit/${report.commit}).\n\nEvidence: [Download the verified contribution actions](${evidence.artifactUrl}).\n`, + labels: recurringLabels(result.key, declaration), + pin: declaration.pin === true && result.status === 'open', + ...(issue ? { number: issue.number, url: issue.url } : {}), + }); + } + return actions; +} +export function helperArguments(action, repository, file) { + return [ + helper, + 'issue', + '--type', + action.type, + '--title', + action.title, + '--body-file', + file, + '--repo', + repository, + '--recurring-key', + action.key, + '--recurring-labels', + JSON.stringify(action.labels.slice(3)), + '--json', + ]; +} +export async function reportRecurring({ + cwd, + input, + dryRun = false, + config, + environment = process.env, + execute: run = execute, +}) { + if (!input) throw new Error('Recurring reporting requires --input.'); + if (!config) { + const { readTooling } = await import('../tooling.mjs'); + config = await readTooling(cwd); + } + const report = JSON.parse(await readFile(path.resolve(cwd, input), 'utf8')); + validate(report); + verifyExecutor(report, environment, dryRun); + const { values, artifactName } = declarations(config, report.run.workflow); + const evidence = await verifiedRun({ + report, + workflow: report.run.workflow, + artifactName, + filename: 'lvbt-recurring-issues.json', + run, + cwd, + }); + const { gh } = evidence; + await verifySourceConfiguration(report, config, gh, 'contributions'); + const issues = await gh([ + 'issue', + 'list', + '--repo', + report.repository, + '--state', + 'all', + ...(!Object.values(values).some((item) => item.adoptExisting) + ? ['--label', 'recurring-owned'] + : []), + '--limit', + '1000', + '--json', + 'number,title,body,state,labels,url,author,updatedAt', + ]); + const actions = actionsFor(report, values, issues, evidence); + const preview = { valid: true, dryRun, repository: report.repository, run: report.run, actions }; + const directory = await mkdtemp(path.join(tmpdir(), 'lvbt-recurring-actions-')); + try { + for (const action of actions) { + const file = path.join(directory, `${action.key}.md`); + await writeFile(file, action.body); + const checked = JSON.parse( + await successful( + run, + process.execPath, + [...helperArguments(action, report.repository, file), '--dry-run'], + { cwd, message: 'Recurring contribution preview failed.' }, + ), + ); + if ( + checked.title !== action.title || + normalize(checked.body) !== normalize(action.body) || + !isDeepStrictEqual(checked.labels, action.labels) + ) + throw new Error('Contribution helper preview differs from the recurring action.'); + } + if (!dryRun && actions.length) + await applyRecurring(actions, report.repository, directory, { run, gh, cwd }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + return preview; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-configuration.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-configuration.mjs new file mode 100644 index 0000000..8045995 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-configuration.mjs @@ -0,0 +1,35 @@ +import { isDeepStrictEqual } from 'node:util'; + +export function verifyExecutor(report, environment, dryRun) { + if (dryRun && !environment.GITHUB_EVENT_NAME) return; + const pairs = [ + [environment.GITHUB_REPOSITORY, report.repository], + [environment.GITHUB_RUN_ID, String(report.run.id)], + [environment.GITHUB_RUN_ATTEMPT, String(report.run.attempt)], + [environment.GITHUB_SHA, report.commit], + [environment.GITHUB_REF_NAME, report.run.headBranch], + [environment.GITHUB_EVENT_NAME, report.run.event], + ]; + if ( + !['schedule', 'workflow_dispatch'].includes(environment.GITHUB_EVENT_NAME) || + pairs.some(([actual, expected]) => actual !== expected) + ) + throw new Error('Issue writes require the matching trusted workflow executor identity.'); + if ( + environment.GITHUB_WORKFLOW_REF && + environment.GITHUB_WORKFLOW_REF !== + `${report.repository}/${report.run.workflow}@refs/heads/${report.run.headBranch}` + ) + throw new Error('Current workflow execution does not match the contribution evidence.'); +} +export async function verifySourceConfiguration(report, config, gh, namespace) { + const stored = await gh([ + 'api', + `repos/${report.repository}/contents/.lvbt/tooling.json?ref=${report.commit}`, + ]); + if (stored.type !== 'file' || stored.encoding !== 'base64' || typeof stored.content !== 'string') + throw new Error('Could not read configuration at the verified source commit.'); + const canonical = JSON.parse(Buffer.from(stored.content, 'base64').toString('utf8')); + if (!isDeepStrictEqual(canonical[namespace], config[namespace])) + throw new Error('Declarations differ from configuration at the verified source commit.'); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-report.mjs b/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-report.mjs new file mode 100644 index 0000000..83b7d6f --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/contributions/trusted-report.mjs @@ -0,0 +1,103 @@ +import { readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +export const idNumber = (id) => BigInt(String(id)); + +export async function successful(run, command, args, { cwd, message }) { + const result = await run(command, args, { cwd }); + if (result.status !== 0) throw new Error(result.stderr || message); + return result.stdout; +} +function verifyIdentity(report, remote, repository, workflow) { + const matches = [ + [remote.repository?.full_name, report.repository], + [report.run.repository, report.repository], + [remote.head_branch, repository.default_branch], + [report.run.headBranch, remote.head_branch], + [remote.head_sha, report.commit], + [report.run.headSha, remote.head_sha], + [report.run.event, remote.event], + [remote.path?.split('@')[0], workflow], + [report.run.workflow, workflow], + [remote.run_attempt, report.run.attempt], + [String(remote.id), String(report.run.id)], + [remote.html_url, report.run.url], + ]; + if ( + !['schedule', 'workflow_dispatch'].includes(remote.event) || + matches.some(([actual, expected]) => actual !== expected) + ) + throw new Error( + 'Report run is not a trusted default-branch schedule or manual workflow result.', + ); +} +function newer(candidate, remote) { + if (!['schedule', 'workflow_dispatch'].includes(candidate.event)) return false; + return ( + idNumber(candidate.id) > idNumber(remote.id) || + (String(candidate.id) === String(remote.id) && candidate.run_attempt > remote.run_attempt) + ); +} +async function verifyArtifact(report, evidence, run, cwd) { + const directory = await mkdtemp(path.join(tmpdir(), 'lvbt-audit-evidence-')); + try { + const { artifactName, filename } = evidence; + const metadata = JSON.parse( + await successful( + run, + 'gh', + ['api', `repos/${report.repository}/actions/runs/${report.run.id}/artifacts?per_page=100`], + { cwd, message: 'Could not verify audit artifact metadata.' }, + ), + ); + const artifacts = + metadata.artifacts?.filter( + (artifact) => artifact.name === artifactName && !artifact.expired, + ) ?? []; + if (artifacts.length !== 1 || !/^\d+$/.test(String(artifacts[0].id))) + throw new Error('Trusted audit artifact is missing, expired, or ambiguous.'); + await successful( + run, + 'gh', + [ + 'run', + 'download', + String(report.run.id), + '--repo', + report.repository, + '--name', + artifactName, + '--dir', + directory, + ], + { cwd, message: 'Could not download trusted audit artifact.' }, + ); + const stored = JSON.parse(await readFile(path.join(directory, filename), 'utf8')); + if (!isDeepStrictEqual(stored, report)) + throw new Error('Input report does not match the trusted workflow artifact.'); + return `${report.run.url}/artifacts/${artifacts[0].id}`; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} +export async function verifiedRun({ report, workflow, artifactName, filename, run, cwd }) { + const gh = async (args) => + JSON.parse( + await successful(run, 'gh', args, { cwd, message: 'GitHub provenance verification failed.' }), + ); + const repository = await gh(['api', `repos/${report.repository}`]); + const remote = await gh(['api', `repos/${report.repository}/actions/runs/${report.run.id}`]); + verifyIdentity(report, remote, repository, workflow); + const latest = await gh([ + 'api', + `repos/${report.repository}/actions/workflows/${remote.workflow_id}/runs?branch=${encodeURIComponent(repository.default_branch)}&per_page=100`, + ]); + if ( + !Array.isArray(latest.workflow_runs) || + latest.workflow_runs.some((candidate) => newer(candidate, remote)) + ) + throw new Error('Report is stale relative to a newer trusted workflow run.'); + const artifactUrl = await verifyArtifact(report, { artifactName, filename }, run, cwd); + return { gh, artifactUrl, runStartedAt: remote.run_started_at ?? remote.created_at }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/env-file.d.mts b/.lvbt/web-platform/packages/cli/src/lib/env-file.d.mts new file mode 100644 index 0000000..c040d4e --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/env-file.d.mts @@ -0,0 +1,10 @@ +export function parseEnvFile(filePath: string): Map; +export function mergeEnvFile(filePath: string, updates: Map): boolean; +export function loadEnvFile( + filePath: string, + environment?: Record, +): void; +export function loadEnvLocal( + projectRoot: string, + environment?: Record, +): void; diff --git a/.lvbt/web-platform/packages/cli/src/lib/env-file.mjs b/.lvbt/web-platform/packages/cli/src/lib/env-file.mjs new file mode 100644 index 0000000..c1819b6 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/env-file.mjs @@ -0,0 +1,69 @@ +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +function parseLine(line) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return null; + const separator = trimmed.indexOf('='); + if (separator === -1) return null; + const key = trimmed.slice(0, separator).trim(); + let value = trimmed.slice(separator + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) + value = value.slice(1, -1); + return { key, value }; +} + +/** Read local values without changing their file or the process environment. */ +export function parseEnvFile(filePath) { + const entries = new Map(); + if (!existsSync(filePath)) return entries; + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + const entry = parseLine(line); + if (entry) entries.set(entry.key, entry.value); + } + return entries; +} + +function quoteEnvValue(value) { + return /[\s"'#]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; +} + +/** Update only named values, preserving unrelated lines and no-op writes. */ +export function mergeEnvFile(filePath, updates) { + const exists = existsSync(filePath); + const content = exists ? readFileSync(filePath, 'utf8') : ''; + const keys = new Set(); + const lines = content.split('\n').map((line) => { + const entry = parseLine(line); + if (!entry) return line; + keys.add(entry.key); + const value = updates.get(entry.key); + return value === undefined || value === entry.value + ? line + : `${entry.key}=${quoteEnvValue(value)}`; + }); + for (const [key, value] of updates) + if (!keys.has(key)) lines.push(`${key}=${quoteEnvValue(value)}`); + const next = lines.join('\n'); + if (exists && next === content) return false; + writeFileSync(filePath, next); + try { + chmodSync(filePath, 0o600); + } catch { + /* Filesystems without POSIX modes can still keep local configuration. */ + } + return true; +} + +/** Fill missing environment values; explicit shell overrides always win. */ +export function loadEnvFile(filePath, environment = process.env) { + for (const [key, value] of parseEnvFile(filePath)) + if (environment[key] === undefined) environment[key] = value; +} + +export function loadEnvLocal(projectRoot, environment = process.env) { + loadEnvFile(path.join(projectRoot, '.env.local'), environment); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/install-fingerprint.mjs b/.lvbt/web-platform/packages/cli/src/lib/install-fingerprint.mjs new file mode 100644 index 0000000..5870d32 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/install-fingerprint.mjs @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const STAMP = 'node_modules/.lvbt-install.json'; +const LEGACY = 'node_modules/.lvbt-lockfile-hash'; +function lockfileHash(cwd) { + return createHash('sha256') + .update(readFileSync(path.join(cwd, 'pnpm-lock.yaml'))) + .digest('hex'); +} +export function recordInstall(cwd, { node = process.versions.node, pnpm }) { + if (!pnpm) throw new Error('Cannot record installation without the installed pnpm version.'); + const fingerprint = { version: 1, node, pnpm, lockfile: lockfileHash(cwd) }; + writeFileSync(path.join(cwd, STAMP), `${JSON.stringify(fingerprint)}\n`, { mode: 0o600 }); +} +export function checkInstall(cwd, { node = process.versions.node, pnpm }) { + const label = 'installed tree'; + const fail = (detail) => ({ ok: false, label, detail, fix: 'pnpm bootstrap' }); + const stamp = path.join(cwd, STAMP); + let lockfile; + try { + lockfile = lockfileHash(cwd); + } catch { + return fail('pnpm-lock.yaml is missing or unreadable'); + } + if (!existsSync(stamp)) { + const legacy = path.join(cwd, LEGACY); + if (existsSync(legacy) && readFileSync(legacy, 'utf8').trim() !== lockfile) + return fail('node_modules was installed from a different pnpm-lock.yaml'); + return { + ok: true, + warning: true, + label, + detail: 'This pre-0.7 installation has no shared fingerprint yet.', + fix: 'pnpm bootstrap', + }; + } + let recorded; + try { + recorded = JSON.parse(readFileSync(stamp, 'utf8')); + } catch { + return fail('The install fingerprint is unreadable.'); + } + if ( + recorded?.version !== 1 || + recorded.node !== node || + recorded.pnpm !== pnpm || + recorded.lockfile !== lockfile + ) + return fail('node_modules does not match the current Node, pnpm, and lockfile'); + return { ok: true, label, detail: 'matches Node, pnpm, and pnpm-lock.yaml' }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/local-environment.d.mts b/.lvbt/web-platform/packages/cli/src/lib/local-environment.d.mts new file mode 100644 index 0000000..4e893d4 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/local-environment.d.mts @@ -0,0 +1,11 @@ +export interface LocalFinding { + ok: boolean; + warning?: boolean; + label: string; + detail: string; + fix?: string; +} +export declare function localEnvironment( + cwd: string, + options?: { apply?: boolean }, +): LocalFinding[]; diff --git a/.lvbt/web-platform/packages/cli/src/lib/local-environment.mjs b/.lvbt/web-platform/packages/cli/src/lib/local-environment.mjs new file mode 100644 index 0000000..64d4378 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/local-environment.mjs @@ -0,0 +1,66 @@ +import { constants, copyFileSync, chmodSync, existsSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { CliError } from './arguments.mjs'; +import { readTooling } from './tooling.mjs'; +import { parseEnvFile } from './env-file.mjs'; + +function inside(cwd, file) { + const root = realpathSync(cwd); + const location = path.resolve(cwd, file); + const actual = realpathSync(existsSync(location) ? location : path.dirname(location)); + const relative = path.relative(root, actual); + if (relative.startsWith('..') || path.isAbsolute(relative)) + throw new CliError('Local environment paths must stay inside the repository.', 2); + return location; +} +export function localEnvironment(cwd, { apply = false } = {}) { + const config = readTooling(cwd).local ?? {}; + const findings = []; + const values = new Map(); + for (const entry of config.env ?? []) { + const example = inside(cwd, entry.example); + const file = inside(cwd, entry.file); + if (!existsSync(example)) { + findings.push({ + ok: false, + label: 'local environment', + detail: `${entry.example} is missing`, + fix: `restore ${entry.example}`, + }); + continue; + } + if (!existsSync(file) && apply) { + copyFileSync(example, file, constants.COPYFILE_EXCL); + chmodSync(file, 0o600); + } + if (!existsSync(file)) { + findings.push({ + ok: false, + label: 'local environment', + detail: `${entry.file} is missing`, + fix: 'pnpm bootstrap', + }); + continue; + } + findings.push({ ok: true, label: 'local environment', detail: `${entry.file} is ready` }); + for (const [name, value] of parseEnvFile(file)) values.set(name, value); + } + findings.push(...optionalFindings(config.optional ?? [], values)); + return findings; +} + +function optionalFindings(optional, values) { + const findings = []; + for (const entry of optional) { + const value = process.env[entry.name] ?? values.get(entry.name); + if (!value || /PLACEHOLDER/.test(value)) + findings.push({ + ok: true, + warning: true, + label: 'integration', + detail: `${entry.purpose}: optional local configuration is missing (${entry.name})`, + fix: 'Configure the integration when working on that feature; page development can continue.', + }); + } + return findings; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs index 3d25dfe..0c094e7 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs @@ -3,11 +3,19 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { CliError } from './arguments.mjs'; import { exists, readJson } from './files.mjs'; +import { checkInstall, recordInstall } from './install-fingerprint.mjs'; +import { localEnvironment } from './local-environment.mjs'; import { findManifests } from './platform/manifest.mjs'; import { platformBootstrap, platformPreflight } from './platform/index.mjs'; +import { readTooling } from './tooling.mjs'; +import { secretScannerFinding } from './check/secrets.mjs'; function output(command, args, cwd) { - const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, pnpm_config_verify_deps_before_run: 'error' }, + }); return result.status === 0 ? result.stdout.trim() : undefined; } @@ -26,8 +34,9 @@ function satisfies(version, range) { } const WRANGLER_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']; +const CF_FILE = 'cloudflare.config.ts'; -export function wranglerDeployArguments(commit, dryRun) { +export function wranglerDeployArguments(commit, dryRun, environment) { if (!/^[a-f0-9]{40}$/.test(commit)) throw new CliError('deploy: provenance requires a full Git commit.', 2); return [ @@ -37,6 +46,21 @@ export function wranglerDeployArguments(commit, dryRun) { '--strict', '--message', `Commit ${commit}`, + ...(environment ? ['--env', environment] : []), + ...(dryRun ? ['--dry-run'] : []), + ]; +} + +export function cfDeployArguments(commit, dryRun, environment) { + if (!/^[a-f0-9]{40}$/.test(commit)) + throw new CliError('deploy: provenance requires a full Git commit.', 2); + return [ + 'exec', + 'cf', + 'deploy', + '--message', + `Commit ${commit}`, + ...(environment ? ['--mode', environment] : []), ...(dryRun ? ['--dry-run'] : []), ]; } @@ -61,20 +85,48 @@ async function wranglerConfig(directory) { return undefined; } -/** Every directory with a wrangler config: the root, then each apps/*. */ -async function deployables(cwd) { - const found = []; - if (await wranglerConfig(cwd)) found.push('.'); +async function canonicalDeployable(cwd, directory, canonical, scope) { + const configFile = path.resolve(directory, canonical); + if (path.basename(configFile) !== CF_FILE) + throw new CliError(`deploy: canonical cf config must be named ${CF_FILE}.`, 2); + if (!(await exists(configFile))) + throw new CliError(`deploy: canonical cf config ${configFile} is missing.`, 2); + const target = path.relative(cwd, path.dirname(configFile)) || '.'; + if (target.startsWith('..') || path.isAbsolute(target)) + throw new CliError('deploy: canonical cf config must be inside this repository.', 2); + return { directory: target, tool: 'cf', source: path.relative(cwd, directory) || '.', ...scope }; +} + +async function deployable(cwd, directory) { + const manifestPath = path.join(directory, 'platform.json'); + const manifest = (await exists(manifestPath)) ? await readJson(manifestPath) : {}; + const environment = manifest.cloudflare?.environment; + const scope = environment ? { environment } : {}; + const canonical = manifest.cloudflare?.cloudflareConfig; + if (canonical) return await canonicalDeployable(cwd, directory, canonical, scope); + const relative = path.relative(cwd, directory) || '.'; + if (await exists(path.join(directory, CF_FILE))) + return { directory: relative, tool: 'cf', ...scope }; + if (await wranglerConfig(directory)) return { directory: relative, tool: 'wrangler', ...scope }; + return undefined; +} + +/** Every configured project: the root, then each apps/*. Cf wins when both configs remain. */ +export async function deployables(cwd) { + const found = new Map(); + const rootTarget = await deployable(cwd, cwd); + if (rootTarget) found.set(rootTarget.directory, rootTarget); const apps = await readdir(path.join(cwd, 'apps'), { withFileTypes: true }).catch(() => []); for (const entry of apps) { - if (entry.isDirectory() && (await wranglerConfig(path.join(cwd, 'apps', entry.name)))) { - found.push(`apps/${entry.name}`); - } + if (!entry.isDirectory()) continue; + const target = await deployable(cwd, path.join(cwd, 'apps', entry.name)); + if (target && (!found.has(target.directory) || target.source)) + found.set(target.directory, target); } - return found; + return [...found.values()]; } -async function toolchainFindings(cwd, packageJson, report) { +async function toolchainFindings(cwd, packageJson, report, { includeDependencies = true } = {}) { const nodeRange = packageJson.engines?.node ?? '>=24'; if (satisfies(process.versions.node, nodeRange)) report.pass('Node.js', `${process.versions.node} satisfies ${nodeRange}`); @@ -107,12 +159,17 @@ async function toolchainFindings(cwd, packageJson, report) { ); else report.pass('pnpm', `${pnpm} matches packageManager`); - if (await exists(path.join(cwd, 'node_modules'))) + if (!includeDependencies) return; + if (await exists(path.join(cwd, 'node_modules'))) { report.pass('dependencies', 'node_modules is present'); - else report.fail('dependencies', 'node_modules is missing', 'pnpm install'); + const installed = checkInstall(cwd, { pnpm }); + if (!installed.ok) report.fail(installed.label, installed.detail, installed.fix); + else if (installed.warning) report.warn(installed.label, installed.detail, installed.fix); + else report.pass(installed.label, installed.detail); + } else report.fail('dependencies', 'node_modules is missing', 'pnpm install'); } -async function repositoryFindings(cwd, report) { +async function repositoryFindings(cwd, report, { production = false } = {}) { const hooksPath = output('git', ['config', '--local', 'core.hooksPath'], cwd); if (hooksPath === '.githooks') report.pass('git hooks', 'core.hooksPath is .githooks'); else @@ -133,7 +190,8 @@ async function repositoryFindings(cwd, report) { "copy .lvbt/commit-scopes.txt from the standard example and list this repository's scopes", ); - // Issues and pull requests are created by people, so a runner does not need gh. + if (!production) return; + // Publishing operations check authentication; ordinary local setup does not. if (process.env.CI) { report.pass('GitHub CLI', 'not needed in CI'); return; @@ -148,16 +206,55 @@ async function repositoryFindings(cwd, report) { ); } -async function cloudflareFindings(cwd, report) { +async function cloudflareFindings(cwd, report, { productionBootstrap = false } = {}) { const targets = await deployables(cwd); if (targets.length === 0) { - report.pass('Cloudflare', 'no wrangler config; nothing to deploy from here'); + report.pass('Cloudflare', 'no Cloudflare project config; nothing to deploy from here'); return; } - const whoami = output('pnpm', ['exec', 'wrangler', 'whoami'], cwd); - if (whoami && !/not authenticated/i.test(whoami)) - report.pass('Cloudflare', `wrangler is signed in; deployables: ${targets.join(', ')}`); - else report.fail('Cloudflare', 'wrangler is not signed in', 'pnpm exec wrangler login'); + const cfTargets = targets.filter((target) => target.tool === 'cf'); + const wranglerTargets = targets.filter((target) => target.tool === 'wrangler'); + if (cfTargets.length > 0) { + const whoami = output( + 'pnpm', + ['exec', 'cf', 'auth', 'whoami'], + path.join(cwd, cfTargets[0].directory), + ); + let authenticated = false; + try { + const identity = JSON.parse(whoami); + authenticated = identity.authenticated === true && identity.tokenValid !== false; + } catch { + // An absent CLI or malformed response is not authentication. + } + if (authenticated) + report.pass( + 'Cloudflare cf', + `ready for: ${cfTargets.map((target) => target.directory).join(', ')}`, + ); + else if (productionBootstrap) + report.warn( + 'Cloudflare cf', + 'cf is not signed in; setup can continue, but cf resource commands need authentication', + 'provide CLOUDFLARE_API_TOKEN for this session or run pnpm exec cf auth login', + ); + else + report.fail( + 'Cloudflare cf', + 'cf is not signed in', + 'provide CLOUDFLARE_API_TOKEN for this session or run pnpm exec cf auth login', + ); + } + if (wranglerTargets.length > 0) { + const whoami = output('pnpm', ['exec', 'wrangler', 'whoami'], cwd); + if (whoami && !/not authenticated/i.test(whoami)) + report.pass( + 'Cloudflare Wrangler', + `ready for: ${wranglerTargets.map((target) => target.directory).join(', ')}`, + ); + else + report.fail('Cloudflare Wrangler', 'wrangler is not signed in', 'pnpm exec wrangler login'); + } } /** @@ -165,24 +262,36 @@ async function cloudflareFindings(cwd, report) { * command that fixes it. Returns the failures instead of throwing, so * `--production` can still report on production. */ -async function machineFindings(cwd) { +async function machineFindings( + cwd, + { + production = false, + productionBootstrap = false, + toolsOnly = false, + includeDependencies = true, + localApply = false, + } = {}, +) { const packageJson = await readJson(path.join(cwd, 'package.json')); const findings = []; const report = { pass: (label, detail) => findings.push({ ok: true, label, detail }), + warn: (label, detail, fix) => findings.push({ ok: true, warning: true, label, detail, fix }), fail: (label, detail, fix) => findings.push({ ok: false, label, detail, fix }), }; - await toolchainFindings(cwd, packageJson, report); - await repositoryFindings(cwd, report); - await cloudflareFindings(cwd, report); - - for (const finding of findings) { - process.stdout.write( - ` ${finding.ok ? 'ok ' : 'FAIL'} ${finding.label.padEnd(14)} ${finding.detail}\n`, - ); - if (!finding.ok) process.stdout.write(` fix: ${finding.fix}\n`); + await toolchainFindings(cwd, packageJson, report, { includeDependencies }); + if (!toolsOnly) { + await repositoryFindings(cwd, report, { production }); + if (!production) { + findings.push(...localEnvironment(cwd, { apply: localApply })); + const scanner = secretScannerFinding({ cwd }); + if (scanner) findings.push(scanner); + } + if (production) await cloudflareFindings(cwd, report, { productionBootstrap }); } + + printFindings(findings); const failed = findings.filter((finding) => !finding.ok); if (failed.length === 0) process.stdout.write(`preflight: all ${findings.length} checks passed\n`); @@ -201,7 +310,7 @@ export async function preflight({ cwd, options = {} }) { 'preflight never changes anything; use --rotate with pnpm bootstrap --production.', 2, ); - const machine = await machineFindings(cwd); + const machine = await machineFindings(cwd, { production: options.production }); if (options.production) { try { await platformPreflight({ cwd, options }); @@ -221,11 +330,18 @@ export async function preflight({ cwd, options = {} }) { export async function bootstrap({ cwd, options = {} }) { if (options.rotate !== undefined && !options.production) throw new CliError('--rotate replaces production secrets, so it needs --production.', 2); + const tools = await machineFindings(cwd, { toolsOnly: true, includeDependencies: false }); + if (tools) throw new CliError(tools, 1); process.stdout.write('pnpm install\n'); const install = spawnSync('pnpm', ['install'], { cwd, stdio: 'inherit' }); if (install.status !== 0) throw new CliError('bootstrap: pnpm install failed', install.status ?? 1); - const machine = await machineFindings(cwd); + recordInstall(cwd, { pnpm: output('pnpm', ['--version'], cwd) }); + const machine = await machineFindings(cwd, { + production: options.production, + productionBootstrap: options.production, + localApply: !options.production, + }); if (machine) throw new CliError(machine, 1); if (options.production) { await platformBootstrap({ cwd, options }); @@ -241,21 +357,28 @@ export async function bootstrap({ cwd, options = {} }) { } /** - * Build, then deploy with Wrangler: the same steps every deployable repository - * runs. Every app with a wrangler config deploys, or just the one `--filter` + * Build, then deploy with the CLI selected by each project's config. Every app + * with a Cloudflare config deploys, or just the one `--filter` * names. */ export async function deploy({ cwd, options }) { + if (readTooling(cwd).release && !options.dryRun) + throw new CliError( + 'deploy: this repository publishes a saved staging release; push the reviewed change to main, then use pnpm promote for production.', + 2, + ); let targets = await deployables(cwd); if (options.filter) - targets = targets.filter( - (target) => target === options.filter || target === `apps/${options.filter}`, + targets = targets.filter((target) => + [target.directory, target.source].some( + (directory) => directory === options.filter || directory === `apps/${options.filter}`, + ), ); if (targets.length === 0) { throw new CliError( options.filter - ? `deploy: ${options.filter} has no wrangler.jsonc, wrangler.json, or wrangler.toml.` - : 'deploy: no wrangler.jsonc, wrangler.json, or wrangler.toml at the root or under apps/.', + ? `deploy: ${options.filter} has no wrangler config or cloudflare.config.ts.` + : 'deploy: no wrangler config or cloudflare.config.ts at the root or under apps/.', 2, ); } @@ -269,10 +392,43 @@ export async function deploy({ cwd, options }) { for (const target of targets) { if (deploymentCommit(cwd) !== commit) throw new CliError('deploy: repository changed after the production build.', 2); - const args = wranglerDeployArguments(commit, options.dryRun); - process.stdout.write(`${target}: pnpm ${args.join(' ')}\n`); - const result = spawnSync('pnpm', args, { cwd: path.join(cwd, target), stdio: 'inherit' }); + const args = + target.tool === 'cf' + ? cfDeployArguments(commit, options.dryRun, target.environment) + : wranglerDeployArguments(commit, options.dryRun, target.environment); + process.stdout.write(`${target.directory}: pnpm ${args.join(' ')}\n`); + const result = spawnSync('pnpm', args, { + cwd: path.join(cwd, target.directory), + stdio: 'inherit', + }); if (result.status !== 0) - throw new CliError(`deploy: wrangler deploy failed in ${target}`, result.status ?? 1); + throw new CliError( + `deploy: ${target.tool} deploy failed in ${target.directory}`, + result.status ?? 1, + ); } } + +function printFindings(findings) { + for (const finding of findings) { + process.stdout.write( + ` ${finding.warning ? 'WARN' : finding.ok ? 'ok ' : 'FAIL'} ${finding.label.padEnd(14)} ${finding.detail}\n`, + ); + if (finding.fix) process.stdout.write(` fix: ${finding.fix}\n`); + } +} + +/** Postinstall only: a manual invocation must not bless a stale dependency tree. */ +export function setupRecord({ cwd, options = {} }) { + if ( + process.env.npm_lifecycle_event !== 'postinstall' || + !process.env.npm_config_user_agent?.startsWith('pnpm/') + ) + throw new CliError( + 'setup-record runs only from pnpm postinstall; use pnpm bootstrap to install and record the tree.', + 2, + ); + if (options.production || options.rotate || options.positional?.length) + throw new CliError('setup-record accepts no arguments.', 2); + recordInstall(cwd, { pnpm: output('pnpm', ['--version'], cwd) }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs index dbd9055..070025e 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs @@ -1,4 +1,6 @@ +import { configForEnvironment } from './config-scope.mjs'; import { SETUP } from './plan.mjs'; +import { configVarEntry, configVarLocation } from './guides.mjs'; import { paint } from './terminal.mjs'; import { account, manualStep, storeFed, succeeded, targetName, wrangler } from './apply-steps.mjs'; @@ -25,7 +27,7 @@ export async function createWidget(context, action) { context.created.widgets.set(widget.name, created); context.io.write(`${paint('green', 'Created')} the Turnstile widget ${widget.name}.\n`); context.io.write( - `Put "${widget.siteKeyVar}": "${created.sitekey}" in vars in ${context.configPath}, commit it, and deploy. The site key is public.\n`, + `Add ${configVarEntry(widget.siteKeyVar, created.sitekey, context.configPath)} to ${configVarLocation(context.configPath)} in ${context.configPath}, commit it, and deploy. The site key is public.\n`, ); await storeFed(context, widget.secret, created.secret); } @@ -172,19 +174,20 @@ export async function deleteSecret(context, action) { } /** - * Whether the config names the database this run created. Wrangler applies - * migrations to the config's database_id, so until a pull request puts the - * new id there, applying them would reach the wrong database or none. + * Whether the config names the database this run created. The account + * inventory supplies its ID; any explicit ID in the config must agree. */ export async function namedInConfig(context, action) { const state = context.observe ? await context.observe() : context.state; const real = state.d1.ok ? state.d1.value[action.name] : undefined; - const bound = state.config.ok - ? state.config.value.d1.find((entry) => entry.binding === action.binding) + const config = configForEnvironment(state.config, action.environment); + const bound = config.ok + ? config.value.d1.find((entry) => entry.binding === action.binding) : undefined; - if (real && bound?.id === real.id) return true; + if (real && bound?.name === action.name && (bound.id === undefined || bound.id === real.id)) + return true; context.io.write( - `The migrations for ${action.name} wait until ${context.configPath} has database_id ${real?.id ?? 'of the new database'}. Run ${SETUP} again after that pull request merges.\n`, + `The migrations for ${action.name} wait until ${context.configPath} binds ${action.binding} to ${action.name}${bound?.id && real ? ` with ID ${real.id}` : ''}. Run ${SETUP} again after that pull request merges.\n`, ); return false; } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs index 9acce1b..4a50bf0 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs @@ -1,4 +1,6 @@ +import { secretForTarget, secretValueKey } from './secret-scope.mjs'; import { randomBytes } from 'node:crypto'; +import path from 'node:path'; import { redact } from './services.mjs'; import { paint } from './terminal.mjs'; @@ -19,7 +21,12 @@ export function account(context) { } export function wrangler(context, args, options = {}) { - return context.run('pnpm', ['exec', 'wrangler', ...args], { + const environment = context.manifest.cloudflare.environment; + const scoped = + environment && !args.includes('--env') && (args[0] === 'secret' || args.includes('--remote')) + ? ['--env', environment] + : []; + return context.run('pnpm', ['exec', 'wrangler', ...args, ...scoped], { cwd: context.directory, env: { CLOUDFLARE_ACCOUNT_ID: context.manifest.cloudflare.accountId, @@ -29,6 +36,18 @@ export function wrangler(context, args, options = {}) { }); } +export function cf(context, args, options = {}) { + const config = context.manifest.cloudflare.cloudflareConfig; + if (!config) throw new Error('cf requires cloudflare.cloudflareConfig in platform.json'); + const environment = context.manifest.cloudflare.environment; + const scoped = environment && !args.includes('--mode') ? ['--mode', environment] : []; + return context.run('pnpm', ['exec', 'cf', ...args, ...scoped], { + cwd: path.resolve(context.directory, path.dirname(config)), + env: { CLOUDFLARE_ACCOUNT_ID: context.manifest.cloudflare.accountId }, + ...options, + }); +} + export function succeeded(result, secret) { if (result.status === 0) return; const output = `${result.stderr}\n${result.stdout}`.trim().split('\n').slice(-3).join(' '); @@ -90,7 +109,7 @@ export async function storeSecret(context, name, target, value) { ); } context.handled.add(`secret:${name}:${target}`); - const secret = context.manifest.secrets?.find((candidate) => candidate.name === name); + const secret = secretForTarget(context.manifest, name, target); const shown = secret?.sensitive === false ? ` = ${value}` : ''; context.io.write( `${paint('green', 'Stored')} ${name}${shown} on ${targetName(context, target)}.\n`, @@ -100,6 +119,7 @@ export async function storeSecret(context, name, target, value) { /** Store a value a new resource produced, straight away, so it cannot be lost or left stale. */ export async function storeFed(context, name, value) { if (!value || !context.state.worker.ok || !context.state.worker.value.exists) return; - context.values.set(name, value); + const secret = secretForTarget(context.manifest, name, 'worker'); + context.values.set(secret ? secretValueKey(context.manifest, secret) : name, value); await storeSecret(context, name, 'worker', value); } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs index cbccece..3cd77e1 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs @@ -1,3 +1,4 @@ +import { secretValueKey } from './secret-scope.mjs'; import { accessAppGuide, teamDomainGuide, turnstileGuide } from './guides.mjs'; import { findApp, findWidget, isSensitive, secretSource } from './plan.mjs'; import { paint } from './terminal.mjs'; @@ -91,7 +92,8 @@ async function knownValue(context, source) { export async function secretValue(context, action) { const { secret, source } = action; - if (context.values.has(secret.name)) return context.values.get(secret.name); + const key = secretValueKey(context.manifest, secret); + if (context.values.has(key)) return context.values.get(key); let value; if (source.type === 'generate') { // The plan offers this only when no target holds a value yet, so every @@ -102,10 +104,47 @@ export async function secretValue(context, action) { value = await knownValue(context, source); } if (!value) value = await promptValue(context, secret, fedGuide(context, source)); - if (value) context.values.set(secret.name, value); + if (value) context.values.set(key, value); return value; } +/** Whether a target already had this value's name before this run. */ +function previouslyStored(context, secret, target) { + if (target === 'worker') + return context.state.worker.ok && context.state.worker.value.secrets.includes(secret.name); + return ( + context.state.github.ok && + context.state.github.value.secrets[target.slice(7)]?.includes(secret.name) === true + ); +} + +/** + * Instructions for an external consumer follow successful storage on every + * target. A generated credential remains hidden unless its maintainer asks + * for one copy explicitly, and the offer is never repeated in this run. + */ +export async function completeSecretSetup(context, secret, { rotating = false, value } = {}) { + const scope = secretValueKey(context.manifest, secret); + const key = `afterSet:${scope}`; + value ??= context.values.get(scope); + if (!secret.afterSet || !value || context.handled.has(key)) return; + const complete = (secret.targets ?? ['worker']).every( + (target) => + context.handled.has(`secret:${secret.name}:${target}`) || + (!rotating && previouslyStored(context, secret, target)), + ); + if (!complete) return; + context.handled.add(key); + context.io.write(`\n${secret.name}: ${secret.afterSet}\n`); + if ( + await context.io.confirm( + `Show ${secret.name} once in this terminal so you can copy it to that external service?`, + false, + ) + ) + context.io.write(`${secret.name} = ${value}\n`); +} + /** The targets of a rotated secret that this run has not already stored. */ function rotationTargets(context, secret) { const targets = (secret.targets ?? ['worker']).filter( @@ -151,6 +190,7 @@ async function rotateSecret(context, secret) { ); } } + await completeSecretSetup(context, secret, { rotating: true, value }); } /** @@ -161,9 +201,7 @@ async function rotateSecret(context, secret) { * its value is new. Nothing is replaced without that flag. */ export async function rotateSecrets(context, names) { - for (const name of names) - await rotateSecret( - context, - context.manifest.secrets.find((secret) => secret.name === name), - ); + const selected = new Set(names); + for (const secret of context.manifest.secrets) + if (selected.has(secret.name)) await rotateSecret(context, secret); } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs index d504c84..b169595 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs @@ -1,7 +1,8 @@ +import path from 'node:path'; import { varGuide } from './guides.mjs'; import { SETUP } from './plan.mjs'; import { paint } from './terminal.mjs'; -import { manualStep, storeSecret, succeeded, wrangler } from './apply-steps.mjs'; +import { cf, manualStep, storeSecret, succeeded, wrangler } from './apply-steps.mjs'; import { createOrUpdateApp, createWidget, @@ -9,7 +10,7 @@ import { namedInConfig, updateWidget, } from './apply-resources.mjs'; -import { secretValue } from './apply-values.mjs'; +import { completeSecretSetup, secretValue } from './apply-values.mjs'; export { generateSecret, targetName } from './apply-steps.mjs'; export { rotateSecrets } from './apply-values.mjs'; @@ -22,24 +23,66 @@ export { rotateSecrets } from './apply-values.mjs'; */ async function createDatabase(context, action) { - succeeded(wrangler(context, ['d1', 'create', action.name])); + succeeded( + context.manifest.cloudflare.cloudflareConfig + ? cf(context, ['d1', 'create', '--name', action.name]) + : wrangler(context, ['d1', 'create', action.name]), + ); context.io.write( - `${paint('green', 'Created')} the D1 database ${action.name}. Put its database_id in ${context.configPath} through a pull request; the final report shows the id.\n`, + `${paint('green', 'Created')} the D1 database ${action.name}. Bind its name in ${context.configPath} through a pull request; the final report shows its ID if you choose to pin it.\n`, ); } async function migrateDatabase(context, action) { if (action.afterCreate && !(await namedInConfig(context, action))) return; - context.io.write( - `Applying migrations to ${action.name}. Wrangler lists them and asks you to confirm.\n`, - ); - succeeded( - wrangler(context, ['d1', 'migrations', 'apply', action.name, '--remote'], { inherit: true }), - ); + const cfConfig = context.manifest.cloudflare.cloudflareConfig; + if (cfConfig) { + const database = context.manifest.d1.find((entry) => entry.name === action.name); + const latest = action.afterCreate && context.observe ? await context.observe() : context.state; + const id = latest.d1.ok ? latest.d1.value[action.name]?.id : undefined; + if (!id) throw new Error(`cannot apply migrations before ${action.name} has a D1 ID`); + context.io.write(`Applying migrations to ${action.name} with cf.\n`); + succeeded( + cf( + context, + [ + 'd1', + 'migrations', + 'apply', + id, + '--dir', + path.resolve(context.directory, database.migrations), + ], + { inherit: true }, + ), + ); + } else { + context.io.write(`Applying migrations to ${action.name}. Wrangler asks you to confirm.\n`); + succeeded( + wrangler( + context, + [ + 'd1', + 'migrations', + 'apply', + action.name, + '--remote', + ...(action.environment ? ['--env', action.environment] : []), + ], + { + inherit: true, + }, + ), + ); + } } async function createBucket(context, action) { - succeeded(wrangler(context, ['r2', 'bucket', 'create', action.name])); + succeeded( + context.manifest.cloudflare.cloudflareConfig + ? cf(context, ['r2', 'buckets', 'create-by-name', action.name]) + : wrangler(context, ['r2', 'bucket', 'create', action.name]), + ); context.io.write(`${paint('green', 'Created')} the R2 bucket ${action.name}.\n`); } @@ -63,6 +106,7 @@ async function putSecret(context, action) { return; } await storeSecret(context, action.secret.name, action.target, value); + await completeSecretSetup(context, action.secret); } async function showSteps(context, action, entry) { diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/config-scope.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/config-scope.mjs new file mode 100644 index 0000000..b6f1c03 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/config-scope.mjs @@ -0,0 +1,11 @@ +/** Select only the named deployment's binding inventory; never fall back to production. */ +export function configForEnvironment(config, environment) { + if (!environment || !config?.ok) return config; + return ( + config.value?.environments?.[environment] ?? { + ok: false, + reason: `cannot read deployment environment ${environment}`, + kind: 'error', + } + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs index 130f700..3b3e00b 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs @@ -67,6 +67,51 @@ export function emailRecords(email) { const region = email.region ?? 'us-east-1'; const domain = email.domain; const mailFrom = `feedback-smtp.${region}.amazonses.com`; + const dkimAndDmarc = [ + { + key: 'dkim', + type: 'TXT', + name: `resend._domainkey.${domain}`, + purpose: 'signs every message (DKIM)', + expected: 'TXT resend._domainkey → the p=… value Resend shows', + matches: (data) => dnsText(data).startsWith('p='), + level: 'required', + }, + { + key: 'dmarc', + type: 'TXT', + name: `_dmarc.${domain}`, + purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)', + expected: 'TXT _dmarc → "v=DMARC1; p=none;"', + matches: (data) => dnsText(data).startsWith('v=DMARC1'), + level: 'recommended', + }, + ]; + if (email.dnsProfile === 'forge') { + const cnameMatches = (target) => (data) => + data.trim().replace(/\.$/, '').toLowerCase() === target; + return [ + { + key: 'rsend', + type: 'CNAME', + name: `rsend.${domain}`, + purpose: 'connects this sending domain to Resend', + expected: 'CNAME rsend → rsend.forge.rmta.net', + matches: cnameMatches('rsend.forge.rmta.net'), + level: 'required', + }, + { + key: 'send', + type: 'CNAME', + name: `send.${domain}`, + purpose: 'routes the return path through Resend', + expected: 'CNAME send → send.forge.rmta.net', + matches: cnameMatches('send.forge.rmta.net'), + level: 'required', + }, + ...dkimAndDmarc, + ]; + } return [ { key: 'mx', @@ -87,42 +132,28 @@ export function emailRecords(email) { dnsText(data).startsWith('v=spf1') && dnsText(data).includes('include:amazonses.com'), level: 'required', }, - { - key: 'dkim', - type: 'TXT', - name: `resend._domainkey.${domain}`, - purpose: 'signs every message (DKIM)', - expected: 'TXT resend._domainkey → the p=… value Resend shows', - matches: (data) => dnsText(data).startsWith('p='), - level: 'required', - }, - { - key: 'dmarc', - type: 'TXT', - name: `_dmarc.${domain}`, - purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)', - expected: 'TXT _dmarc → "v=DMARC1; p=none;"', - matches: (data) => dnsText(data).startsWith('v=DMARC1'), - level: 'recommended', - }, + ...dkimAndDmarc, ]; } export function resendDomainGuide(email, cloudflare) { const region = email.region ?? 'us-east-1'; const zone = cloudflare.zone.name; - const [mx, spf, dkim, dmarc] = emailRecords(email).map((record) => ({ - ...record, - short: relativeName(record.name, zone), - })); + const records = Object.fromEntries( + emailRecords(email).map((record) => [record.key, relativeName(record.name, zone)]), + ); + const forge = email.dnsProfile === 'forge'; + const manualRecords = forge + ? `To add them by hand, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records. Add these CNAME records with TTL "Auto" and Proxy status "DNS only": type CNAME, name ${records.rsend}, target rsend.forge.rmta.net; type CNAME, name ${records.send}, target send.forge.rmta.net. Add a third record: type TXT, name ${records.dkim}, content the long p=… value shown on this domain's page in Resend.` + : `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${records.mx}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${records.spf}, content v=spf1 include:amazonses.com ~all; type TXT, name ${records.dkim}, content the long p=… value Resend shows for it.`; return { url: 'https://resend.com/domains', steps: [ 'Sign in to Resend at https://resend.com/login. If you have no account, sign up at https://resend.com/signup with your @lasvegasfortransit.org address, then ask a maintainer to invite you to the LVBT team. Everything below belongs in that team, never in a personal one.', - `On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json and the DNS records both name it.`, - `The easiest way to add the DNS records is the "Sign in to Cloudflare" button on the domain's page in Resend. Approve the request in the Cloudflare window, and it adds every record for you.`, - `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${mx.short}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${spf.short}, content v=spf1 include:amazonses.com ~all; type TXT, name ${dkim.short}, content the long p=… value Resend shows for it.`, - `Add the DMARC record too, which Resend recommends: type TXT, name ${dmarc.short}, content v=DMARC1; p=none;.`, + `On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json names it${forge ? '.' : ' and the DNS records do too.'}`, + `If Resend offers a "Sign in to Cloudflare" button on the domain's page, you can use it to add the DNS records. Approve the request in the Cloudflare window, then check the records it added against the values Resend shows.`, + manualRecords, + `Add the DMARC record too, which Resend recommends: type TXT, name ${records.dmarc}, content v=DMARC1; p=none;.`, 'Back in Resend, click "Verify DNS Records". Wait until the domain\'s status says "Verified", usually within a few minutes (DNS can take up to 72 hours). Then run this command again.', ], }; @@ -305,13 +336,15 @@ export function googleGroupGuide(group, apps) { export function turnstileGuide(widget, cloudflare, configPath) { const config = configPath ?? 'the production wrangler config'; + const location = configVarLocation(config); + const entry = configVarEntry(widget.siteKeyVar, '', config); const mode = { managed: 'Managed', 'non-interactive': 'Non-interactive', invisible: 'Invisible' }; const createSteps = [ `Open Turnstile in the Cloudflare dashboard with the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). If a widget named "${widget.name}" is already listed, click it and go to the step for the Site Key. ${ACCOUNT_NAME_CHECK}`, `Click "Add widget". Widget name: ${widget.name}.`, `Under "Hostname management", add ${widget.domains.join(', ')}.`, `Widget Mode: "${mode[widget.mode ?? 'managed']}". Leave pre-clearance off, and click "Create".`, - `If "vars" in ${config} already has "${widget.siteKeyVar}" with this widget's Site Key, skip this step. Otherwise copy the Site Key (public, starts with 0x) and paste it into "vars" in ${config} as "${widget.siteKeyVar}" now; save the file and commit it through a pull request later.`, + `If ${location} in ${config} already has "${widget.siteKeyVar}" with this widget's Site Key, skip this step. Otherwise copy the Site Key (public, starts with 0x) and paste it into ${location} in ${config} as ${entry} now; save the file and commit it through a pull request later.`, ]; const secretSteps = [ `Copy the widget's Secret Key (private, also starts with 0x) and paste it at this command's prompt. It is the Worker secret ${widget.secret}.`, @@ -366,16 +399,25 @@ export function setupTokenGuide(manifest) { }; } +export function configVarLocation(configPath) { + return configPath.endsWith('.ts') ? 'worker.env' : '"vars"'; +} + +export function configVarEntry(name, value, configPath) { + const shown = JSON.stringify(value); + return configPath.endsWith('.ts') ? `${name}: bindings.text(${shown})` : `"${name}": ${shown}`; +} + export function varGuide(variable, configPath, value, widget) { const shown = value === undefined ? widget - ? `""` - : '""' - : JSON.stringify(value); + ? `` + : '' + : value; return { steps: [ - `Add "${variable.name}": ${shown} to "vars" in ${configPath}. It is public, so it belongs in the config rather than in a secret.`, + `Add ${configVarEntry(variable.name, shown, configPath)} to ${configVarLocation(configPath)} in ${configPath}. It is public, so it belongs in the config rather than in a secret.`, 'Commit it on a branch and open a pull request. The Worker gets it on the next deploy from main.', ], }; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs index a6ad1c5..979f6e8 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs @@ -2,7 +2,7 @@ import path from 'node:path'; import { CliError } from '../arguments.mjs'; import { applyPlan, rotateSecrets } from './apply.mjs'; import { setupTokenGuide } from './guides.mjs'; -import { findManifests, loadManifest, MANIFEST_FILE } from './manifest.mjs'; +import { findManifests, loadManifest, MANIFEST_FILE, resolveManifestZone } from './manifest.mjs'; import { observePlatform } from './observe.mjs'; import { planPlatform, readiness, SETUP } from './plan.mjs'; import { formatReport } from './report.mjs'; @@ -33,6 +33,17 @@ export function defaultServices() { }; } +/** Resolve a manifest's account selector before any production API call. */ +export function resolveManifestAccount(manifest, env) { + manifest = resolveManifestZone(manifest, env); + if (!manifest.cloudflare.accountIdEnv) return manifest; + const name = manifest.cloudflare.accountIdEnv; + const accountId = env[name]?.trim(); + if (!/^[0-9a-f]{32}$/.test(accountId ?? '')) + throw new CliError(`${name} must be set to the Cloudflare account's 32-character ID.`, 2); + return { ...manifest, cloudflare: { ...manifest.cloudflare, accountId } }; +} + /** The manifests `--filter` selects: `apps/site`, `site`, or `.` for the root. */ export function selectManifests(cwd, filter) { const all = findManifests(cwd); @@ -94,13 +105,19 @@ function setupApiProvider({ manifest, services, io, interactive }) { } async function inspect({ cwd, file, services, io, interactive, askForToken }) { - const manifest = loadManifest(path.join(cwd, file)); + const manifest = resolveManifestAccount(loadManifest(path.join(cwd, file)), services.env); const directory = path.join(cwd, path.dirname(file)); const configPath = path.join( path.dirname(file), - manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', + manifest.cloudflare.cloudflareConfig ?? manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', ); - const signedIn = wranglerToken(services.run, directory); + // Inventory needs Workers, D1 and R2 read scopes. The narrower deploy token + // is deliberately not used here. Cf beta cannot export its OAuth token. + const apiToken = services.env.LVBT_CLOUDFLARE_INVENTORY_TOKEN?.trim(); + const signedIn = apiToken ? { token: apiToken } : wranglerToken(services.run, directory); + if (!signedIn.token && manifest.cloudflare.cloudflareConfig) + signedIn.reason = + 'cf beta OAuth cannot supply the production observer; set LVBT_CLOUDFLARE_INVENTORY_TOKEN with Workers, D1 and R2 read access, or run pnpm exec wrangler login'; const setupApi = setupApiProvider({ manifest, services, io, interactive }); const apis = { wrangler: signedIn.token ? cloudflareApi(signedIn.token, services.request) : undefined, @@ -197,6 +214,13 @@ export function rotationNames(option, manifests) { `--rotate names ${unknownNames.join(', ')}, which platform.json does not declare as a secret.`, 2, ); + const listOnly = new Set( + manifests.flatMap((manifest) => + (manifest.secrets ?? []).filter((secret) => secret.listOnly).map((secret) => secret.name), + ), + ); + if (names.some((name) => listOnly.has(name))) + throw new CliError('--rotate cannot replace list-only future credentials.', 2); return names; } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs index 07524d7..5bcef4c 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs @@ -1,7 +1,9 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { secretForTarget, scopedSecretErrors } from './secret-scope.mjs'; import { validateAgainstSchema } from './schema.mjs'; +import { cloudflareWorkerBindings, wranglerWorkerBindings } from './worker-bindings.mjs'; export const MANIFEST_FILE = 'platform.json'; export const SCHEMA_PATH = fileURLToPath(new URL('../../../platform.schema.json', import.meta.url)); @@ -86,7 +88,9 @@ function duplicates(values) { } function duplicateErrors(manifest) { - const errors = duplicates([...names(manifest.secrets), ...names(manifest.vars)]).map( + const repeated = duplicates(names(manifest.vars)); + repeated.push(...names(manifest.secrets).filter((name) => names(manifest.vars).includes(name))); + const errors = [...new Set(repeated)].map( (name) => `${name} is declared more than once across secrets and vars.`, ); for (const label of ['d1', 'r2', 'turnstile', 'access']) { @@ -110,7 +114,7 @@ export function fedSecrets(manifest) { function referenceErrors(manifest) { const errors = []; - const secrets = new Map((manifest.secrets ?? []).map((secret) => [secret.name, secret])); + const secrets = new Set(names(manifest.secrets)); const vars = new Set(names(manifest.vars)); const references = [ ...fedSecrets(manifest), @@ -119,10 +123,9 @@ function referenceErrors(manifest) { .map((email) => ({ name: email.apiKeySecret, owner: `email "${email.domain}"` })), ]; for (const { name, owner } of references) { - const secret = secrets.get(name); - if (!secret) errors.push(`${owner} feeds ${name}, which is not listed in secrets.`); - else if (!(secret.targets ?? ['worker']).includes('worker')) - errors.push(`${owner} feeds ${name}, so ${name} must target the worker.`); + const secret = secretForTarget(manifest, name, 'worker'); + if (!secrets.has(name)) errors.push(`${owner} feeds ${name}, which is not listed in secrets.`); + else if (!secret) errors.push(`${owner} feeds ${name}, so ${name} must target the worker.`); } for (const widget of manifest.turnstile ?? []) { if (!vars.has(widget.siteKeyVar)) @@ -161,10 +164,16 @@ const targetsGithub = (entry) => (entry.targets ?? []).some((target) => target.startsWith('github:')); function secretErrors(manifest) { - const errors = []; const fed = new Set(fedSecrets(manifest).map((entry) => entry.name)); - for (const secret of manifest.secrets ?? []) { - const sources = [secret.generate === true, secret.from !== undefined, fed.has(secret.name)]; + return (manifest.secrets ?? []).flatMap((secret) => { + const errors = []; + if (secret.listOnly && secret.use !== 'future') + errors.push(`${secret.name} can be listOnly only while its use is future.`); + const sources = [ + secret.generate === true, + secret.from !== undefined, + fed.has(secret.name) && (secret.targets ?? ['worker']).includes('worker'), + ]; const count = sources.filter(Boolean).length; if (count > 1) errors.push( @@ -182,8 +191,8 @@ function secretErrors(manifest) { errors.push(`${secret.name} has a pattern that is not a valid regular expression.`); if (targetsGithub(secret) && !manifest.github) errors.push(`${secret.name} targets a GitHub environment, so github.repository is required.`); - } - return errors; + return errors; + }); } function forbiddenErrors(manifest) { @@ -199,10 +208,65 @@ function forbiddenErrors(manifest) { return errors; } +function accountErrors(manifest) { + const { accountId, accountIdEnv } = manifest.cloudflare; + return (accountId === undefined) === (accountIdEnv === undefined) + ? ['cloudflare needs exactly one of accountId or accountIdEnv.'] + : []; +} + +function zoneErrors(manifest) { + const { id, idEnv } = manifest.cloudflare.zone; + return (id === undefined) === (idEnv === undefined) + ? ['cloudflare.zone needs exactly one of id or idEnv.'] + : []; +} +function resourceBindingErrors(manifest) { + const entries = ['d1', 'r2', 'analyticsEngine', 'rateLimits'].flatMap((kind) => + (manifest[kind] ?? []).map((entry) => `${entry.environment ?? 'production'}:${entry.binding}`), + ); + return duplicates(entries).map( + (binding) => `${binding} resource binding is declared more than once.`, + ); +} +/** Resolve only the declared public zone identifier selector before any provider API call. */ +export function resolveManifestZone(manifest, env) { + const name = manifest.cloudflare.zone.idEnv; + if (!name) return manifest; + const id = env[name]?.trim(); + if (!/^[0-9a-f]{32}$/.test(id ?? '')) + throw new Error(`${name} must be set to the Cloudflare zone's 32-character ID.`); + return { + ...manifest, + cloudflare: { ...manifest.cloudflare, zone: { ...manifest.cloudflare.zone, id } }, + }; +} + +function githubVariableErrors(manifest) { + const errors = []; + const scopes = new Set(); + for (const variable of manifest.github?.variables ?? []) { + const key = `${variable.environment ?? 'repository'}:${variable.name}`; + if (scopes.has(key)) + errors.push( + `GitHub variable ${variable.name} is declared twice in ${variable.environment ?? 'repository'}.`, + ); + scopes.add(key); + if (variable.value !== undefined && variable.from !== undefined) + errors.push(`GitHub variable ${variable.name} cannot specify both value and from.`); + } + return errors; +} + /** The rules a JSON schema cannot express: names that refer to each other, and contradictions. */ function semanticErrors(manifest) { return [ + ...accountErrors(manifest), + ...zoneErrors(manifest), + ...resourceBindingErrors(manifest), ...duplicateErrors(manifest), + ...scopedSecretErrors(manifest.secrets), + ...githubVariableErrors(manifest), ...referenceErrors(manifest), ...accessErrors(manifest), ...secretErrors(manifest), @@ -234,10 +298,13 @@ export function loadManifest(file) { * The parts of the production wrangler config the platform check compares * against: the Worker's name, its vars, and its D1 and R2 bindings. */ -export function readWranglerConfig(file) { - const config = parseJsonc(readFileSync(file, 'utf8')); +export function readWranglerConfig(file, { environment } = {}) { + const production = parseJsonc(readFileSync(file, 'utf8')); + const config = environment ? production.env?.[environment] : production; + if (!config) throw new Error(`No ${environment} environment in ${file}.`); return { name: config.name, + ...wranglerWorkerBindings(config), vars: config.vars ?? {}, d1: (config.d1_databases ?? []).map((database) => ({ binding: database.binding, @@ -252,6 +319,33 @@ export function readWranglerConfig(file) { }; } +function configMode(mode) { + return { mode, isPreview: mode !== undefined && mode !== 'production' }; +} + +/** Read a cf project's canonical config, including its typed binding builders. */ +export async function readCloudflareConfig(file, { mode } = {}) { + const module = await import(pathToFileURL(file).href); + const project = + typeof module.default === 'function' ? await module.default(configMode(mode)) : module.default; + const worker = + typeof project?.worker === 'function' + ? await project.worker(configMode(mode)) + : project?.worker; + if (!worker?.name || !worker.env) + throw new Error(`${file} must declare a Worker with a name and env bindings.`); + const vars = {}; + const d1 = []; + const r2 = []; + for (const [binding, entry] of Object.entries(worker.env)) { + if (entry.type === 'text' || entry.type === 'json') vars[binding] = entry.value; + if (entry.type === 'd1') + d1.push({ binding, name: entry.name, id: entry.id, migrationsTable: 'd1_migrations' }); + if (entry.type === 'r2') r2.push({ binding, name: entry.name }); + } + return { name: worker.name, vars, d1, r2, ...cloudflareWorkerBindings(worker.env) }; +} + /** The .sql files in a migrations directory, in the order Wrangler applies them. */ export function migrationFiles(directory) { let files; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/observe-governance.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/observe-governance.mjs new file mode 100644 index 0000000..a110d52 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe-governance.mjs @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +export function observeGovernance(run, directory, manifest) { + if (!manifest.github?.governance) return { ok: true, value: [] }; + let root = directory; + while (!existsSync(path.join(root, '.lvbt/web-platform.json'))) { + const parent = path.dirname(root); + if (parent === root) + return { + ok: false, + reason: 'Apply the reviewed repository standard before checking governance.', + }; + root = parent; + } + try { + const require = createRequire(path.join(root, 'package.json')); + const entry = require.resolve('@lasvegasfortransit/web-platform/github'); + const result = run( + process.execPath, + [ + '--import', + require.resolve('tsx'), + path.join(path.dirname(entry), 'github-governance-command.ts'), + root, + manifest.github.repository, + ], + { cwd: root }, + ); + if (result.status !== 0) + return { + ok: false, + reason: + 'Shared governance inventory failed; check GitHub read permissions and the adopted release.', + }; + const checks = JSON.parse(result.stdout); + if ( + !Array.isArray(checks) || + checks.some( + (check) => + typeof check.id !== 'string' || + typeof check.requirement !== 'string' || + !['pass', 'fail', 'unknown'].includes(check.status), + ) + ) + throw new Error('Invalid governance inventory'); + return { ok: true, value: checks }; + } catch { + return { + ok: false, + reason: 'Install the adopted web-platform package and tsx before checking governance.', + }; + } +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs index 00f24ff..99b16eb 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs @@ -1,6 +1,9 @@ import path from 'node:path'; +import { deployedWorkerBindings } from './worker-bindings.mjs'; +import { observeGovernance } from './observe-governance.mjs'; +import { configForEnvironment } from './config-scope.mjs'; import { emailRecords } from './guides.mjs'; -import { migrationFiles, readWranglerConfig } from './manifest.mjs'; +import { migrationFiles, readCloudflareConfig, readWranglerConfig } from './manifest.mjs'; /** * Read everything the manifest names, without changing anything. Each part @@ -25,6 +28,9 @@ export function githubEnvironments(manifest) { const targets = [ ...(manifest.secrets ?? []).flatMap((secret) => secret.targets ?? []), ...(manifest.forbidden ?? []).flatMap((entry) => entry.targets ?? []), + ...(manifest.github?.variables ?? []).flatMap((entry) => + entry.environment ? [`github:${entry.environment}`] : [], + ), ]; return [ ...new Set( @@ -49,7 +55,12 @@ async function observeWorker(api, account, name) { if (binding.type === 'plain_text' || binding.type === 'json') vars[binding.name] = binding.text ?? binding.json; } - return { exists: true, secrets: (secrets ?? []).map((secret) => secret.name), vars }; + return { + exists: true, + secrets: (secrets ?? []).map((secret) => secret.name), + vars, + ...deployedWorkerBindings(settings?.bindings ?? []), + }; }); } @@ -60,13 +71,19 @@ async function observeD1(api, account, manifest, config) { const databases = await api.client.list(`${account}/d1/database`); const found = {}; for (const database of manifest.d1) { - const match = databases.find((candidate) => candidate.name === database.name); + const matches = databases.filter((candidate) => candidate.name === database.name); + if (matches.length > 1) + throw new Error( + `multiple D1 databases named ${database.name} exist in this Cloudflare account`, + ); + const [match] = matches; if (!match) continue; const entry = { id: match.uuid }; if (database.migrations) { const table = - config?.d1.find((item) => item.name === database.name)?.migrationsTable ?? - 'd1_migrations'; + configForEnvironment(known(config), database.environment)?.value?.d1.find( + (item) => item.name === database.name, + )?.migrationsTable ?? 'd1_migrations'; entry.applied = SQL_NAME.test(table) ? await attempt(async () => { try { @@ -147,7 +164,8 @@ function githubRead(run, cwd, args) { async function observeGithub(run, cwd, manifest) { const environments = githubEnvironments(manifest); - if (environments.length === 0) return known({ environments: [], secrets: {} }); + if (environments.length === 0 && !manifest.github?.variables?.length) + return known({ environments: [], secrets: {}, variables: known({}) }); const repository = manifest.github.repository; const value = { environments: [], secrets: {} }; for (const environment of environments) { @@ -168,9 +186,40 @@ async function observeGithub(run, cwd, manifest) { if (!listed.ok) return unknown(`gh: ${listed.reason}`, 'unauthorized'); value.secrets[environment] = JSON.parse(listed.stdout).map((secret) => secret.name); } + value.variables = await observeGithubVariables(run, cwd, manifest); return known(value); } +async function observeDomains(api, account, manifest) { + if (!manifest.cloudflare.domains?.length) return known([]); + if (!api.client) return api.missing; + return attempt(() => api.client.list(`${account}/workers/domains`)); +} + +async function observeGithubVariables(run, cwd, manifest) { + const variables = manifest.github?.variables ?? []; + if (variables.length === 0) return known({}); + return attempt(() => { + const values = {}; + for (const environment of new Set(variables.map((entry) => entry.environment))) { + const args = ['variable', 'list']; + if (environment) args.push('--env', environment); + args.push('--repo', manifest.github.repository, '--json', 'name,value'); + const listed = githubRead(run, cwd, args); + if (!listed.ok) throw new Error(`gh: ${listed.reason}`); + const entries = JSON.parse(listed.stdout); + if ( + !Array.isArray(entries) || + entries.some((entry) => typeof entry.name !== 'string' || typeof entry.value !== 'string') + ) + throw new Error('GitHub returned an invalid public variable inventory.'); + for (const entry of entries) + values[environment ? `${environment}:${entry.name}` : entry.name] = entry.value; + } + return values; + }); +} + async function observeDns(resolve, manifest) { const answers = {}; for (const email of manifest.email ?? []) { @@ -182,6 +231,35 @@ async function observeDns(resolve, manifest) { return answers; } +async function observeConfig(manifest, directory) { + const cfConfig = manifest.cloudflare.cloudflareConfig; + const configFile = path.join( + directory, + cfConfig ?? manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', + ); + const production = await attempt(() => + cfConfig + ? readCloudflareConfig(configFile, { mode: manifest.cloudflare.environment }) + : readWranglerConfig(configFile, { environment: manifest.cloudflare.environment }), + ); + if (!production.ok) return production; + const modes = new Set( + [...(manifest.d1 ?? []), ...(manifest.r2 ?? [])].flatMap( + (resource) => resource.environment ?? [], + ), + ); + if (modes.size) { + production.value.environments = {}; + for (const mode of modes) + production.value.environments[mode] = await attempt(() => + cfConfig + ? readCloudflareConfig(configFile, { mode }) + : readWranglerConfig(configFile, { environment: mode }), + ); + } + return production; +} + /** * @param {object} input * @param {object} input.manifest a validated manifest @@ -193,8 +271,7 @@ async function observeDns(resolve, manifest) { */ export async function observePlatform({ manifest, directory, apis, run, resolve, confirmed }) { const account = `accounts/${manifest.cloudflare.accountId}`; - const configFile = path.join(directory, manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc'); - const config = await attempt(() => readWranglerConfig(configFile)); + const config = await observeConfig(manifest, directory); const migrations = {}; for (const database of manifest.d1 ?? []) { if (database.migrations) @@ -210,6 +287,8 @@ export async function observePlatform({ manifest, directory, apis, run, resolve, return { config, migrations, + governance: observeGovernance(run, directory, manifest), + domains: await observeDomains(wrangler, account, manifest), worker: await observeWorker(wrangler, account, manifest.cloudflare.worker), d1: await observeD1(wrangler, account, manifest, config.ok ? config.value : undefined), r2: manifest.r2?.length ? await observeR2(wrangler, account, manifest) : known([]), diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs index a3d7ce7..eaf779e 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs @@ -1,3 +1,4 @@ +import { configForEnvironment } from './config-scope.mjs'; import { turnstileGuide } from './guides.mjs'; import { item, manualGuide, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; @@ -11,7 +12,7 @@ function configItem({ manifest, state, configPath }) { ...fields, status: 'mismatch', detail: `cannot read it: ${state.config.reason}`, - next: 'point cloudflare.wranglerConfig at the production wrangler config', + next: `point cloudflare.${manifest.cloudflare.cloudflareConfig ? 'cloudflareConfig' : 'wranglerConfig'} at the production config`, }); if (state.config.value.name !== name) return item({ @@ -49,8 +50,13 @@ function databaseItem({ state, configPath }, database) { status: 'missing', detail: 'does not exist', next: `${SETUP} creates it`, - action: { type: 'd1.create', name: database.name }, + action: { + type: 'd1.create', + name: database.name, + ...(database.environment ? { environment: database.environment } : {}), + }, }); + if (!state.config.ok) return unknownItem(fields, state.config); const bound = state.config.ok ? state.config.value.d1.find((entry) => entry.binding === database.binding) : undefined; @@ -59,14 +65,16 @@ function databaseItem({ state, configPath }, database) { ...fields, status: 'mismatch', detail: `${configPath} does not bind ${database.binding} to ${database.name}`, - next: `add it to d1_databases in ${configPath} with database_id ${real.id}`, + next: configPath.endsWith('.ts') + ? `set worker.env.${database.binding} to bindings.d1({ name: "${database.name}" }) in ${configPath}` + : `bind ${database.binding} to database_name ${database.name} in d1_databases in ${configPath}`, }); - if (bound && bound.id !== real.id) + if (bound?.id !== undefined && bound.id !== real.id) return item({ ...fields, status: 'mismatch', - detail: `${configPath} has database_id ${bound.id}, but the database is ${real.id}`, - next: `set database_id to ${real.id} in ${configPath}`, + detail: `${configPath} has D1 id ${bound.id}, but the database is ${real.id}`, + next: `set ${configPath.endsWith('.ts') ? `worker.env.${database.binding} bindings.d1 id` : 'database_id'} to ${real.id} in ${configPath}`, }); return item({ ...fields, status: 'ok', detail: `exists and is bound as ${database.binding}` }); } @@ -85,16 +93,20 @@ function migrationsItem({ state, configPath }, database) { detail: `cannot read ${database.migrations}: ${files?.reason ?? 'missing'}`, next: 'fix the migrations path in platform.json', }); - const action = { type: 'd1.migrate', name: database.name }; + const action = { + type: 'd1.migrate', + name: database.name, + ...(database.environment ? { environment: database.environment } : {}), + }; const real = state.d1.ok ? state.d1.value[database.name] : undefined; if (state.d1.ok && !real) - // Wrangler applies migrations to the database_id in the config, so setup - // applies them after creating the database only if the config names it. + // Setup applies migrations after creating the database only if the config + // names it and any explicit ID agrees with the account inventory. return item({ ...fields, status: 'missing', detail: `${files.value.length} to apply once the database exists and ${configPath} names it`, - next: `${SETUP} applies them once ${configPath} has the new database's database_id`, + next: `${SETUP} applies them once ${configPath} binds ${database.binding} to ${database.name}`, action: { ...action, binding: database.binding, afterCreate: true }, }); if (!real?.applied?.ok) return unknownItem(fields, real?.applied ?? state.d1); @@ -106,15 +118,23 @@ function pendingItem({ state, configPath }, database, { fields, files, real, act const pending = files.filter((file) => !real.applied.value.includes(file)); if (pending.length === 0) return item({ ...fields, status: 'ok', detail: `all ${files.length} applied` }); + if (!state.config.ok) return unknownItem(fields, state.config); const bound = state.config.ok ? state.config.value.d1.find((entry) => entry.binding === database.binding) : undefined; - if (state.config.ok && bound?.id !== real.id) + if (state.config.ok && bound?.name !== database.name) return item({ ...fields, status: 'missing', - detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} has database_id ${real.id}`, - next: `set database_id to ${real.id} in ${configPath}, then run ${SETUP} again`, + detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} binds ${database.binding} to ${database.name}`, + next: `bind ${database.binding} to ${database.name} in ${configPath}, then run ${SETUP} again`, + }); + if (bound?.id !== undefined && bound.id !== real.id) + return item({ + ...fields, + status: 'missing', + detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} has ${configPath.endsWith('.ts') ? 'D1 id' : 'database_id'} ${real.id}`, + next: `set ${configPath.endsWith('.ts') ? `worker.env.${database.binding} bindings.d1 id` : 'database_id'} to ${real.id} in ${configPath}, then run ${SETUP} again`, }); return item({ ...fields, @@ -126,10 +146,19 @@ function pendingItem({ state, configPath }, database, { fields, files, real, act } export function planD1(context) { - return (context.manifest.d1 ?? []).flatMap((database) => [ - databaseItem(context, database), - ...(database.migrations ? [migrationsItem(context, database)] : []), - ]); + return (context.manifest.d1 ?? []).flatMap((database) => { + const scoped = { + ...context, + state: { + ...context.state, + config: configForEnvironment(context.state.config, database.environment), + }, + }; + return [ + databaseItem(scoped, database), + ...(database.migrations ? [migrationsItem(scoped, database)] : []), + ]; + }); } export function planR2({ manifest, state, configPath }) { @@ -144,15 +173,20 @@ export function planR2({ manifest, state, configPath }) { next: `${SETUP} creates it`, action: { type: 'r2.create', name: bucket.name }, }); - const bound = state.config.ok - ? state.config.value.r2.find((entry) => entry.binding === bucket.binding) - : undefined; - if (state.config.ok && bound?.name !== bucket.name) + const config = configForEnvironment(state.config, bucket.environment); + if (!config.ok) return unknownItem(fields, config); + const bound = (config.value.r2 ?? []).find((entry) => entry.binding === bucket.binding); + const location = bucket.environment + ? `${bucket.environment} environment of ${configPath}` + : configPath; + if (bound?.name !== bucket.name) return item({ ...fields, status: 'mismatch', - detail: `${configPath} does not bind ${bucket.binding} to ${bucket.name}`, - next: `add it to r2_buckets in ${configPath}`, + detail: `${location} does not bind ${bucket.binding} to ${bucket.name}`, + next: configPath.endsWith('.ts') + ? `set worker.env.${bucket.binding} to bindings.r2({ name: "${bucket.name}" }) in ${location}` + : `add it to r2_buckets in ${location}`, }); return item({ ...fields, status: 'ok', detail: `exists and is bound as ${bucket.binding}` }); }); diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-governance.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-governance.mjs new file mode 100644 index 0000000..334bc3f --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-governance.mjs @@ -0,0 +1,30 @@ +import { item } from './plan-items.mjs'; +export function planGovernance({ manifest, state }) { + if (!manifest.github?.governance) return []; + const observation = state.governance; + const fields = { section: 'GitHub governance', level: 'recommended' }; + if (!observation?.ok) + return [ + item({ + ...fields, + id: 'github:governance', + label: 'Organization governance', + status: 'unknown', + detail: observation?.reason ?? 'No governance inventory was produced.', + next: 'Install the shared release and run pnpm preflight --production with GitHub read permissions.', + }), + ]; + return observation.value.map((check) => + item({ + ...fields, + id: `github:${check.id}`, + label: check.id, + status: check.status === 'pass' ? 'ok' : check.status === 'fail' ? 'mismatch' : 'unknown', + detail: check.requirement, + next: + check.status === 'unknown' + ? 'Check GitHub read permissions and rerun preflight.' + : check.requirement, + }), + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-infrastructure.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-infrastructure.mjs new file mode 100644 index 0000000..8b4fece --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-infrastructure.mjs @@ -0,0 +1,121 @@ +import { item, SETUP, TOKEN_HINT, unknownItem, GH_HINT } from './plan-items.mjs'; + +/** Declared public domains: check ownership before suggesting any attachment. */ +export function planDomains({ manifest, state }) { + return (manifest.cloudflare.domains ?? []).map((hostname) => { + const fields = { id: `domain:${hostname}`, section: 'Public domains', label: hostname }; + const domains = state.domains; + if (!domains?.ok) + return unknownItem( + { ...fields, credentialHint: TOKEN_HINT }, + domains ?? { reason: 'not read' }, + ); + const matches = domains.value.filter((domain) => domain.hostname === hostname); + if (matches.length > 1) + return item({ + ...fields, + status: 'mismatch', + detail: 'has duplicate Worker custom-domain records', + next: 'review the provider records before changing any domain', + }); + const existing = matches[0]; + if (existing) { + if ( + existing.service !== manifest.cloudflare.worker || + existing.zone_id !== manifest.cloudflare.zone.id + ) + return item({ + ...fields, + status: 'mismatch', + detail: `belongs to another Worker or zone (${existing.service})`, + next: 'review the existing owner; do not take over a public hostname during setup', + }); + return item({ ...fields, status: 'ok', detail: `routes to ${manifest.cloudflare.worker}` }); + } + return item({ + ...fields, + status: 'missing', + detail: 'is not attached as a Worker custom domain', + next: `${SETUP} shows the attachment steps`, + action: { + type: 'manual', + key: fields.id, + guide: { + url: 'https://dash.cloudflare.com/', + steps: [ + `Choose the declared Cloudflare account and open the ${manifest.cloudflare.worker} Worker, then Settings → Domains & Routes.`, + `Check ${hostname} is not in use by another service. If it is, stop and review that owner before changing routing. Otherwise add ${hostname} as a Custom Domain in the ${manifest.cloudflare.zone.name} zone.`, + 'Run pnpm preflight --production again to confirm the provider now reports the intended Worker and zone.', + ], + }, + }, + }); + }); +} + +function variableKey(variable) { + return variable.environment ? `${variable.environment}:${variable.name}` : variable.name; +} + +function variableGuide(manifest, variable, desired) { + return { + url: variable.environment + ? `https://github.com/${manifest.github.repository}/settings/environments` + : `https://github.com/${manifest.github.repository}/settings/variables/actions`, + steps: [ + variable.environment + ? `Open repository Settings → Environments → ${variable.environment} → Environment variables and inspect ${variable.name}.` + : `Open repository Settings → Secrets and variables → Actions → Variables and inspect ${variable.name}.`, + desired === undefined + ? `Set ${variable.name} to the public value appropriate for ${variable.purpose}.` + : `Set ${variable.name} to ${desired} only after reviewing any existing value.`, + 'Run pnpm preflight --production again to confirm the declared build setting.', + ], + }; +} + +/** Public build settings live in GitHub Actions variables, separate from Worker vars. */ +export function planGithubVariables({ manifest, state }) { + return (manifest.github?.variables ?? []).map((variable) => { + const fields = { + id: `github:variable:${variableKey(variable)}`, + section: 'GitHub variables', + label: variable.name, + level: variable.use === 'future' ? 'later' : 'required', + }; + if (!state.github.ok) return unknownItem({ ...fields, credentialHint: GH_HINT }, state.github); + const variables = state.github.value.variables; + if (!variables?.ok) + return unknownItem( + { ...fields, credentialHint: GH_HINT }, + variables ?? { reason: 'not read' }, + ); + const desired = + variable.from === 'cloudflare.accountId' ? manifest.cloudflare.accountId : variable.value; + const value = variables.value[variableKey(variable)]; + if ( + typeof value === 'string' && + value.length > 0 && + (desired === undefined || desired === value) + ) + return item({ + ...fields, + status: 'ok', + detail: 'is configured with the declared public setting', + }); + return item({ + ...fields, + status: value === undefined || value === '' ? 'missing' : 'mismatch', + detail: + value === undefined || value === '' + ? `is not set; ${variable.purpose}` + : 'differs from the declared public value', + next: `${SETUP} shows the reviewed setting; existing values are never overwritten automatically`, + action: { + type: 'manual', + key: fields.id, + guide: variableGuide(manifest, variable, desired), + }, + }); + }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs index 420dee6..82141bd 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs @@ -1,4 +1,11 @@ -import { emailRecords, resendDomainGuide, varGuide } from './guides.mjs'; +import { secretTargets } from './secret-scope.mjs'; +import { + configVarEntry, + configVarLocation, + emailRecords, + resendDomainGuide, + varGuide, +} from './guides.mjs'; import { findApp } from './plan-access.mjs'; import { findWidget } from './plan-cloudflare.mjs'; import { GH_HINT, item, SETUP, targetLabel, unknownItem } from './plan-items.mjs'; @@ -35,6 +42,7 @@ export function secretSource(secret, manifest) { value: manifest.cloudflare.accountId, from: 'the Cloudflare account ID', }; + if (!secretTargets(secret).includes('worker')) return { type: 'prompt' }; const widget = (manifest.turnstile ?? []).find((candidate) => candidate.secret === secret.name); if (widget) return { type: 'turnstile', widget, from: `the ${widget.name} Turnstile widget` }; for (const app of manifest.access ?? []) { @@ -86,6 +94,14 @@ function secretItem({ manifest, state }, secret, target) { label: `${secret.name} → ${targetLabel(manifest, target)}`, level: secret.use === 'future' ? 'later' : 'required', }; + if (secret.listOnly) + return item({ + ...fields, + status: 'missing', + level: 'later', + detail: 'Documented future requirement; no credential should be created.', + next: secret.steps?.join(' ') ?? 'Enable only when the feature is implemented.', + }); const present = stored(state, target, secret.name); if (present.blocked) return item({ @@ -106,18 +122,11 @@ function secretItem({ manifest, state }, secret, target) { status: 'ok', detail: plain === undefined ? 'is set' : `is set; it should be ${plain}`, }); - const elsewhere = (secret.targets ?? ['worker']).filter( - (other) => other !== target && stored(state, other, secret.name).value === true, - ); - if (source.type === 'generate' && elsewhere.length > 0) - // A generated value cannot be read back, so minting another here would - // leave the targets holding different values. - return item({ - ...fields, - status: 'mismatch', - detail: `is not set here but is set on ${elsewhere.map((other) => targetLabel(manifest, other)).join(', ')}, and setup cannot read that value to copy it`, - next: `${SETUP} --rotate ${secret.name} stores one new value everywhere`, - }); + const generationProblem = + source.type === 'generate' + ? generatedSecretProblem({ manifest, state, secret, target, fields }) + : undefined; + if (generationProblem) return generationProblem; return item({ ...fields, status: 'missing', @@ -148,7 +157,7 @@ export function planVars({ manifest, state, configPath }) { ...fields, status: 'unknown', detail: `cannot read ${configPath}`, - next: 'fix the wrangler config path', + next: 'fix the production config path', }); const widget = (manifest.turnstile ?? []).find( (candidate) => candidate.siteKeyVar === variable.name, @@ -167,8 +176,8 @@ export function planVars({ manifest, state, configPath }) { return item({ ...fields, status: 'missing', - detail: `is not in vars in ${configPath}`, - next: `add it to vars in ${configPath}${live ? ` as "${live.sitekey}"` : ''}`, + detail: `is not in ${configVarLocation(configPath)} in ${configPath}`, + next: `add ${configVarEntry(variable.name, live?.sitekey ?? '', configPath)} to ${configVarLocation(configPath)} in ${configPath}`, action, }); if (live && value !== live.sitekey) @@ -223,8 +232,8 @@ function workerForbidden({ state, configPath }, entry, fields) { }); if (state.config.ok && entry.name in state.config.value.vars) return found( - `is in vars in ${configPath}`, - `remove it from vars in ${configPath}, then deploy`, + `is in ${configVarLocation(configPath)} in ${configPath}`, + `remove it from ${configVarLocation(configPath)} in ${configPath}, then deploy`, ); if (deployed && entry.name in deployed.vars) return found( @@ -264,3 +273,28 @@ export function planForbidden(context) { }), ); } + +function generatedSecretProblem({ manifest, state, secret, target, fields }) { + const unobserved = (secret.targets ?? ['worker']).find( + (other) => !stored(state, other, secret.name).ok, + ); + if (unobserved) + return item({ + ...fields, + status: 'unknown', + detail: 'Cannot generate while another target could not be checked.', + next: 'Restore inventory access before generating a value.', + }); + const elsewhere = (secret.targets ?? ['worker']).filter( + (other) => other !== target && stored(state, other, secret.name).value === true, + ); + if (elsewhere.length > 0) + // A generated value cannot be read back, so minting another here would + // leave the targets holding different values. + return item({ + ...fields, + status: 'mismatch', + detail: `is not set here but is set on ${elsewhere.map((other) => targetLabel(manifest, other)).join(', ')}, and setup cannot read that value to copy it`, + next: `${SETUP} --rotate ${secret.name} stores one new value everywhere`, + }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-worker-bindings.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-worker-bindings.mjs new file mode 100644 index 0000000..659b074 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-worker-bindings.mjs @@ -0,0 +1,73 @@ +import { item, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; + +function agrees(kind, actual, expected) { + if (kind === 'analyticsEngine') return actual.name === expected.name; + return ( + actual.namespace === expected.namespace && + actual.simple?.limit === expected.simple.limit && + actual.simple?.period === expected.simple.period + ); +} +function manualBindingAction({ manifest, configPath }, fields, kind, declaration) { + const requirement = + kind === 'analyticsEngine' + ? `Bind ${declaration.binding} to Analytics Engine dataset ${declaration.name}. The dataset is created automatically on its first authorized write; selecting a binding is not proof of event delivery.` + : `Bind ${declaration.binding} to rate-limit namespace ${declaration.namespace}, with limit ${declaration.simple.limit} and period ${declaration.simple.period}. Namespace IDs share counters across Workers in the account; review existing usage before choosing or changing one.`; + return { + type: 'manual', + key: fields.id, + guide: { + url: 'https://dash.cloudflare.com/', + steps: [ + `Inspect ${manifest.cloudflare.worker} in the declared Cloudflare account and compare its deployed bindings with ${configPath}.`, + requirement, + 'Review the repository configuration change and publish it through the saved staging and promotion workflow; setup does not rewrite deployed resource bindings.', + 'Run pnpm preflight --production again, then perform the documented runtime acceptance separately.', + ], + }, + }; +} +function bindingItem(context, kind, declaration) { + const { state, configPath } = context; + const fields = { + id: `${kind}:${declaration.binding}`, + section: kind === 'analyticsEngine' ? 'Analytics Engine' : 'Rate limiting', + label: declaration.binding, + }; + if (!state.config.ok) return unknownItem(fields, state.config); + const local = (state.config.value[kind] ?? []).filter( + (entry) => entry.binding === declaration.binding, + ); + const action = manualBindingAction(context, fields, kind, declaration); + if (local.length !== 1 || !agrees(kind, local[0], declaration)) + return item({ + ...fields, + status: 'mismatch', + detail: `${configPath} does not uniquely declare the required binding and settings`, + next: `review ${configPath}`, + action, + }); + if (!state.worker.ok) return unknownItem({ ...fields, credentialHint: TOKEN_HINT }, state.worker); + const deployed = (state.worker.value[kind] ?? []).filter( + (entry) => entry.binding === declaration.binding, + ); + if (deployed.length !== 1 || !agrees(kind, deployed[0], declaration)) + return item({ + ...fields, + status: deployed.length ? 'mismatch' : 'missing', + detail: 'deployed Worker does not uniquely carry the declared resource binding and settings', + next: `${SETUP} shows maintainer deployment steps`, + action, + }); + return item({ + ...fields, + status: 'ok', + detail: + 'repository and deployed Worker binding settings agree; event delivery is a separate runtime check', + }); +} +export function planWorkerBindings(context) { + return ['analyticsEngine', 'rateLimits'].flatMap((kind) => + (context.manifest[kind] ?? []).map((declaration) => bindingItem(context, kind, declaration)), + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs index bb05338..9161ace 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs @@ -1,3 +1,6 @@ +import { planWorkerBindings } from './plan-worker-bindings.mjs'; +import { planGovernance } from './plan-governance.mjs'; +import { planDomains, planGithubVariables } from './plan-infrastructure.mjs'; import { planAccess } from './plan-access.mjs'; import { planD1, planR2, planTurnstile, planWorker } from './plan-cloudflare.mjs'; import { @@ -27,11 +30,15 @@ export function planPlatform({ manifest, state, configPath }) { const context = { manifest, state, configPath }; return [ ...planWorker(context), + ...planWorkerBindings(context), + ...planDomains(context), ...planD1(context), ...planR2(context), ...planTurnstile(context), ...planAccess(context), ...planGithubEnvironments(context), + ...planGovernance(context), + ...planGithubVariables(context), ...planSecrets(context), ...planVars(context), ...planEmail(context), diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs index db65382..f15731e 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from 'node:util'; + /** * The subset of JSON Schema that platform.schema.json uses, interpreted * directly so the schema file stays the one definition of a valid manifest. @@ -16,9 +18,14 @@ export const SUPPORTED_KEYWORDS = new Set([ 'enum', 'pattern', 'minLength', + 'minimum', + 'maximum', 'minItems', + 'maxItems', + 'uniqueItems', 'items', 'properties', + 'propertyNames', 'required', 'additionalProperties', ]); @@ -31,8 +38,15 @@ function typeOf(value) { } function resolve(root, reference) { - const match = /^#\/\$defs\/([A-Za-z0-9_-]+)$/.exec(reference); - const target = match ? root.$defs?.[match[1]] : undefined; + const target = reference.startsWith('#/') + ? reference + .slice(2) + .split('/') + .reduce((value, key) => { + const decoded = key.replaceAll('~1', '/').replaceAll('~0', '~'); + return value && Object.hasOwn(value, decoded) ? value[decoded] : undefined; + }, root) + : undefined; if (!target) throw new Error(`Unsupported or unknown schema reference ${reference}.`); return target; } @@ -47,19 +61,31 @@ function checkString(schema, value, at, errors) { function checkArray(walker, schema, value, at) { if (schema.minItems !== undefined && value.length < schema.minItems) walker.errors.push(`${at}: needs at least ${schema.minItems} item(s).`); + if (schema.maxItems !== undefined && value.length > schema.maxItems) + walker.errors.push(`${at}: permits at most ${schema.maxItems} item(s).`); + if ( + schema.uniqueItems && + value.some((item, i) => value.slice(0, i).some((other) => isDeepStrictEqual(item, other))) + ) + walker.errors.push(`${at}: items must be unique.`); if (schema.items) value.forEach((item, index) => visit(walker, schema.items, item, `${at}[${index}]`)); } function checkObject(walker, schema, value, at) { for (const key of schema.required ?? []) { - if (!(key in value)) walker.errors.push(`${at}: "${key}" is required.`); + if (!Object.hasOwn(value, key)) walker.errors.push(`${at}: "${key}" is required.`); } for (const [key, child] of Object.entries(value)) { - const property = schema.properties?.[key]; + if (schema.propertyNames) visit(walker, schema.propertyNames, key, `${at} property ${key}`); + const property = Object.hasOwn(schema.properties ?? {}, key) + ? schema.properties[key] + : undefined; if (property) visit(walker, property, child, `${at}.${key}`); else if (schema.additionalProperties === false) walker.errors.push(`${at}: "${key}" is not a known field.`); + else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') + visit(walker, schema.additionalProperties, child, `${at}.${key}`); } } @@ -68,6 +94,12 @@ function mismatch(schema, value) { if ('const' in schema && value !== schema.const) return `must be ${JSON.stringify(schema.const)}`; if (schema.enum && !schema.enum.includes(value)) return `must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(', ')}`; + if (typeof value === 'number') { + if (schema.minimum !== undefined && value < schema.minimum) + return `must be at least ${schema.minimum}`; + if (schema.maximum !== undefined && value > schema.maximum) + return `must be at most ${schema.maximum}`; + } const actual = typeOf(value); if (schema.type && actual !== schema.type) return `must be ${schema.type === 'array' ? 'an array' : `a ${schema.type}`}`; @@ -84,6 +116,12 @@ function visit(walker, schema, value, at) { walker.errors.push(`${at}: ${problem}.`); return; } + if (typeof value === 'number') { + if (schema.minimum !== undefined && value < schema.minimum) + return `must be at least ${schema.minimum}`; + if (schema.maximum !== undefined && value > schema.maximum) + return `must be at most ${schema.maximum}`; + } const actual = typeOf(value); if (actual === 'string') checkString(schema, value, at, walker.errors); else if (actual === 'array') checkArray(walker, schema, value, at); @@ -107,7 +145,8 @@ export function unsupportedKeywords(schema) { else { if (!SUPPORTED_KEYWORDS.has(key)) found.add(key); if (key === 'properties' || key === '$defs') walk(child, true); - else if (key === 'items' || key === 'additionalProperties') walk(child, false); + else if (['items', 'additionalProperties', 'propertyNames'].includes(key)) + walk(child, false); } } }; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/secret-scope.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/secret-scope.mjs new file mode 100644 index 0000000..1a79ffe --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/secret-scope.mjs @@ -0,0 +1,39 @@ +/** One declaration may share a value within its targets; other declarations stay independent. */ +export function secretTargets(secret) { + return secret.targets ?? ['worker']; +} +export function secretForTarget(manifest, name, target) { + return manifest.secrets?.find( + (secret) => secret.name === name && secretTargets(secret).includes(target), + ); +} +export function secretValueKey(manifest, secret) { + const repeated = + (manifest.secrets ?? []).filter((entry) => entry.name === secret.name).length > 1; + return repeated + ? `${secret.name}:${JSON.stringify([...secretTargets(secret)].sort())}` + : secret.name; +} +export function scopedSecretErrors(secrets = []) { + const seen = new Map(); + const errors = []; + for (const secret of secrets) { + const targets = secretTargets(secret); + if (new Set(targets).size !== targets.length) + errors.push(`${secret.name} repeats a target in its declaration.`); + const prior = seen.get(secret.name) ?? []; + if ( + prior.some( + (other) => + !secret.targets || + !other.targets || + targets.some((target) => other.targets.includes(target)), + ) + ) + errors.push( + `${secret.name} is declared more than once with overlapping or implicit target scopes.`, + ); + seen.set(secret.name, [...prior, secret]); + } + return errors; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs index deb4df5..d91d01f 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs @@ -13,7 +13,7 @@ import path from 'node:path'; export function runCommand(command, args, { cwd, env, input, inherit = false } = {}) { const result = spawnSync(command, args, { cwd, - env: env ? { ...process.env, ...env } : process.env, + env: { ...process.env, ...env, pnpm_config_verify_deps_before_run: 'error' }, input, encoding: 'utf8', stdio: inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'], @@ -166,7 +166,7 @@ export function dnsResolver(request = fetch) { ); if (!response.ok) throw new Error(`DNS lookup answered ${response.status}`); const payload = await response.json(); - const code = { MX: 15, TXT: 16 }[type]; + const code = { CNAME: 5, MX: 15, TXT: 16 }[type]; return (payload.Answer ?? []) .filter((answer) => code === undefined || answer.type === code) .map((answer) => answer.data); diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/worker-bindings.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/worker-bindings.mjs new file mode 100644 index 0000000..a306002 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/worker-bindings.mjs @@ -0,0 +1,42 @@ +/** Public resource declarations only; secret values never enter the binding inventory. */ +function inventory(analyticsEngine, rateLimits) { + return { + ...(analyticsEngine.length ? { analyticsEngine } : {}), + ...(rateLimits.length ? { rateLimits } : {}), + }; +} +const dataset = (binding, name) => ({ binding, name }); +const limiter = (binding, namespace, simple) => ({ binding, namespace: String(namespace), simple }); + +export function wranglerWorkerBindings(config) { + const analyticsEngine = (config.analytics_engine_datasets ?? []).map((entry) => + dataset(entry.binding, entry.dataset), + ); + const rateLimits = [ + ...(config.ratelimits ?? []), + ...(config.unsafe?.bindings ?? []).filter((entry) => entry.type === 'ratelimit'), + ].map((entry) => limiter(entry.name, entry.namespace_id, entry.simple)); + return inventory(analyticsEngine, rateLimits); +} + +export function cloudflareWorkerBindings(env) { + const analyticsEngine = []; + const rateLimits = []; + for (const [binding, entry] of Object.entries(env)) { + if (entry.type === 'analytics-engine-dataset') + analyticsEngine.push(dataset(binding, entry.name)); + if (entry.type === 'rate-limit') + rateLimits.push(limiter(binding, entry.namespace, entry.simple)); + } + return inventory(analyticsEngine, rateLimits); +} + +export function deployedWorkerBindings(bindings) { + const analyticsEngine = bindings + .filter((entry) => entry.type === 'analytics_engine') + .map((entry) => dataset(entry.name, entry.dataset)); + const rateLimits = bindings + .filter((entry) => entry.type === 'ratelimit') + .map((entry) => limiter(entry.name, entry.namespace_id, entry.simple)); + return inventory(analyticsEngine, rateLimits); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/release/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/release/index.mjs new file mode 100644 index 0000000..7ca9edd --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/release/index.mjs @@ -0,0 +1,41 @@ +import { CliError } from '../arguments.mjs'; +import { runRelease } from './runner.mjs'; +export { promote } from './promote.mjs'; +const flags = [ + 'action', + 'pr', + 'publication-mode', + 'protection', + 'app', + 'directory', + 'commit', + 'release-id', + 'target', + 'version', + 'repository', + 'run-id', + 'expected-version', + 'run-file', + 'artifact-hash', + 'attestation-directory', + 'candidate-directory', + 'activation', + 'verification', + 'url', +]; + +export async function release({ cwd, options = {} }) { + if (options.dryRun || options.production || options.staged) + throw new CliError('Select an explicit release operation and target.', 2); + const positionals = options.positional ?? []; + const mode = ['publication', 'smoke', 'migrate', 'attestation', 'pr-preview'].includes( + positionals[0], + ) + ? positionals[0] + : 'worker-release'; + const args = mode !== 'worker-release' ? positionals.slice(1) : [...positionals]; + for (const key of flags) if (options[key] !== undefined) args.push(`--${key}`, options[key]); + for (const flag of ['protected', 'public', 'wait-for-propagation']) + if (options[flag]) args.push(`--${flag}`); + return await runRelease({ cwd, mode, args }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/release/promote.mjs b/.lvbt/web-platform/packages/cli/src/lib/release/promote.mjs new file mode 100644 index 0000000..386411a --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/release/promote.mjs @@ -0,0 +1,18 @@ +import { CliError } from '../arguments.mjs'; +import { runRelease } from './runner.mjs'; + +export async function promote({ cwd, options = {} }) { + const runId = options.runId ?? options['run-id']; + if (runId !== undefined && !/^[1-9][0-9]*$/.test(runId)) + throw new CliError('Select an Actions run ID.', 2); + const expectedVersion = options['expected-version']; + if ( + expectedVersion !== undefined && + !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(expectedVersion) + ) + throw new CliError('Select the expected current Worker version UUID.', 2); + const args = runId ? ['--run-id', runId] : []; + if (expectedVersion) args.push('--expected-version', expectedVersion); + if (options.app) args.push('--app', options.app); + return await runRelease({ cwd, mode: 'promote', args }); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/release/runner.mjs b/.lvbt/web-platform/packages/cli/src/lib/release/runner.mjs new file mode 100644 index 0000000..81b0391 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/release/runner.mjs @@ -0,0 +1,57 @@ +import { spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { CliError } from '../arguments.mjs'; +import { readTooling } from '../tooling.mjs'; + +export async function releaseEntry(cwd) { + try { + const entry = createRequire(path.join(cwd, 'package.json')).resolve( + '@lasvegasfortransit/web-platform/release', + ); + return path.join(path.dirname(entry), 'release-command.ts'); + } catch { + const candidates = [ + fileURLToPath(new URL('../../../../web-platform/src/release-command.ts', import.meta.url)), + path.join(cwd, '.lvbt/web-platform/packages/web-platform/src/release-command.ts'), + ]; + for (const file of candidates) + if ( + await access(file).then( + () => true, + () => false, + ) + ) + return file; + throw new CliError( + 'Install the pinned @lasvegasfortransit/web-platform package or apply the vendored repository standard before releasing.', + 2, + ); + } +} + +export async function runRelease({ cwd, mode, args = [] }) { + if (!(mode === 'attestation' && args[0] === 'manifest') && !readTooling(cwd).release) + throw new CliError('Configure release in .lvbt/tooling.json before releasing.', 2); + const entry = await releaseEntry(cwd); + await new Promise((resolve, reject) => { + const child = spawn('pnpm', ['exec', 'tsx', entry, mode, cwd, ...args], { + cwd, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => + code === 0 + ? resolve() + : reject( + new CliError( + `Release stopped (${signal ?? code}). Reconcile any reported Actions request before dispatching again.`, + code ?? 1, + ), + ), + ); + }); + return 0; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/tooling.mjs b/.lvbt/web-platform/packages/cli/src/lib/tooling.mjs new file mode 100644 index 0000000..0468a3b --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/tooling.mjs @@ -0,0 +1,24 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { CliError } from './arguments.mjs'; +import { validateAgainstSchema } from './platform/schema.mjs'; + +const schema = JSON.parse( + readFileSync(new URL('../../tooling.schema.json', import.meta.url), 'utf8'), +); +export function validateTooling(value) { + return validateAgainstSchema(schema, value); +} +export function readTooling(cwd) { + const file = path.join(cwd, '.lvbt/tooling.json'); + if (!existsSync(file)) return { version: 1 }; + let config; + try { + config = JSON.parse(readFileSync(file, 'utf8')); + } catch { + throw new CliError('Invalid .lvbt/tooling.json; restore valid JSON.', 2); + } + const errors = validateTooling(config); + if (errors.length) throw new CliError(`Invalid .lvbt/tooling.json:\n${errors.join('\n')}`, 2); + return config; +} diff --git a/.lvbt/web-platform/packages/cli/tooling.schema.json b/.lvbt/web-platform/packages/cli/tooling.schema.json new file mode 100644 index 0000000..91eda99 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/tooling.schema.json @@ -0,0 +1,637 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "minLength": 1 + }, + "version": { + "const": 1 + }, + "local": { + "type": "object", + "additionalProperties": false, + "properties": { + "env": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "example": { + "$ref": "#/$defs/path" + }, + "file": { + "$ref": "#/$defs/path" + } + }, + "required": ["example", "file"] + } + }, + "optional": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "purpose": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "purpose"] + } + } + }, + "required": [] + }, + "audits": { + "type": "object", + "additionalProperties": false, + "properties": { + "workflow": { + "type": "string", + "minLength": 1 + }, + "artifactName": { + "type": "string", + "minLength": 1 + }, + "links": { + "type": "object", + "additionalProperties": false, + "properties": { + "local": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + }, + "production": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + } + }, + "required": [] + }, + "lighthouse": { + "type": "object", + "additionalProperties": false, + "properties": { + "local": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + }, + "production": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + } + }, + "required": [] + }, + "dependencies": { + "type": "object", + "additionalProperties": false, + "properties": { + "local": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + }, + "production": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "$ref": "#/$defs/path" + }, + "command": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output": { + "$ref": "#/$defs/path" + }, + "format": { + "enum": ["lychee", "links", "lhci", "lighthouse", "pnpm"] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["command"] + } + }, + "required": [] + } + }, + "required": [] + }, + "release": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "appDirectory": { + "type": "string", + "minLength": 1 + }, + "productionUrl": { + "type": "string", + "minLength": 1 + }, + "previewUrl": { + "type": "string", + "minLength": 1 + }, + "productionWorker": { + "type": "string", + "minLength": 1 + }, + "previewWorker": { + "type": "string", + "minLength": 1 + }, + "artifactPrefix": { + "type": "string", + "minLength": 1 + }, + "stagingWorkflow": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "branch": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "path"] + }, + "promotionWorkflow": { + "type": "object", + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "titlePrefix": { + "type": "string", + "minLength": 1 + }, + "branch": { + "type": "string", + "minLength": 1 + } + }, + "required": ["file", "titlePrefix", "branch"] + }, + "artifactSource": { + "enum": ["cf-output", "legacy-worker", "typed-worker"] + }, + "productionUrlEnv": { + "type": "string", + "minLength": 1 + }, + "previewUrlEnv": { + "type": "string", + "minLength": 1 + }, + "artifactAcceptance": { + "type": "object", + "additionalProperties": false, + "properties": { + "forbiddenPaths": { + "type": "array", + "items": { + "$ref": "#/$defs/path" + } + }, + "forbiddenLanguages": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "previewBindings": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "previewBindingsEnv": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "smoke": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "pattern": "^/(?!/).*$" + }, + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "body": { + "type": "string" + } + }, + "required": ["path", "status"] + }, + "typedConfig": { + "$ref": "#/$defs/path" + }, + "assetsDirectory": { + "type": "string", + "minLength": 1 + }, + "apps": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/releaseProfile" + } + }, + "migrations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "binding": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "directory": { + "type": "string", + "minLength": 1 + } + }, + "required": ["binding", "directory"] + } + }, + "workersDevSubdomain": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "publicationMode": { + "enum": ["version", "named-staging"] + }, + "publicPath": { + "type": "string", + "pattern": "^/(?:[a-z0-9-]+/)*$" + }, + "previewReadOnlyBindings": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "attestation": { + "type": "object", + "additionalProperties": false, + "properties": { + "signerWorkflow": { + "const": "LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml" + }, + "signerCommit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "legacyArtifacts": { + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "runId": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "sourceCommit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "artifactId": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expiresAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$" + } + }, + "required": ["runId", "sourceCommit", "artifactId", "expiresAt"] + } + } + }, + "required": ["signerWorkflow", "signerCommit"] + }, + "previewOnly": { + "type": "boolean" + }, + "workersDevSubdomainEnv": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + } + }, + "required": [] + }, + "contributions": { + "type": "object", + "additionalProperties": false, + "properties": { + "recurring": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^(?!constructor$|prototype$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "workflow": { + "type": "string", + "pattern": "^\\.github/workflows/[a-z0-9-]+\\.ya?ml$" + }, + "artifactName": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "type": { + "enum": ["bug", "feature"] + }, + "labels": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "pin": { + "type": "boolean" + }, + "adoptExisting": { + "type": "boolean" + } + }, + "required": ["workflow", "artifactName", "title", "type", "labels"] + } + } + } + } + }, + "required": ["version"], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LVBT repository tooling declaration", + "$defs": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" + }, + "releaseProfile": { + "type": "object", + "additionalProperties": false, + "properties": { + "appDirectory": { + "$ref": "#/properties/release/properties/appDirectory" + }, + "productionUrl": { + "$ref": "#/properties/release/properties/productionUrl" + }, + "previewUrl": { + "$ref": "#/properties/release/properties/previewUrl" + }, + "productionWorker": { + "$ref": "#/properties/release/properties/productionWorker" + }, + "previewWorker": { + "$ref": "#/properties/release/properties/previewWorker" + }, + "artifactPrefix": { + "$ref": "#/properties/release/properties/artifactPrefix" + }, + "artifactSource": { + "$ref": "#/properties/release/properties/artifactSource" + }, + "productionUrlEnv": { + "$ref": "#/properties/release/properties/productionUrlEnv" + }, + "previewUrlEnv": { + "$ref": "#/properties/release/properties/previewUrlEnv" + }, + "artifactAcceptance": { + "$ref": "#/properties/release/properties/artifactAcceptance" + }, + "previewBindings": { + "$ref": "#/properties/release/properties/previewBindings" + }, + "previewBindingsEnv": { + "$ref": "#/properties/release/properties/previewBindingsEnv" + }, + "smoke": { + "$ref": "#/properties/release/properties/smoke" + }, + "typedConfig": { + "$ref": "#/properties/release/properties/typedConfig" + }, + "assetsDirectory": { + "$ref": "#/properties/release/properties/assetsDirectory" + }, + "migrations": { + "$ref": "#/properties/release/properties/migrations" + }, + "workersDevSubdomain": { + "$ref": "#/properties/release/properties/workersDevSubdomain" + }, + "publicationMode": { + "$ref": "#/properties/release/properties/publicationMode" + }, + "publicPath": { + "$ref": "#/properties/release/properties/publicPath" + }, + "previewReadOnlyBindings": { + "$ref": "#/properties/release/properties/previewReadOnlyBindings" + }, + "attestation": { + "$ref": "#/properties/release/properties/attestation" + }, + "previewOnly": { + "$ref": "#/properties/release/properties/previewOnly" + }, + "workersDevSubdomainEnv": { + "$ref": "#/properties/release/properties/workersDevSubdomainEnv" + } + } + } + } +} diff --git a/.lvbt/web-platform/packages/eslint-config/base.d.ts b/.lvbt/web-platform/packages/eslint-config/base.d.ts new file mode 100644 index 0000000..313fec4 --- /dev/null +++ b/.lvbt/web-platform/packages/eslint-config/base.d.ts @@ -0,0 +1,3 @@ +import type { Linter } from 'eslint'; + +export declare const config: Linter.Config[]; diff --git a/.lvbt/web-platform/packages/eslint-config/base.js b/.lvbt/web-platform/packages/eslint-config/base.js index e9be2c8..f36fd5a 100644 --- a/.lvbt/web-platform/packages/eslint-config/base.js +++ b/.lvbt/web-platform/packages/eslint-config/base.js @@ -14,6 +14,7 @@ import tseslint from 'typescript-eslint'; const ignores = { ignores: [ '**/.astro/**', + '**/.cloudflare/**', '**/.turbo/**', '**/.wrangler/**', '**/coverage/**', diff --git a/.lvbt/web-platform/packages/eslint-config/browser.d.ts b/.lvbt/web-platform/packages/eslint-config/browser.d.ts new file mode 100644 index 0000000..313fec4 --- /dev/null +++ b/.lvbt/web-platform/packages/eslint-config/browser.d.ts @@ -0,0 +1,3 @@ +import type { Linter } from 'eslint'; + +export declare const config: Linter.Config[]; diff --git a/.lvbt/web-platform/packages/eslint-config/package.json b/.lvbt/web-platform/packages/eslint-config/package.json index 74559be..96a7546 100644 --- a/.lvbt/web-platform/packages/eslint-config/package.json +++ b/.lvbt/web-platform/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/eslint-config", - "version": "0.5.4", + "version": "0.7.1", "description": "The ESLint configurations every LVBT repository uses.", "license": "MIT", "type": "module", @@ -14,14 +14,26 @@ "access": "restricted" }, "exports": { - "./base": "./base.js", - "./react-internal": "./react-internal.js", - "./browser": "./browser.js" + "./base": { + "types": "./base.d.ts", + "default": "./base.js" + }, + "./react-internal": { + "types": "./react-internal.d.ts", + "default": "./react-internal.js" + }, + "./browser": { + "types": "./browser.d.ts", + "default": "./browser.js" + } }, "files": [ "base.js", + "base.d.ts", "react-internal.js", - "browser.js" + "react-internal.d.ts", + "browser.js", + "browser.d.ts" ], "dependencies": { "@eslint-community/eslint-plugin-eslint-comments": "4.7.2", diff --git a/.lvbt/web-platform/packages/eslint-config/react-internal.d.ts b/.lvbt/web-platform/packages/eslint-config/react-internal.d.ts new file mode 100644 index 0000000..313fec4 --- /dev/null +++ b/.lvbt/web-platform/packages/eslint-config/react-internal.d.ts @@ -0,0 +1,3 @@ +import type { Linter } from 'eslint'; + +export declare const config: Linter.Config[]; diff --git a/.lvbt/web-platform/packages/playwright-config/package.json b/.lvbt/web-platform/packages/playwright-config/package.json index 716ab21..7161be1 100644 --- a/.lvbt/web-platform/packages/playwright-config/package.json +++ b/.lvbt/web-platform/packages/playwright-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/playwright-config", - "version": "0.5.4", + "version": "0.7.1", "description": "The Playwright configuration every LVBT repository spreads into its own: end-to-end tests under tests/e2e, desktop and mobile projects, traces on failure.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/prettier-config/package.json b/.lvbt/web-platform/packages/prettier-config/package.json index 7448441..df07b57 100644 --- a/.lvbt/web-platform/packages/prettier-config/package.json +++ b/.lvbt/web-platform/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/prettier-config", - "version": "0.5.4", + "version": "0.7.1", "description": "The Prettier configuration every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/typescript-config/package.json b/.lvbt/web-platform/packages/typescript-config/package.json index 68dccd6..1c88eee 100644 --- a/.lvbt/web-platform/packages/typescript-config/package.json +++ b/.lvbt/web-platform/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/typescript-config", - "version": "0.5.4", + "version": "0.7.1", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/.lvbt/web-platform/packages/vitest-config/package.json b/.lvbt/web-platform/packages/vitest-config/package.json index f5f2e01..3570548 100644 --- a/.lvbt/web-platform/packages/vitest-config/package.json +++ b/.lvbt/web-platform/packages/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/vitest-config", - "version": "0.5.4", + "version": "0.7.1", "description": "The Vitest configuration every LVBT repository spreads into its own.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/web-platform/actions/release-build/action.yml b/.lvbt/web-platform/packages/web-platform/actions/release-build/action.yml new file mode 100644 index 0000000..16e4b90 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/actions/release-build/action.yml @@ -0,0 +1,39 @@ +name: Build and retain reviewed release +description: One implementation of validation, compilation, and immutable artifact retention. +inputs: + artifact-source: + required: true + description: Reviewed cf-output, typed-worker, or legacy-worker source + artifact-prefix: + required: true + description: Stable application artifact name prefix +runs: + using: composite + steps: + - name: Install validation browser + if: env.VALIDATION_BROWSER_DIRECTORY != '' + shell: bash + run: pnpm --dir "$VALIDATION_BROWSER_DIRECTORY" exec playwright install --with-deps chromium + - name: Build and validate the app + shell: bash + run: pnpm check + - name: Build reviewed assets + shell: bash + run: pnpm build + - name: Prepare static cf output + if: inputs.artifact-source == 'cf-output' + shell: bash + run: pnpm --dir "$APP_DIRECTORY" exec cf deploy --dry-run + - name: Package reviewed release + shell: bash + run: >- + pnpm exec lvbt release package --directory "$RUNNER_TEMP/release" --commit "$GITHUB_SHA" + --release-id "$RELEASE_ID" + - name: Retain immutable release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-prefix }}-${{ github.run_id }} + path: ${{ runner.temp }}/release/ + include-hidden-files: true + if-no-files-found: error + retention-days: 90 diff --git a/.lvbt/web-platform/packages/web-platform/package.json b/.lvbt/web-platform/packages/web-platform/package.json index d08cd48..2e4906c 100644 --- a/.lvbt/web-platform/packages/web-platform/package.json +++ b/.lvbt/web-platform/packages/web-platform/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/web-platform", - "version": "0.5.4", + "version": "0.7.1", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", @@ -18,10 +18,12 @@ "./cloudflare": "./src/cloudflare.ts", "./github": "./src/github.ts", "./provision": "./src/provision.ts", - "./release": "./src/release.ts" + "./release": "./src/release.ts", + "./links": "./src/links.ts" }, "files": [ - "src" + "src", + "actions" ], "scripts": { "test": "vitest run" @@ -30,7 +32,10 @@ "node": ">=24" }, "dependencies": { - "zod": "4.5.4" + "zod": "4.5.4", + "playwright-core": "1.62.1", + "jsonc-parser": "3.3.1", + "acorn": "8.18.0" }, "devDependencies": { "vitest": "4.1.11" diff --git a/.lvbt/web-platform/packages/web-platform/src/access-auth.ts b/.lvbt/web-platform/packages/web-platform/src/access-auth.ts new file mode 100644 index 0000000..f3dbc2b --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/access-auth.ts @@ -0,0 +1,47 @@ +export interface AccessCredentials { + clientId: string; + clientSecret: string; +} + +export function accessCredentials( + env: Record, +): AccessCredentials | undefined { + const clientId = env.CF_ACCESS_CLIENT_ID?.trim(); + const clientSecret = env.CF_ACCESS_CLIENT_SECRET?.trim(); + if (Boolean(clientId) !== Boolean(clientSecret)) + throw new Error('Set both Cloudflare Access service credentials.'); + return clientId && clientSecret ? { clientId, clientSecret } : undefined; +} + +export function accessHeaders( + url: string, + origin: string, + credentials?: AccessCredentials, +): Record { + if (!credentials) return {}; + const target = new URL(origin); + if (target.protocol !== 'https:') throw new Error('Access credentials require an HTTPS origin.'); + if (new URL(url).origin !== target.origin) return {}; + return { + 'CF-Access-Client-Id': credentials.clientId, + 'CF-Access-Client-Secret': credentials.clientSecret, + }; +} + +export function accessRequestOptions( + url: string, + origin: string, + credentials?: AccessCredentials, +): { headers: Record; maxRedirects: 0 } { + return { headers: accessHeaders(url, origin, credentials), maxRedirects: 0 }; +} + +export async function accessFetch( + url: string, + origin: string, + credentials?: AccessCredentials, +): Promise { + // Never follow redirects with service credentials. Access redirects are authentication + // failures; application redirects can be tested explicitly at their destination. + return fetch(url, { headers: accessHeaders(url, origin, credentials), redirect: 'manual' }); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/cf-release-artifact.ts b/.lvbt/web-platform/packages/web-platform/src/cf-release-artifact.ts new file mode 100644 index 0000000..8ea1ef1 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/cf-release-artifact.ts @@ -0,0 +1,96 @@ +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { configuredReleaseIdentity, workerReleaseEntry } from './worker-release-entry.js'; +import { packageWorkerCfRelease } from './cf-worker-release-artifact.js'; +import { packageRelease, type WebsiteRelease } from './saved-release-artifact.js'; +import { releaseMarkerAssets } from './release-marker-assets.js'; + +const staticWorker = z.strictObject({ + name: z.string(), + compatibilityDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + compatibilityFlags: z.array(z.string()).optional(), + assets: z.strictObject({ + notFoundHandling: z.enum(['none', '404-page', 'single-page-application']).optional(), + htmlHandling: z + .enum(['auto-trailing-slash', 'force-trailing-slash', 'drop-trailing-slash', 'none']) + .optional(), + }), + observability: z.unknown().optional(), + previewUrls: z.boolean().optional(), + workersDev: z.boolean().optional(), +}); +const assetWorker = 'export default {fetch(request,env){return env.ASSETS.fetch(request)}};\n'; + +export async function packageCfRelease( + source: string, + destination: string, + identity: { commit: string; releaseId: string }, + config: ReleaseConfiguration, +): Promise { + identity = configuredReleaseIdentity(identity, config); + const worker = path.join(source, '.cloudflare/output/v0/workers/default'); + const descriptor = path.join(worker, 'worker.config.json'); + if ((await lstat(descriptor)).isSymbolicLink()) throw new Error('Invalid cf Worker descriptor.'); + const value: unknown = JSON.parse(await readFile(descriptor, 'utf8')); + if (z.object({ manifest: z.unknown() }).safeParse(value).success) + return await packageWorkerCfRelease(source, destination, identity, config); + const built = staticWorker.parse(value); + if (built.name !== config.productionWorker) + throw new Error('The built Worker does not match the configured production Worker.'); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-cf-release-')); + try { + await cp(path.join(worker, 'assets'), path.join(temporary, 'dist'), { recursive: true }); + await mkdir(path.join(temporary, '.wrangler/worker'), { recursive: true }); + await writeFile(path.join(temporary, '.wrangler/worker/assets.js'), assetWorker); + await writeFile( + path.join(temporary, '.wrangler/worker/index.js'), + workerReleaseEntry('./assets.js', identity, config), + ); + const assets = releaseMarkerAssets( + { + directory: './dist', + binding: 'ASSETS', + run_worker_first: true, + not_found_handling: built.assets.notFoundHandling ?? 'none', + ...(built.assets.htmlHandling ? { html_handling: built.assets.htmlHandling } : {}), + }, + config.publicPath, + ); + await writeFile( + path.join(temporary, 'wrangler.jsonc'), + JSON.stringify( + { + name: config.productionWorker, + main: '.wrangler/worker/index.js', + compatibility_date: built.compatibilityDate, + compatibility_flags: built.compatibilityFlags ?? [], + assets, + observability: built.observability ?? { enabled: true }, + workers_dev: false, + preview_urls: true, + routes: [{ pattern: new URL(config.productionUrl).hostname, custom_domain: true }], + vars: { LVBT_DEPLOYMENT_ENV: 'production' }, + env: { + preview: { + name: config.previewWorker, + assets, + routes: [{ pattern: new URL(config.previewUrl).hostname, custom_domain: true }], + vars: { LVBT_DEPLOYMENT_ENV: 'preview' }, + }, + }, + }, + null, + 2, + ), + ); + return await packageRelease(temporary, destination, identity, { + ...config.artifactAcceptance, + formatVersion: 2, + }); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/cf-worker-release-artifact.ts b/.lvbt/web-platform/packages/web-platform/src/cf-worker-release-artifact.ts new file mode 100644 index 0000000..d2af769 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/cf-worker-release-artifact.ts @@ -0,0 +1,194 @@ +import { cp, lstat, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { configuredReleaseIdentity, workerReleaseEntry } from './worker-release-entry.js'; +import { sealSavedRelease, type WebsiteRelease } from './saved-release-artifact.js'; + +const dataset = z.strictObject({ + type: z.literal('analytics-engine-dataset'), + name: z.string().regex(/^[A-Za-z0-9_]+$/), +}); +const rateLimit = z.strictObject({ + type: z.literal('rate-limit'), + namespace: z.string().regex(/^\d+$/), + simple: z.strictObject({ + limit: z.number().int().positive(), + period: z.union([z.literal(10), z.literal(60)]), + }), +}); +const secret = z.strictObject({ type: z.literal('secret') }); +const binding = z.discriminatedUnion('type', [dataset, rateLimit, secret]); +const bindings = z.record(z.string().regex(/^[A-Z][A-Z0-9_]*$/), binding); +type Bindings = z.infer; +const modulePath = z + .string() + .regex(/^[A-Za-z0-9_./-]+\.m?js$/) + .refine( + (value) => !path.isAbsolute(value) && !value.split('/').includes('..'), + 'Compiled modules must stay inside the bundle.', + ); +const descriptor = z.strictObject({ + name: z.string(), + compatibilityDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + compatibilityFlags: z.array(z.string()).optional(), + domains: z.array(z.string()), + workersDev: z.boolean().optional(), + previewUrls: z.boolean().optional(), + observability: z + .strictObject({ enabled: z.boolean(), headSamplingRate: z.number().min(0).max(1).optional() }) + .optional(), + env: bindings, + manifest: z.strictObject({ + type: z.literal('complete'), + mainModule: modulePath, + modules: z.record(modulePath, z.strictObject({ type: z.literal('esm') })), + }), +}); + +export type WorkerReleaseConfiguration = ReleaseConfiguration & { + previewBindings?: Record | undefined; +}; +function assertBindingIsolation( + name: string, + value: Bindings[string], + candidate: Bindings[string] | undefined, +): void { + if (candidate?.type !== value.type) + throw new Error(`Preview binding ${name} must retain its declared type.`); + if ( + value.type === 'analytics-engine-dataset' && + candidate.type === 'analytics-engine-dataset' && + candidate.name === value.name + ) + throw new Error(`Preview binding ${name} must use an isolated Analytics Engine dataset.`); + if (value.type === 'rate-limit' && candidate.type === 'rate-limit') { + if (BigInt(candidate.namespace) === BigInt(value.namespace)) + throw new Error(`Preview binding ${name} must use an isolated rate-limit namespace.`); + if ( + candidate.simple.limit !== value.simple.limit || + candidate.simple.period !== value.simple.period + ) + throw new Error(`Preview binding ${name} must retain production rate-limit behavior.`); + } +} +function previewBindings(production: Bindings, config: WorkerReleaseConfiguration): Bindings { + if (!config.previewBindings) + throw new Error('Declare every isolated preview binding before packaging a Worker release.'); + const preview = bindings.parse(config.previewBindings); + if ( + config.previewWorker === config.productionWorker || + config.previewUrl === config.productionUrl + ) + throw new Error('Preview Worker and origin must be isolated from production.'); + if (Object.keys(preview).sort().join(',') !== Object.keys(production).sort().join(',')) + throw new Error('Preview bindings must explicitly match every production binding.'); + for (const [name, value] of Object.entries(production)) + assertBindingIsolation(name, value, preview[name]); + return preview; +} +function wranglerBindings(values: Bindings): Record { + const analytics_engine_datasets = []; + const ratelimits = []; + for (const [name, value] of Object.entries(values)) { + if (value.type === 'analytics-engine-dataset') + analytics_engine_datasets.push({ binding: name, dataset: value.name }); + if (value.type === 'rate-limit') + ratelimits.push({ name, namespace_id: value.namespace, simple: value.simple }); + // Secrets belong to the target Worker; names declare requirements but never copy values. + } + return { analytics_engine_datasets, ratelimits }; +} +async function copyModules( + source: string, + destination: string, + modules: Record, +): Promise { + const root = await realpath(source); + for (const file of Object.keys(modules)) { + const from = path.join(source, file); + const canonical = await realpath(from); + if (!canonical.startsWith(`${root}${path.sep}`) || !(await lstat(from)).isFile()) + throw new Error('Compiled Worker modules must be regular files inside their bundle.'); + const to = path.join(destination, file); + await mkdir(path.dirname(to), { recursive: true }); + await cp(from, to); + } +} +function workerConfiguration( + built: z.infer, + config: WorkerReleaseConfiguration, + preview: Bindings, +): Record { + return { + name: config.productionWorker, + main: '.wrangler/worker/index.js', + compatibility_date: built.compatibilityDate, + compatibility_flags: built.compatibilityFlags ?? [], + observability: built.observability + ? { + enabled: built.observability.enabled, + head_sampling_rate: built.observability.headSamplingRate, + } + : undefined, + workers_dev: false, + preview_urls: true, + routes: [{ pattern: new URL(config.productionUrl).hostname, custom_domain: true }], + vars: { LVBT_DEPLOYMENT_ENV: 'production' }, + ...wranglerBindings(built.env), + env: { + preview: { + name: config.previewWorker, + routes: [{ pattern: new URL(config.previewUrl).hostname, custom_domain: true }], + vars: { LVBT_DEPLOYMENT_ENV: 'preview' }, + ...wranglerBindings(preview), + }, + }, + }; +} +export async function packageWorkerCfRelease( + source: string, + destination: string, + identity: { commit: string; releaseId: string }, + config: WorkerReleaseConfiguration, +): Promise { + identity = configuredReleaseIdentity(identity, config); + const directory = path.join(source, '.cloudflare/output/v0/workers/default'); + const file = path.join(directory, 'worker.config.json'); + if (!(await lstat(file)).isFile()) throw new Error('Worker descriptor must be a regular file.'); + const built = descriptor.parse(JSON.parse(await readFile(file, 'utf8'))); + if ( + built.name !== config.productionWorker || + !built.domains.includes(new URL(config.productionUrl).hostname) + ) + throw new Error('Built Worker does not match the configured production Worker and origin.'); + if (!built.manifest.modules[built.manifest.mainModule]) + throw new Error('Compiled Worker manifest is missing its main module.'); + const preview = previewBindings(built.env, config); + await mkdir(destination, { recursive: false }); + try { + await copyModules( + path.join(directory, 'bundle'), + path.join(destination, '.wrangler/worker/bundle'), + built.manifest.modules, + ); + await writeFile( + path.join(destination, '.wrangler/worker/index.js'), + workerReleaseEntry(`./bundle/${built.manifest.mainModule}`, identity, config), + ); + await writeFile(path.join(destination, 'lvbt-release.json'), `${JSON.stringify(identity)}\n`); + await writeFile( + path.join(destination, 'wrangler.jsonc'), + `${JSON.stringify(workerConfiguration(built, config, preview), null, 2)}\n`, + ); + // Preserve binding declarations for review without materializing secret values. + await writeFile( + path.join(destination, 'worker-bindings.json'), + `${JSON.stringify({ production: built.env, preview }, null, 2)}\n`, + ); + return await sealSavedRelease(destination, identity, 'worker', 2); + } catch (error) { + await rm(destination, { recursive: true, force: true }); + throw error; + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/cloudflare.ts b/.lvbt/web-platform/packages/web-platform/src/cloudflare.ts index fa2cec5..223412f 100644 --- a/.lvbt/web-platform/packages/web-platform/src/cloudflare.ts +++ b/.lvbt/web-platform/packages/web-platform/src/cloudflare.ts @@ -10,3 +10,5 @@ export { provisionCustomDomain } from './provision-domain.js'; export { provisionAnalytics } from './provision-analytics.js'; export { provisionWorkerPresence, provisionWorkerPreviewUrls } from './provision-worker.js'; export { activeVersion, uploadedVersion, verifyArchiveVersion } from './cloudflare-release.js'; + +export { validAnalytics } from './doctor-cloudflare.js'; diff --git a/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts b/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts index 98d83fb..b1bb0c0 100644 --- a/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts +++ b/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts @@ -91,7 +91,7 @@ function validDns(input: unknown, target: CloudflareTarget) { ); } -function validAnalytics(input: unknown, hostname: string) { +export function validAnalytics(input: unknown, hostname: string) { const sites = z .array( z.object({ diff --git a/.lvbt/web-platform/packages/web-platform/src/expected-production-version.ts b/.lvbt/web-platform/packages/web-platform/src/expected-production-version.ts new file mode 100644 index 0000000..70c80d4 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/expected-production-version.ts @@ -0,0 +1,34 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { z } from 'zod'; +import { activeVersion } from './cloudflare-release.js'; +import type { ReleaseConfiguration } from './release-config.js'; + +type Run = (args: string[]) => Promise; +const run: Run = async (args) => + ( + await promisify(execFile)('pnpm', args, { + env: { ...process.env, WRANGLER_LOG_SANITIZE: 'true' }, + maxBuffer: 16 * 1024 * 1024, + }) + ).stdout; + +/** A read-only optimistic precondition, checked before every selected production write. */ +export async function verifyExpectedProductionVersion( + config: Pick, + expected: string | undefined, + directory?: string, + command: Run = run, +): Promise { + if (expected === undefined || expected === '') return; + const version = z.uuid().parse(expected); + const args = ['exec', 'wrangler', 'deployments', 'list', '--name', config.productionWorker]; + if (directory) args.push('--config', path.join(directory, 'wrangler.jsonc'), '--env', ''); + args.push('--json'); + const current = activeVersion(JSON.parse(await command(args))); + if (current !== version) + throw new Error( + 'The active production version changed; inspect the deployment before requesting promotion again. No production write was attempted.', + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/github-governance-command.ts b/.lvbt/web-platform/packages/web-platform/src/github-governance-command.ts new file mode 100644 index 0000000..8ea2d6a --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/github-governance-command.ts @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { githubGovernanceDoctor } from './github-governance.js'; +import { githubReader } from './github-read.js'; +const [root, repository] = process.argv.slice(2); +if (!root) throw new Error('Pass the checked repository root.'); +const name = z + .string() + .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/) + .parse(repository); +const ruleset: unknown = JSON.parse( + await readFile(path.join(root, '.lvbt/web-platform/standards/ruleset.json'), 'utf8'), +); +const checks = await githubGovernanceDoctor({ repository: name, ruleset }, githubReader(root)); +process.stdout.write(`${JSON.stringify(checks)}\n`); diff --git a/.lvbt/web-platform/packages/web-platform/src/github-governance.ts b/.lvbt/web-platform/packages/web-platform/src/github-governance.ts new file mode 100644 index 0000000..cdf692c --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/github-governance.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import { doctorCheck } from './doctor-check.js'; +import { matchesPinnedRules, matchesPinnedRuleset } from './ruleset.js'; + +const repositorySettings = z.object({ + allow_merge_commit: z.literal(false), + allow_squash_merge: z.literal(false), + allow_rebase_merge: z.literal(true), + security_and_analysis: z.object({ + secret_scanning: z.object({ status: z.literal('enabled') }), + secret_scanning_push_protection: z.object({ status: z.literal('enabled') }), + }), +}); +type Read = (endpoint: string) => Promise; +export async function githubGovernanceDoctor( + target: { repository: string; ruleset: unknown }, + read: Read, +) { + const base = `repos/${target.repository}`; + const check = (id: string, requirement: string, inspect: () => Promise) => + doctorCheck(id, requirement, inspect); + return await Promise.all([ + check( + 'merge-methods', + 'Settings → General: enable rebase; disable merge and squash.', + async () => + repositorySettings + .pick({ allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: true }) + .safeParse(await read(base)).success, + ), + check( + 'secret-scanning', + 'Settings → Code security: enable secret scanning and push protection.', + async () => + repositorySettings.pick({ security_and_analysis: true }).safeParse(await read(base)) + .success, + ), + check( + 'actions-policy', + 'Settings → Actions: enable Actions and require full commit SHA pins.', + async () => + z + .object({ enabled: z.literal(true), sha_pinning_required: z.literal(true) }) + .safeParse(await read(`${base}/actions/permissions`)).success, + ), + check( + 'workflow-permissions', + 'Settings → Actions: default token read-only; allow contribution automation.', + async () => + z + .object({ + default_workflow_permissions: z.literal('read'), + can_approve_pull_request_reviews: z.literal(true), + }) + .safeParse(await read(`${base}/actions/permissions/workflow`)).success, + ), + check( + 'ruleset', + 'Settings → Rules: apply the reviewed organization ruleset; preserve additional merge-queue protections.', + async () => { + return await rulesMatch(target.ruleset, read, base); + }, + ), + check( + 'vulnerability-alerts', + 'Settings → Code security: enable Dependabot alerts.', + async () => { + await read(`${base}/vulnerability-alerts`); + return true; + }, + ), + check( + 'security-updates', + 'Settings → Code security: enable and unpause Dependabot security updates.', + async () => { + const updates = z + .object({ enabled: z.boolean(), paused: z.boolean() }) + .parse(await read(`${base}/automated-security-fixes`)); + return updates.enabled && !updates.paused; + }, + ), + ]); +} + +async function rulesMatch(standard: unknown, read: Read, base: string): Promise { + const expected = z + .object({ name: z.string(), rules: z.array(z.unknown()) }) + .loose() + .parse(standard); + const summaries = z + .array(z.object({ id: z.number(), name: z.string() })) + .parse(await read(`${base}/rulesets`)); + const owned = summaries.filter((rule) => rule.name === expected.name); + if (owned.length !== 1) return false; + const actual = z + .object({ rules: z.array(z.unknown()) }) + .loose() + .parse(await read(`${base}/rulesets/${owned[0]?.id}`)); + const { rules: _expectedRules, ...fields } = expected; + const { rules: _actualRules, ...observed } = actual; + return matchesPinnedRuleset(observed, fields) && matchesPinnedRules(actual.rules, expected); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/github-read.ts b/.lvbt/web-platform/packages/web-platform/src/github-read.ts index 2a8e94e..6ac9ff4 100644 --- a/.lvbt/web-platform/packages/web-platform/src/github-read.ts +++ b/.lvbt/web-platform/packages/web-platform/src/github-read.ts @@ -17,7 +17,9 @@ export function githubReader(root: string) { stdio: ['ignore', 'pipe', 'pipe'], }, ); - const pages = z.array(z.unknown()).min(1).parse(JSON.parse(output)); + const pages = z.array(z.unknown()).parse(JSON.parse(output)); + // gh emits [] for a successful HTTP 204 response when --slurp is enabled. + if (pages.length === 0) return Promise.resolve(null); if (pages.length === 1) return Promise.resolve(pages[0]); if (pages.every(Array.isArray)) return Promise.resolve(pages.flat()); const records = z.array(z.record(z.string(), z.unknown())).parse(pages); diff --git a/.lvbt/web-platform/packages/web-platform/src/github.ts b/.lvbt/web-platform/packages/web-platform/src/github.ts index 3a2f7aa..60384e8 100644 --- a/.lvbt/web-platform/packages/web-platform/src/github.ts +++ b/.lvbt/web-platform/packages/web-platform/src/github.ts @@ -10,3 +10,5 @@ export { } from './provision-variables.js'; export { provisionEnvironment, provisionEnvironmentPresence } from './provision-environment.js'; export { provisionEnvironmentSecret } from './provision-secret.js'; + +export { githubGovernanceDoctor } from './github-governance.js'; diff --git a/.lvbt/web-platform/packages/web-platform/src/legacy-release-attestation.ts b/.lvbt/web-platform/packages/web-platform/src/legacy-release-attestation.ts new file mode 100644 index 0000000..0182361 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/legacy-release-attestation.ts @@ -0,0 +1,185 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { z } from 'zod'; +import { github, githubJson } from './release-github.js'; +import { releaseSource, type StagingWorkflow } from './release-source.js'; +import { verifyRelease, type SavedReleaseIdentity } from './saved-release-artifact.js'; + +const identifier = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +export const legacyArtifactsSchema = z + .array( + z.strictObject({ + runId: z.string().regex(/^[1-9][0-9]*$/), + sourceCommit: z.string().regex(/^[a-f0-9]{40}$/), + artifactId: identifier, + expiresAt: z.iso.datetime(), + }), + ) + .max(100) + .refine( + (records) => + new Set(records.map((record) => record.runId)).size === records.length && + new Set(records.map((record) => record.artifactId)).size === records.length, + 'Legacy run and artifact IDs must be unique.', + ); +type LegacyArtifact = z.infer[number]; +interface Configuration { + repository: string; + artifactPrefix?: string; + stagingWorkflow?: StagingWorkflow; + attestation?: { legacyArtifacts?: LegacyArtifact[] | undefined } | undefined; +} +interface Dependencies { + json?: typeof githubJson; + download?: typeof github; + now?: () => number; +} +const metadataSchema = z.object({ + id: identifier, + name: z.string(), + expired: z.literal(false), + expires_at: z.iso.datetime(), + workflow_run: z.object({ + id: identifier, + head_sha: z.string(), + head_branch: z.string(), + repository_id: identifier, + head_repository_id: identifier, + }), +}); + +function selectedRecord(config: Configuration, identity: SavedReleaseIdentity, now: () => number) { + const records = legacyArtifactsSchema.parse(config.attestation?.legacyArtifacts ?? []); + const record = records.find((value) => value.runId === identity.releaseId); + if (record?.sourceCommit !== identity.commit) return undefined; + if (Date.parse(record.expiresAt) <= now()) + throw new Error('Legacy attestation allowance expired.'); + if ( + !/^[\w.-]+\/[\w.-]+$/.test(config.repository) || + !config.artifactPrefix || + !config.stagingWorkflow + ) + throw new Error('Legacy provenance requires the configured repository and staging workflow.'); + return record; +} + +async function verifiedMetadata( + config: Configuration, + record: LegacyArtifact, + dependencies: Dependencies, +) { + const json = dependencies.json ?? githubJson; + const repo = `repos/${config.repository}`; + const repository = z + .object({ id: identifier, default_branch: z.string() }) + .parse(await json(['api', repo])); + const branch = config.stagingWorkflow?.branch ?? 'main'; + if (repository.default_branch !== branch) + throw new Error('Legacy source is not on the default branch.'); + const run = await json(['api', `${repo}/actions/runs/${record.runId}`]); + const source = releaseSource(run, config.repository, record.runId, config.stagingWorkflow); + const owners = z + .object({ + repository: z.object({ id: identifier }), + head_repository: z.object({ id: identifier }), + }) + .parse(run); + if ( + source.commit !== record.sourceCommit || + owners.repository.id !== repository.id || + owners.head_repository.id !== repository.id + ) + throw new Error('Legacy source does not match its reviewed run and repository.'); + const artifact = metadataSchema.parse( + await json(['api', `${repo}/actions/artifacts/${record.artifactId}`]), + ); + const expectedName = `${config.artifactPrefix}-${record.runId}`; + const expected = { + id: record.artifactId, + name: expectedName, + expired: false, + expires_at: record.expiresAt, + workflow_run: { + id: Number(record.runId), + head_sha: record.sourceCommit, + head_branch: branch, + repository_id: repository.id, + head_repository_id: repository.id, + }, + }; + if ( + !isDeepStrictEqual(artifact, expected) || + Date.parse(artifact.expires_at) <= (dependencies.now ?? Date.now)() + ) + throw new Error('Legacy artifact metadata changed or expired.'); + const inventory = z + .object({ + total_count: z.number().int().nonnegative(), + artifacts: z.array(z.object({ id: identifier, name: z.string() })), + }) + .parse(await json(['api', `${repo}/actions/runs/${record.runId}/artifacts?per_page=100`])); + const matches = inventory.artifacts.filter((value) => value.name === expectedName); + if ( + inventory.total_count !== inventory.artifacts.length || + matches.length !== 1 || + matches[0]?.id !== record.artifactId + ) + throw new Error('Legacy artifact inventory is changed, truncated or ambiguous.'); + return artifact; +} + +/** Selection controls proof download only; publication must independently verify retained bytes. */ +export async function selectedLegacyRelease( + config: Configuration, + identity: SavedReleaseIdentity, + dependencies: Dependencies = {}, +): Promise { + const record = selectedRecord(config, identity, dependencies.now ?? Date.now); + if (!record) return false; + const before = await verifiedMetadata(config, record, dependencies); + const after = await verifiedMetadata(config, record, dependencies); + if (!isDeepStrictEqual(before, after)) + throw new Error('Legacy metadata changed during selection.'); + return true; +} + +/** A bounded migration exception for exact remotely retained v1 content, never newly produced bytes. */ +export async function verifyLegacyReleaseAttestation( + config: Configuration, + directory: string, + dependencies: Dependencies = {}, +): Promise { + const local = await verifyRelease(directory); + if (local.formatVersion !== 1) return false; + const record = selectedRecord(config, local, dependencies.now ?? Date.now); + if (!record) return false; + const before = await verifiedMetadata(config, record, dependencies); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-legacy-attestation-')); + try { + await (dependencies.download ?? github)([ + 'run', + 'download', + record.runId, + '--repo', + config.repository, + '--name', + before.name, + '--dir', + temporary, + ]); + const retained = await verifyRelease(temporary); + if (!isDeepStrictEqual(local, retained)) + throw new Error('Legacy retained inventory does not match the selected bytes.'); + const after = await verifiedMetadata(config, record, dependencies); + if (!isDeepStrictEqual(before, after)) + throw new Error('Legacy metadata changed during verification.'); + // A downloaded or provider response cannot authorize a concurrently changed local artifact. + if (!isDeepStrictEqual(local, await verifyRelease(directory))) + throw new Error('Legacy local inventory changed during verification.'); + return true; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/legacy-release-marker.ts b/.lvbt/web-platform/packages/web-platform/src/legacy-release-marker.ts new file mode 100644 index 0000000..985b99b --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/legacy-release-marker.ts @@ -0,0 +1,64 @@ +import { constants } from 'node:fs'; +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parse, type ParseError } from 'jsonc-parser'; +import { z } from 'zod'; +import { releaseMarkerAssets } from './release-marker-assets.js'; +import { workerReleaseEntry } from './worker-release-entry.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import type { SavedReleaseIdentity } from './saved-release-artifact.js'; +const record = z.record(z.string(), z.unknown()); +function fields(value: unknown): Record { + return record.parse(value ?? {}); +} +export async function normalizeLegacyReleaseMarker( + directory: string, + identity: SavedReleaseIdentity, + config: ReleaseConfiguration, +): Promise { + const file = path.join(directory, 'wrangler.jsonc'); + const errors: ParseError[] = []; + const settings = fields( + parse(await readFile(file, 'utf8'), errors, { allowTrailingComma: true }), + ); + if (errors.length) throw new Error('Invalid legacy Worker configuration.'); + const environments = fields(settings.env); + const preview = fields(environments.preview); + const assets = settings.assets + ? releaseMarkerAssets(fields(settings.assets), config.publicPath) + : undefined; + const previewAssets = preview.assets + ? releaseMarkerAssets(fields(preview.assets), config.publicPath) + : assets; + const worker = path.join(directory, '.wrangler/worker'); + await copyFile( + path.join(worker, 'index.js'), + path.join(worker, 'release-original.js'), + constants.COPYFILE_EXCL, + ); + await writeFile( + path.join(worker, 'index.js'), + workerReleaseEntry('./release-original.js', identity, config), + ); + await writeFile( + file, + JSON.stringify( + { + ...settings, + main: '.wrangler/worker/index.js', + assets, + vars: { ...fields(settings.vars), LVBT_DEPLOYMENT_ENV: 'production' }, + env: { + ...environments, + preview: { + ...preview, + assets: previewAssets, + vars: { ...fields(preview.vars), LVBT_DEPLOYMENT_ENV: 'preview' }, + }, + }, + }, + null, + 2, + ), + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/legacy-worker-release-artifact.ts b/.lvbt/web-platform/packages/web-platform/src/legacy-worker-release-artifact.ts new file mode 100644 index 0000000..8021c8e --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/legacy-worker-release-artifact.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { ReleaseConfiguration } from './release-config.js'; +import { packageRelease, type WebsiteRelease } from './saved-release-artifact.js'; +import { configuredReleaseIdentity } from './worker-release-entry.js'; +import { retainReleaseMigrations } from './saved-release-migrations.js'; +import { normalizeLegacyReleaseMarker } from './legacy-release-marker.js'; + +export async function packageLegacyWorkerRelease( + source: string, + destination: string, + identity: { commit: string; releaseId: string }, + config: ReleaseConfiguration, +): Promise { + identity = configuredReleaseIdentity(identity, config); + if (config.profile || (config.publicPath && config.publicPath !== '/')) + throw new Error( + 'Nested app releases require a canonical typed-worker or cf-output artifact source.', + ); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-legacy-worker-')); + try { + const copy = path.join(temporary, 'release'); + // The existing reader validates regular build files before copying them; generated + // migration paths can then be written without touching checkout files or symlink targets. + await packageRelease(source, copy, identity, { + ...config.artifactAcceptance, + formatVersion: 2, + }); + await normalizeLegacyReleaseMarker(copy, identity, config); + if (config.migrations?.length) await retainReleaseMigrations(source, copy, config); + return await packageRelease(copy, destination, identity, { + ...config.artifactAcceptance, + formatVersion: 2, + }); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/links.ts b/.lvbt/web-platform/packages/web-platform/src/links.ts new file mode 100644 index 0000000..fd58daa --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/links.ts @@ -0,0 +1,262 @@ +function decodeAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&#(x[\da-f]+|\d+);/gi, (_match, code: string) => + String.fromCodePoint( + code.startsWith('x') ? Number.parseInt(code.slice(1), 16) : Number(code), + ), + ); +} +function resolveLink( + value: string, + base: string, + localOrigin: string, + canonicalOrigins: ReadonlySet, +): URL | undefined { + if (!value || value.startsWith('#')) return undefined; + const url = new URL(decodeAttribute(value), base); + if (!['http:', 'https:'].includes(url.protocol)) return undefined; + if (canonicalOrigins.has(url.origin)) return new URL(`${url.pathname}${url.search}`, localOrigin); + url.hash = ''; + return url; +} +export function linksFromHtml( + html: string, + pagePath: string, + localOrigin: string, + canonicalOrigins: ReadonlySet = new Set(), +): { internal: string[]; external: string[] } { + const base = new URL(pagePath, localOrigin).href; + const internal = new Set(); + const external = new Set(); + // These are compiled HTML attributes, not source-code or Markdown links. + const attributes = + /\b(?:href|src|poster|action|srcset)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; + for (const match of html.matchAll(attributes)) { + const raw = match[1] ?? match[2] ?? match[3] ?? ''; + const values = /^srcset/i.test(match[0]) + ? raw.split(',').map((entry) => entry.trim().split(/\s+/)[0] ?? '') + : [raw]; + for (const value of values) { + const url = resolveLink(value, base, localOrigin, canonicalOrigins); + if (url) (url.origin === localOrigin ? internal : external).add(url.href); + } + } + return { internal: [...internal], external: [...external] }; +} + +export type LinkRequest = ( + url: string, + options: { redirect: 'manual'; signal: AbortSignal }, +) => Promise; +async function checkLink( + initial: string, + localOrigin: string, + request: LinkRequest, + canonicalOrigins: ReadonlySet, +): Promise { + let url = initial; + const visited = new Set(); + for (;;) { + if (visited.has(url) || visited.size >= 8) + throw new LinkFailure(`Invalid redirect chain: ${initial}`); + visited.add(url); + const response = await request(url, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + // Drain response bodies to release connections between the many local requests. + await response.arrayBuffer(); + if (response.ok) return undefined; + if (![301, 302, 303, 307, 308].includes(response.status)) + throw new LinkFailure(`Broken build link (${response.status}): ${initial}`); + const location = response.headers.get('location'); + if (!location) throw new LinkFailure(`Redirect has no destination: ${url}`); + const destination = resolveLink(location, url, localOrigin, canonicalOrigins); + if (!destination) throw new LinkFailure(`Invalid redirect destination: ${url}`); + if (destination.origin !== localOrigin) return destination.href; + url = destination.href; + } +} + +class LinkFailure extends Error {} +export interface LinkResult { + url: string; + status: 'pass' | 'fail' | 'error'; + diagnostic?: string; + source?: string; +} +export interface LinkReport { + checked: number; + results: LinkResult[]; + external: string[]; +} +export async function checkInternalLinkResults( + urls: Iterable, + localOrigin: string, + request: LinkRequest = fetch, + canonicalOrigins: ReadonlySet = new Set(), +): Promise { + const results: LinkResult[] = []; + const external = new Set(); + for (const url of urls) { + try { + const redirected = await checkLink(url, localOrigin, request, canonicalOrigins); + if (redirected) external.add(redirected); + results.push({ url, status: 'pass' }); + } catch (error) { + results.push({ + url, + status: error instanceof LinkFailure ? 'fail' : 'error', + diagnostic: error instanceof Error ? error.message : String(error), + }); + } + } + return { checked: results.length, results, external: [...external] }; +} +export async function checkInternalLinks( + urls: Iterable, + localOrigin: string, + request: LinkRequest = fetch, + canonicalOrigins: ReadonlySet = new Set(), +): Promise { + const report = await checkInternalLinkResults(urls, localOrigin, request, canonicalOrigins); + const failed = report.results.find((result) => result.status !== 'pass'); + if (failed) throw new Error(failed.diagnostic); + return report.external; +} + +async function publicDocument(url: string, origin: string, request: LinkRequest): Promise { + const visited = new Set(); + for (;;) { + if (visited.has(url) || visited.size >= 8) throw new Error(`Invalid public redirect: ${url}`); + visited.add(url); + const response = await request(url, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + const body = await response.text(); + if (response.ok) return body; + const location = response.headers.get('location'); + if (![301, 302, 303, 307, 308].includes(response.status) || !location) + throw new Error(`Public document HTTP ${response.status}: ${url}`); + const destination = new URL(location, url); + if (destination.origin !== origin) + throw new Error(`Public document redirects outside ${origin}`); + url = destination.href; + } +} + +function sitemapLocations(xml: string, sitemap: string): { index: boolean; locations: string[] } { + const index = /]*>\s*([^<]+)\s*<\/loc>/g)].map((entry) => + (entry[1] ?? '').trim(), + ); + if (!locations.length) throw new Error(`Empty public sitemap: ${sitemap}`); + return { index, locations }; +} + +function publicSitemapRoute( + location: string, + sitemap: string, + origin: string, + canonicalOrigins: ReadonlySet, +): string { + const url = resolveLink(location, sitemap, origin, canonicalOrigins); + if (url?.origin !== origin) throw new Error(`Public sitemap route outside ${origin}`); + return url.href; +} + +async function publicPages( + origin: string, + request: LinkRequest, + canonicalOrigins: ReadonlySet, +): Promise> { + const sitemapQueue = [new URL('/sitemap-index.xml', origin).href]; + const seen = new Set(); + const pages = new Set([`${origin}/`]); + for (let sitemap = sitemapQueue.shift(); sitemap; sitemap = sitemapQueue.shift()) { + if (seen.has(sitemap)) continue; + if (seen.size >= 50) throw new Error('Public sitemap exceeds 50 documents.'); + seen.add(sitemap); + const xml = await publicDocument(sitemap, origin, request); + const { index, locations } = sitemapLocations(xml, sitemap); + for (const location of locations) { + const route = publicSitemapRoute(location, sitemap, origin, canonicalOrigins); + if (index) sitemapQueue.push(route); + else pages.add(route); + if (pages.size > 1000) throw new Error('Public sitemap exceeds 1000 pages.'); + } + } + return pages; +} +interface PublicCollection { + internal: Set; + external: Set; + sources: Map>; + documentFailures: LinkResult[]; +} +async function collectPublicPage( + page: string, + options: { origin: string; request: LinkRequest; canonicalOrigins: ReadonlySet }, + collection: PublicCollection, +): Promise { + try { + const html = await publicDocument(page, options.origin, options.request); + const links = linksFromHtml(html, page, options.origin, options.canonicalOrigins); + for (const url of links.internal) { + collection.internal.add(url); + const owners = collection.sources.get(url) ?? new Set(); + owners.add(page); + collection.sources.set(url, owners); + } + for (const url of links.external) collection.external.add(url); + } catch (error) { + collection.internal.delete(page); + collection.documentFailures.push({ + url: page, + source: page, + status: 'error', + diagnostic: error instanceof Error ? error.message : String(error), + }); + } +} +/** Discover pages from the live sitemap; compiled files never supply production evidence. */ +export async function publicLinkReport( + origin: string, + request: LinkRequest = fetch, + canonicalOrigins: ReadonlySet = new Set(), +): Promise { + if (new URL(origin).origin !== origin || new URL(origin).protocol !== 'https:') + throw new Error('Public link reports require an HTTPS origin.'); + const pages = await publicPages(origin, request, canonicalOrigins); + const collection: PublicCollection = { + internal: new Set(pages), + external: new Set(), + sources: new Map(), + documentFailures: [], + }; + for (const page of pages) + await collectPublicPage(page, { origin, request, canonicalOrigins }, collection); + const report = await checkInternalLinkResults( + collection.internal, + origin, + request, + canonicalOrigins, + ); + for (const url of report.external) collection.external.add(url); + return { + checked: report.checked + collection.documentFailures.length, + results: [ + ...report.results.map((entry) => ({ + ...entry, + source: [...(collection.sources.get(entry.url) ?? [entry.url])].join(', '), + })), + ...collection.documentFailures, + ], + external: [...collection.external], + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/named-worker-release.ts b/.lvbt/web-platform/packages/web-platform/src/named-worker-release.ts new file mode 100644 index 0000000..f8317ea --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/named-worker-release.ts @@ -0,0 +1,111 @@ +import { execFile } from 'node:child_process'; +import { cp, mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { z } from 'zod'; +import { accessFetch, accessCredentials } from './access-auth.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { waitForReleaseIdentity } from './release-identity.js'; +import { verifyRelease, type WebsiteRelease } from './saved-release-artifact.js'; +const execute = promisify(execFile); +export function namedDeployReceipt(output: string, worker: string): { version: string } { + const schema = z.object({ + type: z.literal('deploy'), + version: z.literal(1), + worker_name: z.literal(worker), + version_id: z.uuid(), + }); + const records = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => schema.safeParse(JSON.parse(line) as unknown)) + .filter((result) => result.success); + if (records.length !== 1 || !records[0]?.success) + throw new Error( + 'Expected one matching named Worker deployment receipt; reconcile provider state before retrying.', + ); + return { version: records[0].data.version_id }; +} +export async function deployNamedSavedRelease( + config: ReleaseConfiguration, + directory: string, + release: WebsiteRelease, + target: 'preview' | 'production', +): Promise<{ version: string }> { + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-named-release-')); + try { + const copy = path.join(temporary, 'release'); + await cp(directory, copy, { recursive: true }); + if ((await verifyRelease(copy)).artifactHash !== release.artifactHash) + throw new Error('Saved release changed while preparing deployment.'); + const receiptPath = path.join(temporary, 'receipt.jsonl'); + const worker = target === 'preview' ? config.previewWorker : config.productionWorker; + await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'deploy', + '--name', + worker, + '--config', + path.join(copy, 'wrangler.jsonc'), + '--env', + target === 'preview' ? 'preview' : '', + '--no-bundle', + ], + { + env: { + ...process.env, + CI: 'true', + WRANGLER_LOG_SANITIZE: 'true', + WRANGLER_OUTPUT_FILE_PATH: receiptPath, + }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + return namedDeployReceipt(await readFile(receiptPath, 'utf8'), worker); + } catch (cause) { + throw new Error( + 'Named Worker deployment outcome is unknown; reconcile the selected Worker and live release marker before retrying or promoting.', + { cause }, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} +function assertNamedPreviewOrigin(config: ReleaseConfiguration): void { + const origin = new URL(config.previewUrl); + if ( + origin.hostname.endsWith('.workers.dev') && + origin.hostname !== `${config.previewWorker}.${config.workersDevSubdomain}.workers.dev` + ) + throw new Error( + 'Named staging origin does not identify the reviewed Worker and workers.dev account.', + ); +} +export async function assertNamedPreviewProtection(config: ReleaseConfiguration): Promise { + assertNamedPreviewOrigin(config); + const anonymous = await accessFetch(config.previewUrl, config.previewUrl); + if (![302, 401, 403].includes(anonymous.status)) + throw new Error('Named staging requires Access anonymous denial before deploying saved bytes.'); + await anonymous.arrayBuffer(); +} +export async function verifyNamedPreview( + config: ReleaseConfiguration, + release: WebsiteRelease, +): Promise { + assertNamedPreviewOrigin(config); + const credentials = accessCredentials(process.env); + if (!credentials) throw new Error('Named staging verification requires Access credentials.'); + await waitForReleaseIdentity( + config.previewUrl, + { + commit: release.commit, + releaseId: release.releaseId, + ...(release.app ? { app: release.app } : {}), + }, + { credentials }, + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.d.mts b/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.d.mts new file mode 100644 index 0000000..de93884 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.d.mts @@ -0,0 +1 @@ +export function prPreviewComment(app: string, url: string): { marker: string; body: string }; diff --git a/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.mjs b/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.mjs new file mode 100644 index 0000000..08cfa3b --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/pr-preview-comment.mjs @@ -0,0 +1,10 @@ +export function prPreviewComment(app, url) { + if (app && !/^[a-z0-9][a-z0-9-]*$/u.test(app)) + throw new Error('PR comment requires a reviewed release profile.'); + const marker = app ? `` : ''; + const label = app ? `Worker candidate (${app})` : 'Worker candidate'; + return { + marker, + body: `${marker}\n${label}: ${url}\n\nPR preview only; production uses a separately retained staging release.`, + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/pr-preview-config.ts b/.lvbt/web-platform/packages/web-platform/src/pr-preview-config.ts index 458e671..2cbce1c 100644 --- a/.lvbt/web-platform/packages/web-platform/src/pr-preview-config.ts +++ b/.lvbt/web-platform/packages/web-platform/src/pr-preview-config.ts @@ -72,7 +72,31 @@ export function stagingPreviewConfiguration(input: unknown, worker: string, asse return { ...config, assets: { ...config.assets, directory: assets }, routes: [] }; } -export function previewUploadReceipt(output: string, worker: string) { +function verifyPreviewUrl( + value: string, + version: string, + worker: string, + accountSubdomain?: string, +): void { + const url = new URL(value); + if (accountSubdomain && url.hostname.split('.')[1] !== accountSubdomain) + throw new Error('Preview URL does not identify the reviewed workers.dev account.'); + if ( + url.protocol !== 'https:' || + !url.hostname.endsWith('.workers.dev') || + url.hostname.split('.').length !== 4 || + !url.hostname.startsWith(`${version.slice(0, 8)}-${worker}.`) || + url.username || + url.password || + url.port || + url.search || + url.hash || + url.pathname !== '/' + ) + throw new Error('Preview URL does not identify the uploaded Worker version.'); +} + +export function previewUploadReceipt(output: string, worker: string, accountSubdomain?: string) { const records = output .split('\n') .filter((line) => line.trim()) @@ -90,18 +114,6 @@ export function previewUploadReceipt(output: string, worker: string) { if (uploads.length !== 1 || !uploads[0]?.success) throw new Error('Expected one matching Worker version upload receipt.'); const receipt = uploads[0].data; - const url = new URL(receipt.preview_url); - if ( - url.protocol !== 'https:' || - !url.hostname.endsWith('.workers.dev') || - !url.hostname.startsWith(`${receipt.version_id.slice(0, 8)}-${worker}.`) || - url.username || - url.password || - url.port || - url.search || - url.hash || - url.pathname !== '/' - ) - throw new Error('Preview URL does not identify the uploaded Worker version.'); + verifyPreviewUrl(receipt.preview_url, receipt.version_id, worker, accountSubdomain); return { version: receipt.version_id, url: receipt.preview_url }; } diff --git a/.lvbt/web-platform/packages/web-platform/src/promote-command.ts b/.lvbt/web-platform/packages/web-platform/src/promote-command.ts new file mode 100644 index 0000000..08cbb9e --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/promote-command.ts @@ -0,0 +1,124 @@ +import { productionEndpoint } from './release-path.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { z } from 'zod'; +import { github, githubJson } from './release-github.js'; +import { promotionRequest, type PromotionRun } from './promotion-request.js'; +import { publicationSchema } from './publication.js'; +import { waitForReleaseIdentity } from './release-identity.js'; + +export async function runPromote( + config: ReleaseConfiguration, + args: string[] = process.argv.slice(2), +): Promise { + if (config.previewOnly) throw new Error('Preview-only apps cannot request production promotion.'); + const { values } = parseArgs({ + args, + options: { + 'run-id': { type: 'string' }, + 'expected-version': { type: 'string' }, + help: { type: 'boolean' }, + }, + }); + if (values.help) { + process.stdout.write( + 'Usage: pnpm promote [--run-id ]\nPromotes current preview by default, using GitHub environment credentials. Requires gh authentication with Actions write access.\n', + ); + } else { + if (values['expected-version']) z.uuid().parse(values['expected-version']); + const repository = config.repository; + const repo = z + .object({ nameWithOwner: z.literal(repository) }) + .parse(await githubJson(['repo', 'view', '--json', 'nameWithOwner'])); + const endpoint = `repos/${repo.nameWithOwner}/actions/workflows/${config.promotionWorkflow.file}`; + z.object({ state: z.literal('active') }).parse(await githubJson(['api', endpoint])); + const requestId = randomUUID(); + process.stdout.write( + `Request: ${requestId}\nResolving the selected preview release in GitHub Actions.\n`, + ); + const run = await promotionRequest(requestId, values['run-id'], { + workflow: { + titlePrefix: config.promotionWorkflow.titlePrefix, + branch: config.promotionWorkflow.branch, + path: `.github/workflows/${config.promotionWorkflow.file}`, + }, + dispatch: async (id, releaseId) => { + const args = [ + 'api', + `${endpoint}/dispatches`, + '--method', + 'POST', + '-f', + `ref=${config.promotionWorkflow.branch}`, + '-f', + `inputs[request_id]=${id}`, + ]; + if (releaseId) args.push('-f', `inputs[run_id]=${releaseId}`); + if (config.profile) args.push('-f', `inputs[app]=${config.profile}`); + if (values['expected-version']) + args.push('-f', `inputs[expected_version]=${values['expected-version']}`); + await github(args); + }, + listRuns: async () => + await githubJson([ + 'api', + `${endpoint}/runs?event=workflow_dispatch&branch=${config.promotionWorkflow.branch}&per_page=100`, + ]), + getRun: async (id) => await githubJson(['api', `repos/${repository}/actions/runs/${id}`]), + progress: (current) => process.stdout.write(`${current.status}: ${current.html_url}\n`), + }); + await verifyPublication(config, run); + } +} + +async function verifyPublication(config: ReleaseConfiguration, run: PromotionRun): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-publication-')); + try { + try { + await github([ + 'run', + 'download', + String(run.id), + '--repo', + config.repository, + '--name', + `publication-${run.id}-${run.run_attempt}${config.profile ? `-${config.profile}` : ''}`, + '--dir', + directory, + ]); + } catch { + throw new Error( + `Promotion ${run.conclusion}; publication receipt unavailable. Inspect ${run.html_url} before dispatching again.`, + ); + } + const receipt = publicationSchema.parse( + JSON.parse(await readFile(path.join(directory, 'publication.json'), 'utf8')), + ); + if (receipt.url !== productionEndpoint(config) || receipt.release.app !== config.profile) + throw new Error( + `Publication receipt targets another site. Inspect ${run.html_url}; do not redispatch.`, + ); + process.stdout.write(`${JSON.stringify({ run: run.html_url, ...receipt }, null, 2)}\n`); + if (receipt.activation === 'confirmed') + await waitForReleaseIdentity(config.productionUrl, receipt.release, { + publicPath: config.publicPath, + }); + if ( + run.conclusion !== 'success' || + receipt.activation !== 'confirmed' || + receipt.verification !== 'success' + ) + throw new Error( + `Promotion ${run.conclusion}; activation ${receipt.activation}; public verification ${receipt.verification}. Inspect ${run.html_url}. Do not blindly redispatch.`, + ); + process.stdout.write( + `Published release ${receipt.release.releaseId} at ${receipt.url}; live marker verified.\n`, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/promotion-request.ts b/.lvbt/web-platform/packages/web-platform/src/promotion-request.ts new file mode 100644 index 0000000..276cc36 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/promotion-request.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +const runSchema = z.object({ + id: z.number().int().positive(), + display_title: z.string(), + event: z.string(), + head_branch: z.string(), + path: z.string(), + status: z.string(), + conclusion: z.string().nullable(), + run_attempt: z.number().int().positive(), + html_url: z.url(), +}); +export type PromotionRun = z.infer; +interface Actions { + workflow?: { titlePrefix: string; path: string; branch: string }; + dispatch: (requestId: string, runId?: string) => Promise; + listRuns: () => Promise; + getRun: (id: number) => Promise; + now?: () => number; + sleep?: (ms: number) => Promise; + discoveryTimeoutMs?: number; + completionTimeoutMs?: number; + progress?: (run: PromotionRun) => void; +} +interface Timing { + now: () => number; + sleep: (ms: number) => Promise; +} +async function trackedRead( + read: () => Promise, + context: string, + timing: Timing, + deadline: number, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await read(); + } catch (error) { + const rejected = error instanceof Error && /HTTP (400|401|403|404|422)/.test(error.message); + if (rejected || attempt >= 2 || timing.now() >= deadline) + throw new Error(`Cannot track ${context}. Do not dispatch again; inspect GitHub Actions.`, { + cause: error, + }); + await timing.sleep(Math.min(5_000, deadline - timing.now())); + } + } +} + +async function discoverRun( + requestId: string, + actions: Actions, + { now, sleep }: Timing, +): Promise { + const discoveryDeadline = now() + (actions.discoveryTimeoutMs ?? 120_000); + for (;;) { + const runs = z + .object({ workflow_runs: z.array(runSchema) }) + .parse( + await trackedRead( + () => actions.listRuns(), + `request ${requestId}`, + { now, sleep }, + discoveryDeadline, + ), + ).workflow_runs; + const matches = runs.filter( + (run) => + run.display_title === + `${actions.workflow?.titlePrefix ?? 'Promote website'}: ${requestId}` && + run.event === 'workflow_dispatch' && + run.head_branch === (actions.workflow?.branch ?? 'main') && + run.path === (actions.workflow?.path ?? '.github/workflows/deploy-worker-candidate.yml'), + ); + if (matches.length > 1) + throw new Error( + `Multiple runs match request ${requestId}. Reconcile in Actions before publishing again.`, + ); + const selected = matches[0]; + if (selected) return selected; + if (now() >= discoveryDeadline) + throw new Error( + `Dispatch unconfirmed for request ${requestId}. Do not dispatch again; reconcile this request in GitHub Actions.`, + ); + await sleep(Math.min(5_000, discoveryDeadline - now())); + } +} + +async function waitForRun( + selected: PromotionRun, + actions: Actions, + { now, sleep }: Timing, +): Promise { + const completionDeadline = now() + (actions.completionTimeoutMs ?? 40 * 60_000); + let lastStatus = ''; + for (;;) { + const run = runSchema.parse( + await trackedRead( + () => actions.getRun(selected.id), + selected.html_url, + { now, sleep }, + completionDeadline, + ), + ); + if ( + run.id !== selected.id || + run.display_title !== selected.display_title || + run.head_branch !== (actions.workflow?.branch ?? 'main') || + run.path !== selected.path + ) + throw new Error( + 'GitHub returned a different promotion run. Reconcile before publishing again.', + ); + if (run.status !== lastStatus) { + actions.progress?.(run); + lastStatus = run.status; + } + if (run.status === 'completed') return run; + if (now() >= completionDeadline) + throw new Error( + `Promotion still ${run.status}: ${run.html_url}. Do not dispatch again; inspect this run.`, + ); + await sleep(Math.min(10_000, completionDeadline - now())); + } +} + +export async function promotionRequest( + requestId: string, + runId: string | undefined, + actions: Actions, +): Promise { + if (runId && !/^[1-9][0-9]*$/.test(runId)) throw new Error('Select an Actions run ID.'); + const timing = { + now: actions.now ?? Date.now, + sleep: + actions.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), + }; + // A transport error can occur after GitHub accepts the request. Never automatically redispatch. + try { + await actions.dispatch(requestId, runId); + } catch (error) { + if (error instanceof Error && /HTTP (401|403|404|422)/.test(error.message)) throw error; + } + const selected = await discoverRun(requestId, actions, timing); + return await waitForRun(selected, actions, timing); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/publication-baseline.ts b/.lvbt/web-platform/packages/web-platform/src/publication-baseline.ts new file mode 100644 index 0000000..a567b03 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/publication-baseline.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { verifyExpectedProductionVersion } from './expected-production-version.js'; +import { releaseMarkerPath } from './release-path.js'; +import { + readReleaseIdentity, + releaseIdentitySchema, + type ReleaseIdentity, +} from './release-identity.js'; +import type { ReleaseConfiguration } from './release-config.js'; + +const legacyMarkerSchema = z.strictObject({ + formatVersion: z.literal(1), + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u), + commit: z.string().regex(/^[a-f0-9]{40}$/u), + artifactHash: z.string().regex(/^[a-f0-9]{64}$/u), +}); +export const baselineEvidenceSchema = z.strictObject({ + kind: z.literal('provider-version'), + version: z.uuid(), + markerStatus: z.enum(['legacy', 'missing', 'nonmarker']), + legacyMarker: legacyMarkerSchema.nullable(), +}); +export type BaselineEvidence = z.infer; +type Configuration = Pick< + ReleaseConfiguration, + 'productionWorker' | 'productionUrl' | 'publicPath' | 'profile' +>; +interface Dependencies { + request?: typeof fetch; + verifyVersion?: typeof verifyExpectedProductionVersion; +} +function evidence(version: string, value: unknown, missing: boolean): BaselineEvidence { + const legacy = legacyMarkerSchema.safeParse(value); + return { + kind: 'provider-version', + version, + markerStatus: legacy.success ? 'legacy' : missing ? 'missing' : 'nonmarker', + legacyMarker: legacy.success ? legacy.data : null, + }; +} +async function marker(config: Configuration, request: typeof fetch) { + const response = await request(`${config.productionUrl}${releaseMarkerPath(config.publicPath)}`, { + redirect: 'manual', + cache: 'no-store', + signal: AbortSignal.timeout(10_000), + }); + if (response.status === 404) return { missing: true, value: null }; + if (response.status !== 200) + throw new Error(`Production baseline marker unavailable (${response.status}).`); + let value: unknown; + try { + value = await response.json(); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + value = null; + } + return { missing: false, value }; +} + +/** Legacy evidence is permitted only with an explicit authenticated current-version precondition. */ +export async function readProductionBaseline( + config: Configuration, + expectedVersion?: string, + dependencies: Dependencies = {}, +): Promise<{ identity: ReleaseIdentity | null; evidence?: BaselineEvidence }> { + if (!expectedVersion) + return { + identity: await readReleaseIdentity(config.productionUrl, { + publicPath: config.publicPath, + app: config.profile, + ...(dependencies.request ? { request: dependencies.request } : {}), + }), + }; + await (dependencies.verifyVersion ?? verifyExpectedProductionVersion)(config, expectedVersion); + const result = await marker(config, dependencies.request ?? fetch); + const identity = releaseIdentitySchema.safeParse(result.value); + if (identity.success) { + if (identity.data.app !== config.profile) + throw new Error('Production baseline belongs to another app.'); + return { identity: identity.data }; + } + const retained = evidence(expectedVersion, result.value, result.missing); + if (retained.legacyMarker && config.profile && retained.legacyMarker.slug !== config.profile) + throw new Error('Legacy production baseline belongs to another app.'); + return { identity: null, evidence: retained }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/publication.ts b/.lvbt/web-platform/packages/web-platform/src/publication.ts new file mode 100644 index 0000000..3a30dcf --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/publication.ts @@ -0,0 +1,49 @@ +import { baselineEvidenceSchema, type BaselineEvidence } from './publication-baseline.js'; +import { z } from 'zod'; +import type { ReleaseIdentity } from './release-identity.js'; + +export const publicationSchema = z.object({ + release: z.object({ + app: z.string().optional(), + releaseId: z.string().regex(/^[1-9][0-9]*$/), + commit: z.string().regex(/^[a-f0-9]{40}$/), + }), + baseline: z + .object({ releaseId: z.string(), commit: z.string(), app: z.string().optional() }) + .nullable(), + baselineEvidence: baselineEvidenceSchema.optional(), + artifactHash: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable(), + version: z.uuid().nullable(), + activation: z.enum(['confirmed', 'unknown', 'not-attempted']), + verification: z.enum(['success', 'failure', 'skipped', 'cancelled']), + url: z.url({ protocol: /^https$/ }), + recordedAt: z.string(), +}); +export function publicationReceipt(input: { + release: ReleaseIdentity; + baseline: ReleaseIdentity | null; + baselineEvidence?: BaselineEvidence | undefined; + artifactHash?: string | undefined; + version?: string | undefined; + activation: string; + verification: string; + url?: string; +}) { + return publicationSchema.parse({ + ...input, + artifactHash: input.artifactHash === '' ? null : (input.artifactHash ?? null), + version: input.version === '' ? null : (input.version ?? null), + activation: + input.activation === 'success' + ? 'confirmed' + : ['skipped', ''].includes(input.activation) + ? 'not-attempted' + : 'unknown', + verification: input.verification || 'skipped', + url: input.url ?? 'https://lasvegasfortransit.org', + recordedAt: new Date().toISOString(), + }); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/read-only-worker-modules.ts b/.lvbt/web-platform/packages/web-platform/src/read-only-worker-modules.ts new file mode 100644 index 0000000..5c4777c --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/read-only-worker-modules.ts @@ -0,0 +1,79 @@ +import { lstat, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { parse } from 'acorn'; + +interface SyntaxNode { + type: string; + [key: string]: unknown; +} +function node(value: unknown): SyntaxNode | undefined { + if (typeof value !== 'object' || value === null || !('type' in value)) return; + if (typeof value.type !== 'string') return; + return value as SyntaxNode; +} +function rejectGlobalAccess(): never { + throw new Error( + 'Shared read-only bindings require reviewed handler modules without global or unverified native binding imports.', + ); +} +function staticTarget(value: unknown): string { + const target = node(value); + if (target?.type !== 'Literal' || typeof target.value !== 'string') rejectGlobalAccess(); + return target.value; +} +function checkNativeDeclaration(current: SyntaxNode): void { + if (!current.source || staticTarget(current.source) !== 'cloudflare:workers') return; + if ( + current.type !== 'ImportDeclaration' || + !Array.isArray(current.specifiers) || + !current.specifiers.length + ) + rejectGlobalAccess(); + for (const value of current.specifiers) { + const specifier = node(value); + if (specifier?.type !== 'ImportSpecifier' || node(specifier.imported)?.name !== 'DurableObject') + rejectGlobalAccess(); + } +} +function checkDynamicImport(current: SyntaxNode): void { + if (current.type === 'ImportExpression' && staticTarget(current.source) === 'cloudflare:workers') + rejectGlobalAccess(); + const callee = node(current.callee); + if ( + current.type !== 'CallExpression' || + callee?.type !== 'Identifier' || + !['require', '__require'].includes(String(callee.name)) + ) + return; + const target: unknown = Array.isArray(current.arguments) ? current.arguments[0] : undefined; + if (staticTarget(target) === 'cloudflare:workers') rejectGlobalAccess(); +} +function visit(value: unknown): void { + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + const current = node(value); + if (!current) return; + if ( + ['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(current.type) + ) + checkNativeDeclaration(current); + checkDynamicImport(current); + for (const child of Object.values(current)) visit(child); +} +export function assertReadOnlyWorkerModule(source: string): void { + visit(parse(source, { ecmaVersion: 'latest', sourceType: 'module' })); +} +async function verifyDirectory(directory: string): Promise { + for (const entry of await readdir(directory)) { + const file = path.join(directory, entry); + const info = await lstat(file); + if (info.isSymbolicLink()) rejectGlobalAccess(); + if (info.isDirectory()) await verifyDirectory(file); + else if (/\.[cm]?js$/u.test(entry)) assertReadOnlyWorkerModule(await readFile(file, 'utf8')); + } +} +export async function verifyReadOnlyWorkerModules(directory: string): Promise { + await verifyDirectory(path.join(directory, '.wrangler/worker')); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/record-publication-command.ts b/.lvbt/web-platform/packages/web-platform/src/record-publication-command.ts new file mode 100644 index 0000000..fc4b3b4 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/record-publication-command.ts @@ -0,0 +1,90 @@ +import { + readProductionBaseline, + baselineEvidenceSchema, + type BaselineEvidence, +} from './publication-baseline.js'; +import { productionEndpoint } from './release-path.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { type ReleaseIdentity } from './release-identity.js'; +import { publicationReceipt } from './publication.js'; + +function selectedIdentity( + config: ReleaseConfiguration, + commit: string, + releaseId: string, +): ReleaseIdentity { + return { commit, releaseId, ...(config.profile ? { app: config.profile } : {}) }; +} +async function savedBaseline( + file: string, +): Promise<{ baseline: ReleaseIdentity | null; baselineEvidence?: BaselineEvidence }> { + let baseline: ReleaseIdentity | null = null; + let baselineEvidence: BaselineEvidence | undefined; + try { + const stored = JSON.parse(await readFile(file, 'utf8')) as { + identity?: ReleaseIdentity | null; + evidence?: unknown; + releaseId?: string; + commit?: string; + }; + baseline = stored.identity ?? (stored.releaseId ? (stored as ReleaseIdentity) : null); + if (stored.evidence) baselineEvidence = baselineEvidenceSchema.parse(stored.evidence); + } catch { + /* Baseline step may have failed; still retain the outcome. */ + } + return { baseline, ...(baselineEvidence ? { baselineEvidence } : {}) }; +} +export async function runPublication( + config: ReleaseConfiguration, + args: string[] = process.argv.slice(2), +): Promise { + const { positionals, values } = parseArgs({ + args, + allowPositionals: true, + options: { + directory: { type: 'string' }, + 'expected-version': { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + 'artifact-hash': { type: 'string' }, + version: { type: 'string' }, + activation: { type: 'string' }, + verification: { type: 'string' }, + }, + }); + if (!values.directory) throw new Error('Pass --directory.'); + await mkdir(values.directory, { recursive: true }); + const baselinePath = path.join(values.directory, 'baseline.json'); + if (positionals[0] === 'baseline') { + const baseline = await readProductionBaseline(config, values['expected-version']); + await writeFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); + } else if (positionals[0] === 'record') { + if (!values.commit || !values['release-id']) throw new Error('Pass --commit and --release-id.'); + const { baseline, baselineEvidence } = await savedBaseline(baselinePath); + const receipt = publicationReceipt({ + url: productionEndpoint(config), + release: selectedIdentity(config, values.commit, values['release-id']), + baseline, + ...(baselineEvidence ? { baselineEvidence } : {}), + artifactHash: values['artifact-hash'], + version: values.version, + activation: values.activation ?? '', + verification: values.verification ?? '', + }); + await writeFile( + path.join(values.directory, 'publication.json'), + `${JSON.stringify(receipt, null, 2)}\n`, + ); + const summary = process.env.GITHUB_STEP_SUMMARY; + if (summary) + await writeFile( + summary, + `### Website publication receipt\n\nRelease: ${receipt.release.releaseId}\n\nCommit: ${receipt.release.commit}\n\nBaseline: ${receipt.baseline?.releaseId ?? 'unavailable'}\n\nArtifact SHA-256: ${receipt.artifactHash ?? 'unavailable'}\n\nWorker version: ${receipt.version ?? 'unavailable'}\n\nActivation: ${receipt.activation}\n\nPublic verification: ${receipt.verification}\n\nPublic site: ${receipt.url}\n`, + { flag: 'a' }, + ); + process.stdout.write(`${JSON.stringify(receipt)}\n`); + } else throw new Error('Use baseline or record.'); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-attestation.ts b/.lvbt/web-platform/packages/web-platform/src/release-attestation.ts new file mode 100644 index 0000000..d66d339 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-attestation.ts @@ -0,0 +1,170 @@ +import { execFile } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { isDeepStrictEqual, parseArgs, promisify } from 'node:util'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { verifyRelease, type WebsiteRelease } from './saved-release-artifact.js'; +import { + legacyArtifactsSchema, + verifyLegacyReleaseAttestation, +} from './legacy-release-attestation.js'; + +const execute = promisify(execFile); +const signer = z.strictObject({ + signerWorkflow: z.literal( + 'LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml', + ), + signerCommit: z.string().regex(/^[a-f0-9]{40}$/), + legacyArtifacts: legacyArtifactsSchema.optional(), +}); +type AttestationConfig = Pick & + Partial> & { + attestation?: + | { + signerWorkflow: string; + signerCommit: string; + legacyArtifacts?: z.infer | undefined; + } + | undefined; + }; + +/** Sign the verified inventory itself, so its digest binds every retained file. */ +export async function createReleaseAttestation( + directory: string, + proofDirectory: string, + expected?: Pick, +): Promise { + const release = await verifyRelease(directory); + if (expected && (release.commit !== expected.commit || release.releaseId !== expected.releaseId)) + throw new Error('Attestation source does not match the selected build run.'); + await mkdir(proofDirectory, { recursive: true }); + await writeFile( + path.join(proofDirectory, 'release-attestation.json'), + `${JSON.stringify(release)}\n`, + { flag: 'wx' }, + ); +} + +async function regularProof(file: string): Promise { + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) + throw new Error('Release attestation proof must contain regular files smaller than16MiB.'); +} + +/** Require GitHub's signed source and shared-builder identity before trusting the envelope. */ +export async function verifyReleaseAttestation( + config: AttestationConfig, + directory: string, + proofDirectory?: string, +): Promise { + if (!config.attestation) return; + const expectedSigner = signer.parse(config.attestation); + if (!proofDirectory && (await verifyLegacyReleaseAttestation(config, directory))) return; + if (!proofDirectory) + throw new Error('This release requires --attestation-directory with retained signed proof.'); + const release = await verifyRelease(directory); + const files = ['release-attestation.json', 'bundle.jsonl'] as const; + for (const file of files) await regularProof(path.resolve(proofDirectory, file)); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-attestation-')); + const manifest = path.join(temporary, files[0]); + const bundle = path.join(temporary, files[1]); + try { + for (const file of files) + await writeFile( + path.join(temporary, file), + await readFile(path.resolve(proofDirectory, file)), + { flag: 'wx' }, + ); + const signed: unknown = JSON.parse(await readFile(manifest, 'utf8')); + await verifySignature({ + repository: config.repository, + expectedSigner, + sourceCommit: release.commit, + manifest, + bundle, + }); + if (!isDeepStrictEqual(signed, release)) + throw new Error('Signed inventory does not match the selected release.'); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +interface SignatureInput { + repository: string; + expectedSigner: z.infer; + sourceCommit: string; + manifest: string; + bundle: string; +} +async function verifySignature({ + repository, + expectedSigner, + sourceCommit, + manifest, + bundle, +}: SignatureInput): Promise { + try { + await execute( + 'gh', + [ + 'attestation', + 'verify', + manifest, + '--bundle', + bundle, + '--repo', + repository, + '--signer-workflow', + expectedSigner.signerWorkflow, + '--signer-digest', + expectedSigner.signerCommit, + '--source-digest', + sourceCommit, + '--source-ref', + 'refs/heads/main', + '--deny-self-hosted-runners', + ], + { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, + ); + } catch { + throw new Error('Release attestation verification failed; no publication is authorized.'); + } +} + +export async function runReleaseAttestation( + config: ReleaseConfiguration | undefined, + args: string[], +): Promise { + const { positionals, values } = parseArgs({ + args, + allowPositionals: true, + options: { + directory: { type: 'string' }, + 'attestation-directory': { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + }, + }); + if (!values.directory || !values['attestation-directory']) + throw new Error('Pass --directory and --attestation-directory.'); + if (positionals[0] === 'manifest') + await createReleaseAttestation( + path.resolve(values.directory), + path.resolve(values['attestation-directory']), + values.commit && values['release-id'] + ? { commit: values.commit, releaseId: values['release-id'] } + : undefined, + ); + else if (positionals[0] === 'verify') { + if (!config?.attestation) + throw new Error('Configure the reviewed attestation signer before verifying proof.'); + await verifyReleaseAttestation( + config, + path.resolve(values.directory), + path.resolve(values['attestation-directory']), + ); + } else throw new Error('Use attestation manifest or attestation verify.'); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-browser.ts b/.lvbt/web-platform/packages/web-platform/src/release-browser.ts new file mode 100644 index 0000000..c628f42 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-browser.ts @@ -0,0 +1,22 @@ +import type { BrowserContext } from 'playwright-core'; +import { accessHeaders, type AccessCredentials } from './access-auth.js'; + +export async function scopeBrowserAccess( + context: Pick, + origin: string, + credentials?: AccessCredentials, +): Promise { + if (!credentials) return; + await context.route('**/*', async (route) => { + const headers = accessHeaders(route.request().url(), origin, credentials); + if (Object.keys(headers).length === 0) { + await route.continue(); + return; + } + const response = await route.fetch({ + headers: { ...route.request().headers(), ...headers }, + maxRedirects: 0, + }); + await route.fulfill({ response }); + }); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-candidate.ts b/.lvbt/web-platform/packages/web-platform/src/release-candidate.ts new file mode 100644 index 0000000..357b546 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-candidate.ts @@ -0,0 +1,239 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual, promisify } from 'node:util'; +import { z } from 'zod'; +import { githubJson } from './release-github.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import type { WebsiteRelease } from './saved-release-artifact.js'; +const execute = promisify(execFile); +const sha = z.string().regex(/^[a-f0-9]{40}$/); +const identifier = z.string().regex(/^[1-9][0-9]*$/); +export const candidateReceiptSchema = z.strictObject({ + formatVersion: z.literal(1), + issuer: z.strictObject({ + runId: identifier, + attempt: identifier, + toolsCommit: sha, + sharedCommit: sha, + }), + release: z.strictObject({ + commit: sha, + releaseId: identifier, + artifactHash: z.string().regex(/^[a-f0-9]{64}$/), + app: z.string().optional(), + }), + preview: z.strictObject({ worker: z.string(), origin: z.url(), version: z.uuid() }), + checks: z.strictObject({ + artifact: z.literal('success'), + smoke: z.literal('success'), + browser: z.enum(['success', 'not-applicable']), + }), +}); +type CandidateReceipt = z.infer; +interface CandidateContext { + runId: string; + attempt: string; + toolsCommit: string; + sharedCommit: string; +} +export interface CandidateOperations { + getRun(id: string): Promise; + getJobs(id: string): Promise; + getArtifacts(id: string): Promise; + downloadArtifact(id: number): Promise; +} +export function candidateArtifactName( + context: Pick, + profile?: string, +): string { + return `candidate-${context.runId}-${context.attempt}${profile ? `-${profile}` : ''}`; +} +async function currentContext(config: ReleaseConfiguration): Promise { + if ( + process.env.GITHUB_ACTIONS !== 'true' || + process.env.GITHUB_REPOSITORY !== config.repository || + process.env.GITHUB_REF !== 'refs/heads/main' || + process.env.GITHUB_EVENT_NAME !== 'workflow_dispatch' + ) + throw new Error( + 'Trusted candidate receipts require the current default-branch manual promotion run.', + ); + const root = path.resolve( + process.cwd(), + ...config.appDirectory + .split('/') + .filter((part) => part !== '.') + .map(() => '..'), + ); + const shared = z + .object({ commit: sha }) + .parse(JSON.parse(await readFile(path.join(root, '.lvbt/web-platform.json'), 'utf8'))); + return { + runId: identifier.parse(process.env.GITHUB_RUN_ID), + attempt: identifier.parse(process.env.GITHUB_RUN_ATTEMPT), + toolsCommit: sha.parse(process.env.GITHUB_SHA), + sharedCommit: shared.commit, + }; +} +function selectedRelease(release: WebsiteRelease) { + return { + commit: release.commit, + releaseId: release.releaseId, + artifactHash: release.artifactHash, + ...(release.app ? { app: release.app } : {}), + }; +} +export async function createCandidateReceipt( + config: ReleaseConfiguration, + release: WebsiteRelease, + input: { directory: string; version: string; browser: string }, +): Promise { + if ( + input.browser !== 'success' && + !(input.browser === 'skipped' && process.env.BROWSER_SCRIPT === 'none') + ) + throw new Error( + 'Candidate receipt requires completed browser acceptance or an explicitly declared service without UI.', + ); + const receipt = candidateReceiptSchema.parse({ + formatVersion: 1, + issuer: await currentContext(config), + release: selectedRelease(release), + preview: { worker: config.previewWorker, origin: config.previewUrl, version: input.version }, + checks: { + artifact: 'success', + smoke: 'success', + browser: input.browser === 'success' ? 'success' : 'not-applicable', + }, + }); + await mkdir(input.directory, { recursive: true }); + await writeFile(path.join(input.directory, 'candidate.json'), JSON.stringify(receipt) + '\n', { + flag: 'wx', + }); +} +async function downloadArtifact(repository: string, id: number): Promise { + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-candidate-proof-')); + try { + const zip = path.join(temporary, 'candidate.zip'); + const { stdout } = await execute( + 'gh', + ['api', `repos/${repository}/actions/artifacts/${id}/zip`], + { encoding: 'buffer', maxBuffer: 4 * 1024 * 1024 }, + ); + await writeFile(zip, stdout); + const { stdout: receipt } = await execute('unzip', ['-p', zip, 'candidate.json'], { + maxBuffer: 1024 * 1024, + }); + return receipt; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} +function operations(repository: string): CandidateOperations { + return { + getRun: async (id) => await githubJson(['api', `repos/${repository}/actions/runs/${id}`]), + getJobs: async (id) => + await githubJson([ + 'api', + `repos/${repository}/actions/runs/${id}/jobs?filter=latest&per_page=100`, + ]), + getArtifacts: async (id) => + await githubJson(['api', `repos/${repository}/actions/runs/${id}/artifacts?per_page=100`]), + downloadArtifact: async (id) => await downloadArtifact(repository, id), + }; +} +function assertRun(value: unknown, config: ReleaseConfiguration, context: CandidateContext): void { + z.object({ + id: z.number().refine((id) => String(id) === context.runId), + run_attempt: z.number().refine((attempt) => String(attempt) === context.attempt), + head_sha: z.literal(context.toolsCommit), + head_branch: z.literal('main'), + event: z.literal('workflow_dispatch'), + path: z + .string() + .refine( + (value) => value.split('@')[0] === `.github/workflows/${config.promotionWorkflow.file}`, + ), + repository: z.object({ full_name: z.literal(config.repository) }), + head_repository: z.object({ full_name: z.literal(config.repository) }), + }).parse(value); +} +function assertCompletedCandidate(value: unknown): void { + const jobs = z + .object({ + jobs: z.array( + z.object({ name: z.string(), status: z.string(), conclusion: z.string().nullable() }), + ), + }) + .parse(value) + .jobs.filter((job) => /(^| \/ )Verify named release candidate$/.test(job.name)); + if (jobs.length !== 1 || jobs[0]?.status !== 'completed' || jobs[0].conclusion !== 'success') + throw new Error('Current promotion run has no uniquely completed successful candidate job.'); +} +function selectedArtifact(value: unknown, name: string, context: CandidateContext): number { + const artifacts = z + .object({ + artifacts: z.array( + z.object({ + id: z.number().int().positive(), + name: z.string(), + expired: z.boolean(), + workflow_run: z.object({ id: z.number(), head_sha: z.string(), head_branch: z.string() }), + }), + ), + }) + .parse(value) + .artifacts.filter((artifact) => artifact.name === name); + const artifact = artifacts[0]; + if ( + artifacts.length !== 1 || + !artifact || + artifact.expired || + String(artifact.workflow_run.id) !== context.runId || + artifact.workflow_run.head_sha !== context.toolsCommit || + artifact.workflow_run.head_branch !== 'main' + ) + throw new Error('Candidate proof must be one retained artifact from the current trusted run.'); + return artifact.id; +} +export async function verifyCandidateReceipt( + config: ReleaseConfiguration, + release: WebsiteRelease, + directory: string, + options: { context?: CandidateContext; operations?: CandidateOperations } = {}, +): Promise { + const context = options.context ?? (await currentContext(config)); + const tools = options.operations ?? operations(config.repository); + const file = path.join(directory, 'candidate.json'); + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) + throw new Error('Candidate proof must be a regular receipt smaller than1MiB.'); + const text = await readFile(file, 'utf8'); + const receipt = candidateReceiptSchema.parse(JSON.parse(text)); + if ( + !isDeepStrictEqual(receipt.issuer, context) || + !isDeepStrictEqual(receipt.release, selectedRelease(release)) || + receipt.preview.worker !== config.previewWorker || + receipt.preview.origin !== config.previewUrl + ) + throw new Error( + 'Candidate proof does not match this run, tools, selected artifact, app, and preview namespace.', + ); + assertRun(await tools.getRun(context.runId), config, context); + assertCompletedCandidate(await tools.getJobs(context.runId)); + const id = selectedArtifact( + await tools.getArtifacts(context.runId), + candidateArtifactName(context, config.profile), + context, + ); + const retained = await tools.downloadArtifact(id); + if ( + createHash('sha256').update(retained).digest('hex') !== + createHash('sha256').update(text).digest('hex') + ) + throw new Error('Candidate proof bytes differ from the immutable current-run artifact.'); + return receipt; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-command.ts b/.lvbt/web-platform/packages/web-platform/src/release-command.ts new file mode 100644 index 0000000..3795e06 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-command.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { runPrPreview } from './release-pr-preview-command.js'; +import { runReleaseAttestation } from './release-attestation.js'; +import { readReleaseConfiguration } from './release-config.js'; +import { runPromote } from './promote-command.js'; +import { runWorkerRelease } from './worker-release-command.js'; +import { runWorkerReleaseSmoke } from './worker-release-smoke.js'; +import { runPublication } from './record-publication-command.js'; +import { runReleaseMigrations } from './saved-release-migrations.js'; + +const [command, root, ...args] = process.argv.slice(2); +if (!root) throw new Error('Pass the repository root.'); +const appIndex = args.indexOf('--app'); +let app: string | undefined; +if (appIndex >= 0) { + app = args[appIndex + 1]; + if (!app || app.startsWith('--')) throw new Error('Pass --app with a named release profile.'); + args.splice(appIndex, 2); +} +if (command === 'attestation' && args[0] === 'manifest') { + await runReleaseAttestation(undefined, args); +} else { + const config = await readReleaseConfiguration( + root, + process.env, + app ?? process.env.LVBT_RELEASE_APP, + ); + process.chdir(path.resolve(root, config.appDirectory)); + if (command === 'pr-preview') await runPrPreview(config, args); + else if (command === 'promote') await runPromote(config, args); + else if (command === 'worker-release') await runWorkerRelease(config, args); + else if (command === 'publication') await runPublication(config, args); + else if (command === 'smoke') await runWorkerReleaseSmoke(config, args); + else if (command === 'attestation') await runReleaseAttestation(config, args); + else if (command === 'migrate') await runReleaseMigrations(config, args); + else throw new Error('Use promote, worker-release, publication, smoke, or migrate.'); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-config.ts b/.lvbt/web-platform/packages/web-platform/src/release-config.ts new file mode 100644 index 0000000..1d7af85 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-config.ts @@ -0,0 +1,228 @@ +import { publicPathSchema, productionEndpoint } from './release-path.js'; +import { workerSmokeSchema } from './worker-release-smoke.js'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { legacyArtifactsSchema } from './legacy-release-attestation.js'; + +const origin = z + .url({ protocol: /^https$/ }) + .refine((value) => new URL(value).origin === value, 'Use an HTTPS origin without a path.'); +const worker = z.string().regex(/^[a-z0-9][a-z0-9-]*$/); +export const releaseConfigurationSchema = z + .object({ + repository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + appDirectory: z + .string() + .min(1) + .refine( + (value) => !path.isAbsolute(value) && !value.split(/[\\/]/).includes('..'), + 'Keep the app inside the checked repository.', + ), + productionUrl: origin, + previewUrl: origin, + productionWorker: worker, + previewWorker: worker, + artifactPrefix: worker, + workersDevSubdomain: z + .string() + .max(63) + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + .optional(), + profile: worker.optional(), + publicPath: publicPathSchema.optional(), + attestation: z + .strictObject({ + signerWorkflow: z.literal( + 'LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml', + ), + signerCommit: z.string().regex(/^[a-f0-9]{40}$/), + legacyArtifacts: legacyArtifactsSchema.optional(), + }) + .optional(), + previewOnly: z.boolean().optional(), + publicationMode: z.enum(['version', 'named-staging']).optional(), + previewReadOnlyBindings: z + .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)) + .refine( + (names) => new Set(names).size === names.length, + 'Declare unique preview read-only bindings.', + ) + .optional(), + smoke: workerSmokeSchema.optional(), + previewBindings: z.record(z.string(), z.unknown()).optional(), + artifactSource: z.enum(['legacy-worker', 'cf-output', 'typed-worker']).optional(), + typedConfig: z.string().min(1).optional(), + assetsDirectory: z.string().min(1).optional(), + migrations: z + .array( + z.strictObject({ + binding: z.string().regex(/^[A-Z][A-Z0-9_]*$/), + directory: z.string().min(1), + }), + ) + .optional(), + artifactAcceptance: z + .object({ + forbiddenPaths: z.array(z.string().min(1)).optional(), + forbiddenLanguages: z.array(z.string().min(1)).optional(), + }) + .strict() + .optional(), + stagingWorkflow: z.object({ + name: z.string().min(1), + path: z.string().regex(/^\.github\/workflows\/[a-z0-9-]+\.ya?ml$/), + branch: z.string().min(1).default('main'), + }), + promotionWorkflow: z.object({ + file: z.string().regex(/^[a-z0-9-]+\.ya?ml$/), + titlePrefix: z.string().min(1), + branch: z.string().min(1), + }), + }) + .strict() + .refine( + (config) => + config.productionWorker !== config.previewWorker && + config.productionUrl !== config.previewUrl, + 'Staging and production require separate Workers and origins.', + ) + .refine( + (config) => config.artifactSource !== 'typed-worker' || Boolean(config.typedConfig), + 'Typed Worker releases require the canonical typedConfig.', + ); +export type ReleaseConfiguration = z.infer; +function configuredUrl( + value: Record, + field: string, + env: Record, +): unknown { + const selector = value[`${field}Env`]; + if (selector === undefined) return value[field]; + const name = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/) + .parse(selector); + const result = env[name]?.trim(); + if (!result) throw new Error(`Configure ${name} with the app's HTTPS origin before releasing.`); + return result; +} +async function repositoryOrigin( + cwd: string, + env: Record, +): Promise { + if (env.GITHUB_REPOSITORY) return env.GITHUB_REPOSITORY; + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const { stdout } = await promisify(execFile)('git', ['remote', 'get-url', 'origin'], { cwd }); + const remote = stdout.trim(); + const ssh = /^git@github\.com:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote); + if (ssh?.[1]) return ssh[1]; + const url = new URL(remote); + if ( + url.protocol !== 'https:' || + url.hostname !== 'github.com' || + url.username || + url.password || + url.search || + url.hash + ) + throw new Error('Configure a GitHub origin or an explicit release repository.'); + return url.pathname.slice(1).replace(/\.git$/, ''); +} +function configuredWorkersSubdomain( + value: Record, + env: Record, +) { + if (value.workersDevSubdomainEnv === undefined) return value.workersDevSubdomain; + if (value.workersDevSubdomain !== undefined) + throw new Error('Declare workersDevSubdomain or workersDevSubdomainEnv, not both.'); + const name = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/) + .parse(value.workersDevSubdomainEnv); + const label = env[name]?.trim(); + if (!label) + throw new Error(`Configure ${name} with the reviewed Workers account label before releasing.`); + return label; +} +async function resolveConfiguration( + cwd: string, + value: Record, + env: Record, +): Promise { + const productionUrl = configuredUrl(value, 'productionUrl', env); + const previewUrl = configuredUrl(value, 'previewUrl', env); + const workersDevSubdomain = configuredWorkersSubdomain(value, env); + const repository = value.repository ?? (await repositoryOrigin(cwd, env)); + let previewBindings = value.previewBindings; + if (value.previewBindingsEnv !== undefined) { + const name = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/) + .parse(value.previewBindingsEnv); + const input = env[name]?.trim(); + if (!input) + throw new Error( + `Configure ${name} with isolated preview binding declarations before releasing.`, + ); + previewBindings = JSON.parse(input) as unknown; + } + const { + productionUrlEnv: _productionUrlEnv, + previewUrlEnv: _previewUrlEnv, + previewBindingsEnv: _previewBindingsEnv, + workersDevSubdomainEnv: _workersDevSubdomainEnv, + ...release + } = value; + return releaseConfigurationSchema.parse({ + ...release, + repository, + productionUrl, + previewUrl, + workersDevSubdomain, + ...(previewBindings ? { previewBindings } : {}), + }); +} + +function assertProfiles(configs: ReleaseConfiguration[]): void { + for (const fields of [ + configs.map((config) => config.artifactPrefix), + configs.flatMap((config) => [config.productionWorker, config.previewWorker]), + configs.flatMap((config) => [productionEndpoint(config), config.previewUrl]), + ]) + if (new Set(fields).size !== fields.length) + throw new Error('Release apps require distinct artifacts, Worker namespaces, and origins.'); +} + +export async function readReleaseConfiguration( + cwd: string, + env: Record = process.env, + app: string | undefined = env.LVBT_RELEASE_APP, +): Promise { + const tooling = z + .object({ version: z.literal(1), release: z.record(z.string(), z.unknown()) }) + .parse(JSON.parse(await readFile(path.join(cwd, '.lvbt/tooling.json'), 'utf8'))); + const { apps, ...common } = tooling.release; + if (apps === undefined) { + if (app) throw new Error(`Unknown release app: ${app}.`); + return await resolveConfiguration(cwd, common, env); + } + const profiles = z.record(worker, z.record(z.string(), z.unknown())).parse(apps); + const configs = await Promise.all( + Object.entries(profiles).map(async ([profile, values]) => { + if ( + ['repository', 'stagingWorkflow', 'promotionWorkflow', 'profile', 'apps'].some( + (key) => key in values, + ) + ) + throw new Error('Release apps cannot override the repository or workflow identity.'); + return await resolveConfiguration(cwd, { ...common, ...values, profile }, env); + }), + ); + assertProfiles(configs); + if (!app) throw new Error('Select --app with a named release profile.'); + const selected = configs.find((config) => config.profile === app); + if (!selected) throw new Error(`Unknown release app: ${app}.`); + return selected; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-github.ts b/.lvbt/web-platform/packages/web-platform/src/release-github.ts new file mode 100644 index 0000000..c884cab --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-github.ts @@ -0,0 +1,11 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); +export async function github(args: string[]): Promise { + const { stdout } = await execute('gh', args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }); + return stdout; +} +export async function githubJson(args: string[]): Promise { + return JSON.parse(await github(args)); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-identity.ts b/.lvbt/web-platform/packages/web-platform/src/release-identity.ts new file mode 100644 index 0000000..eed592a --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-identity.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; +import { releaseMarkerPath } from './release-path.js'; +import { accessHeaders, type AccessCredentials } from './access-auth.js'; + +export const releaseIdentitySchema = z + .object({ + commit: z.string().regex(/^[a-f0-9]{40}$/), + app: z + .string() + .regex(/^[a-z0-9][a-z0-9-]*$/) + .optional(), + releaseId: z.string().regex(/^[1-9][0-9]*$/), + }) + .strict(); +export type ReleaseIdentity = z.infer; +interface ReadOptions { + publicPath?: string | undefined; + app?: string | undefined; + credentials?: AccessCredentials; + request?: ( + url: string, + options: { headers: Record; redirect: 'manual'; signal: AbortSignal }, + ) => Promise; + requestTimeoutMs?: number; +} +class TransientMarkerError extends Error {} + +export async function readReleaseIdentity( + origin: string, + options: ReadOptions = {}, +): Promise { + const url = `${new URL(origin).origin}${releaseMarkerPath(options.publicPath)}`; + let response: Response; + try { + response = await (options.request ?? fetch)(url, { + headers: accessHeaders(url, origin, options.credentials), + redirect: 'manual', + signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000), + }); + } catch { + throw new TransientMarkerError('Release marker request failed or timed out.'); + } + if ([301, 302, 303, 307, 308, 401, 403].includes(response.status)) + throw new Error(`Release marker authentication failed (${response.status}).`); + if (response.status === 404 || response.status === 429 || response.status >= 500) + throw new TransientMarkerError(`Release marker unavailable (${response.status}).`); + if (response.status !== 200) throw new Error(`Release marker unavailable (${response.status}).`); + let value: unknown; + try { + value = await response.json(); + } catch (error) { + if (error instanceof SyntaxError) + throw new Error('Release marker has an invalid identity.', { cause: error }); + throw new TransientMarkerError('Release marker body request failed or timed out.', { + cause: error, + }); + } + const identity = releaseIdentitySchema.safeParse(value); + if (!identity.success) throw new Error('Release marker has an invalid identity.'); + if (options.app && identity.data.app !== options.app) + throw new Error('Release marker belongs to another app.'); + return identity.data; +} + +function sameIdentity(actual: ReleaseIdentity, expected: ReleaseIdentity): boolean { + return ( + actual.commit === expected.commit && + actual.releaseId === expected.releaseId && + actual.app === expected.app + ); +} +export async function waitForReleaseIdentity( + origin: string, + expected: ReleaseIdentity, + options: ReadOptions & { + timeoutMs?: number; + intervalMs?: number; + now?: () => number; + sleep?: (ms: number) => Promise; + } = {}, +): Promise { + releaseIdentitySchema.parse(expected); + const now = options.now ?? Date.now; + const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const deadline = now() + (options.timeoutMs ?? 0); + let last: string; + for (;;) { + try { + const actual = await readReleaseIdentity(origin, { + ...options, + ...(expected.app ? { app: expected.app } : {}), + requestTimeoutMs: Math.max( + 1, + Math.min( + options.requestTimeoutMs ?? 10_000, + options.timeoutMs ? deadline - now() : 10_000, + ), + ), + }); + if (sameIdentity(actual, expected)) return actual; + last = `observed release ${actual.releaseId} at ${actual.commit}`; + } catch (error) { + if (!(error instanceof TransientMarkerError)) throw error; + last = error.message; + } + if (now() >= deadline) + throw new Error(`Expected release ${expected.releaseId} at ${expected.commit}; ${last}.`); + await sleep(Math.min(options.intervalMs ?? 5_000, deadline - now())); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-marker-assets.ts b/.lvbt/web-platform/packages/web-platform/src/release-marker-assets.ts new file mode 100644 index 0000000..9bd6a47 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-marker-assets.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { releaseMarkerPath } from './release-path.js'; +export function releaseMarkerAssets( + assets: Record, + publicPath?: string, +): Record { + const routing = z + .union([z.boolean(), z.array(z.string())]) + .optional() + .parse(assets.run_worker_first); + if (routing === true) return { ...assets }; + return { + ...assets, + run_worker_first: [ + ...new Set([ + ...(Array.isArray(routing) ? routing : []), + '/lvbt-release.json', + releaseMarkerPath(publicPath), + ]), + ], + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-path.ts b/.lvbt/web-platform/packages/web-platform/src/release-path.ts new file mode 100644 index 0000000..25c635e --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-path.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +export const publicPathSchema = z.string().regex(/^\/(?:[a-z0-9-]+\/)*$/); +export function releaseMarkerPath(publicPath = '/'): string { + const prefix = publicPathSchema.parse(publicPath); + return `${prefix}lvbt-release.json`; +} +export function productionEndpoint(config: { + productionUrl: string; + publicPath?: string | undefined; +}): string { + const prefix = publicPathSchema.parse(config.publicPath ?? '/'); + return `${config.productionUrl}${prefix === '/' ? '' : prefix}`; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-command.ts b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-command.ts new file mode 100644 index 0000000..eb74734 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-command.ts @@ -0,0 +1,132 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { ReleaseConfiguration } from './release-config.js'; +import { accessCredentials, accessFetch } from './access-auth.js'; +import { deployNamedSavedRelease } from './named-worker-release.js'; +import { + assertPrPreviewEvent, + namedPrConfiguration, + prPreviewConfiguration, + prPreviewArguments, +} from './release-pr-preview-config.js'; +import { packageTypedWorkerRelease } from './typed-worker-release-artifact.js'; +import { sealSavedRelease } from './saved-release-artifact.js'; +import { runReleaseMigrations } from './saved-release-migrations.js'; +import { verifyWorkerReleaseConfiguration } from './worker-release-configuration.js'; +import { runWorkerPreview } from './worker-preview-command.js'; +import { verifyWorkerReleaseSmoke } from './worker-release-smoke.js'; +import { deleteNamedPrPreview } from './release-pr-preview-delete.js'; +export { prPreviewConfiguration } from './release-pr-preview-config.js'; + +async function previewCredentials(config: ReleaseConfiguration, protection: string) { + if (protection === 'public') return undefined; + const anonymous = await accessFetch(config.previewUrl, config.previewUrl); + if (![302, 401, 403].includes(anonymous.status)) + throw new Error('Anonymous request reached PR preview.'); + await anonymous.arrayBuffer(); + const credentials = accessCredentials(process.env); + if (!credentials) throw new Error('Protected PR preview requires Access credentials.'); + return credentials; +} + +/** PR-only deployment: all saved bytes are private temporary inputs, never promotion artifacts. */ +export async function runPrPreview(config: ReleaseConfiguration, args: string[]): Promise { + const input = prPreviewArguments(args); + const mode = input.publicationMode; + if (mode !== (config.publicationMode ?? 'version')) + throw new Error('PR publication mode differs from the release configuration.'); + const selected = prPreviewConfiguration(config, input.number); + const identity = { + commit: input.commit, + releaseId: input.releaseId, + ...(config.profile ? { app: config.profile } : {}), + }; + await assertPrPreviewEvent(config, input); + if (input.action === 'resolve') { + const output = process.env.GITHUB_OUTPUT; + if (output) await writeFile(output, `url=${selected.previewUrl}\n`, { flag: 'a' }); + process.stdout.write(`${JSON.stringify({ url: selected.previewUrl })}\n`); + return; + } + if (input.action === 'delete') { + if (mode !== 'named-staging') throw new Error('Only named PR Workers support deletion.'); + await deleteNamedPrPreview(selected); + return; + } + if (mode === 'version') { + await runWorkerPreview(config, [ + '--alias', + `pr-${input.number}`, + '--env', + 'preview', + '--message', + `PR #${input.number} at ${input.commit}`, + ]); + return; + } + if (config.artifactSource !== 'typed-worker' || !config.smoke) + throw new Error('Named PR previews require typed-worker inputs and a declared API smoke.'); + await deployNamedPr(config, { selected, identity, protection: input.protection }); +} + +async function deployNamedPr( + config: ReleaseConfiguration, + input: { + selected: ReleaseConfiguration; + identity: { commit: string; releaseId: string; app?: string }; + protection: string; + }, +): Promise { + const credentials = await previewCredentials(input.selected, input.protection); + const smoke = config.smoke; + if (!smoke) throw new Error('Declare the PR API smoke.'); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-pr-preview-')); + try { + const directory = path.join(temporary, 'release'); + await packageTypedWorkerRelease(process.cwd(), directory, input.identity, config); + await verifyWorkerReleaseConfiguration(directory, config); + const file = path.join(directory, 'wrangler.jsonc'); + await writeFile( + file, + JSON.stringify( + namedPrConfiguration(JSON.parse(await readFile(file, 'utf8')), input.selected), + null, + 2, + ), + ); + await rm(path.join(directory, 'release.json')); + const release = await sealSavedRelease(directory, input.identity, 'worker', 2); + await verifyWorkerReleaseConfiguration(directory, input.selected); + await runReleaseMigrations(input.selected, [ + '--directory', + directory, + '--target', + 'preview', + '--commit', + input.identity.commit, + '--release-id', + input.identity.releaseId, + ]); + const receipt = await deployNamedSavedRelease(input.selected, directory, release, 'preview'); + await verifyWorkerReleaseSmoke({ + origin: input.selected.previewUrl, + identity: input.identity, + smoke, + publicPath: config.publicPath, + protected: input.protection === 'access', + credentials, + timeoutMs: 180_000, + }); + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile( + output, + `url=${input.selected.previewUrl}\nversion=${receipt.version}\nprotected=${input.protection === 'access'}\n`, + { flag: 'a' }, + ); + process.stdout.write(`${JSON.stringify({ url: input.selected.previewUrl, ...receipt })}\n`); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-config.ts b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-config.ts new file mode 100644 index 0000000..37410e2 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-config.ts @@ -0,0 +1,126 @@ +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; + +export function prPreviewArguments(args: string[]) { + const { values } = parseArgs({ + args, + options: { + pr: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + 'publication-mode': { type: 'string' }, + protection: { type: 'string' }, + action: { type: 'string', default: 'deploy' }, + }, + }); + return z + .strictObject({ + number: z.string().regex(/^[1-9][0-9]{0,9}$/), + commit: z.string().regex(/^[a-f0-9]{40}$/), + releaseId: z.string().regex(/^[1-9][0-9]*$/), + publicationMode: z.enum(['version', 'named-staging']), + protection: z.enum(['public', 'access']), + action: z.enum(['deploy', 'delete', 'resolve']), + }) + .parse({ + number: values.pr, + commit: values.commit, + releaseId: values['release-id'], + publicationMode: values['publication-mode'], + protection: values.protection, + action: values.action, + }); +} + +export function prPreviewConfiguration( + config: ReleaseConfiguration, + number: string, +): ReleaseConfiguration { + if (!/^[1-9][0-9]{0,9}$/.test(number)) throw new Error('Use a positive pull request number.'); + const worker = `${config.productionWorker}-pr-${number}`; + if (worker.length > 63 || !config.workersDevSubdomain) + throw new Error('Configure a reviewed Workers account and a bounded PR Worker name.'); + const { attestation: _attestation, ...preview } = config; + return { + ...preview, + previewOnly: true, + publicationMode: 'version', + previewWorker: worker, + previewUrl: `https://${worker}.${config.workersDevSubdomain}.workers.dev`, + }; +} + +export async function assertPrPreviewEvent( + config: ReleaseConfiguration, + input: { number: string; commit: string; releaseId: string; action: string }, +): Promise { + if ( + process.env.GITHUB_EVENT_NAME !== 'pull_request' || + process.env.GITHUB_REPOSITORY !== config.repository || + process.env.GITHUB_SHA !== input.commit || + process.env.GITHUB_RUN_ID !== input.releaseId || + !process.env.GITHUB_EVENT_PATH + ) + throw new Error('PR previews require the matching GitHub pull_request run and repository.'); + const event = z + .object({ + action: z.string(), + number: z.number().int().positive(), + pull_request: z.object({ + head: z.object({ repo: z.object({ full_name: z.literal(config.repository) }) }), + base: z.object({ + ref: z.literal(config.stagingWorkflow.branch), + repo: z.object({ full_name: z.literal(config.repository) }), + }), + }), + }) + .parse(JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf8'))); + if (String(event.number) !== input.number) + throw new Error('PR preview number does not match the trusted event.'); + if ((event.action === 'closed') !== (input.action === 'delete')) + throw new Error('Deletion requires a closed PR; closed PRs cannot deploy previews.'); +} + +export function namedPrConfiguration(value: unknown, config: ReleaseConfiguration): unknown { + const saved = z + .object({ env: z.object({ preview: z.record(z.string(), z.unknown()) }).loose() }) + .loose() + .parse(value); + const preview = saved.env.preview; + const durable = + preview.durable_objects === undefined + ? undefined + : z + .object({ + bindings: z.array(z.object({ script_name: z.string().optional() }).loose()), + }) + .loose() + .parse(preview.durable_objects); + return { + ...saved, + env: { + preview: { + ...preview, + name: config.previewWorker, + routes: [], + route: undefined, + workers_dev: true, + preview_urls: true, + triggers: { crons: [] }, + ...(durable + ? { + durable_objects: { + ...durable, + bindings: durable.bindings.map((binding) => ({ + ...binding, + script_name: config.previewWorker, + })), + }, + } + : {}), + }, + }, + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-delete.ts b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-delete.ts new file mode 100644 index 0000000..462cbea --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-pr-preview-delete.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; + +/** Delete only a derived PR Worker after checking the authenticated account's public identity. */ +export async function deleteNamedPrPreview(config: ReleaseConfiguration): Promise { + const account = z + .string() + .regex(/^[a-f0-9]{32}$/) + .parse(process.env.CLOUDFLARE_ACCOUNT_ID); + const token = z.string().min(1).parse(process.env.CLOUDFLARE_API_TOKEN); + const base = `https://api.cloudflare.com/client/v4/accounts/${account}/workers`; + const options = { + headers: { Authorization: `Bearer ${token}` }, + redirect: 'error' as const, + signal: AbortSignal.timeout(30_000), + }; + const identity = await fetch(`${base}/subdomain`, options); + if (!identity.ok) throw new Error('Cannot verify the preview Cloudflare account.'); + const actual = z + .object({ success: z.literal(true), result: z.object({ subdomain: z.string() }) }) + .parse(await identity.json()); + if (actual.result.subdomain !== config.workersDevSubdomain) + throw new Error('Refusing deletion outside the reviewed Workers account.'); + const response = await fetch(`${base}/scripts/${config.previewWorker}?force=true`, { + ...options, + method: 'DELETE', + }); + if (response.status === 404) return; + const result = z + .object({ success: z.boolean(), errors: z.array(z.object({ code: z.number() })).optional() }) + .parse(await response.json()); + if (response.ok && result.success) return; + if (result.errors?.some((error) => error.code === 10007)) return; + throw new Error( + `PR preview deletion failed (${response.status}); reconcile the selected Worker before retrying.`, + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-smoke-command.ts b/.lvbt/web-platform/packages/web-platform/src/release-smoke-command.ts new file mode 100644 index 0000000..5b6b469 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-smoke-command.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { parseArgs } from 'node:util'; +import type { BrowserType } from 'playwright-core'; +import { accessCredentials, accessFetch } from './access-auth.js'; +import { runWorkerReleaseSmoke, validateWorkerSmokeOrigin } from './worker-release-smoke.js'; +import { scopeBrowserAccess } from './release-browser.js'; +import { productionEndpoint } from './release-path.js'; +import { waitForReleaseIdentity } from './release-identity.js'; +import type { ReleaseConfiguration } from './release-config.js'; + +function smokeOrigin( + config: ReleaseConfiguration, + url: string | undefined, + checks: { publicCheck: boolean; protectedCheck: boolean }, +): string { + if (!url) throw new Error('Pass --url with an HTTPS origin.'); + const parsed = new URL(url); + if (parsed.protocol !== 'https:' || parsed.pathname !== '/' || parsed.search || parsed.hash) + throw new Error('Pass an HTTPS origin without a path.'); + const origin = parsed.origin; + if (checks.publicCheck && origin !== config.productionUrl) + throw new Error('Public checks require the configured production origin.'); + if (checks.protectedCheck) validateWorkerSmokeOrigin(origin, config, true); + return origin; +} + +export async function runReleaseSmoke( + config: ReleaseConfiguration, + chromium: Pick, + args: string[] = process.argv.slice(2), +): Promise { + if (config.smoke) { + await runWorkerReleaseSmoke(config, args); + return; + } + const { values } = parseArgs({ + args, + options: { + url: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + protected: { type: 'boolean', default: false }, + public: { type: 'boolean', default: false }, + 'wait-for-propagation': { type: 'boolean', default: false }, + }, + }); + const origin = smokeOrigin(config, values.url, { + publicCheck: values.public, + protectedCheck: values.protected, + }); + const credentials = values.protected ? accessCredentials(process.env) : undefined; + if (values.protected) { + if (!credentials) + throw new Error('Protected staging verification requires Access credentials.'); + assert.ok( + [302, 401, 403].includes((await accessFetch(`${origin}/`, origin)).status), + 'Anonymous request reached staging.', + ); + } + if (!values.commit || !values['release-id']) + throw new Error('Pass both --commit and --release-id.'); + await waitForReleaseIdentity( + origin, + { + commit: values.commit, + releaseId: values['release-id'], + ...(config.profile ? { app: config.profile } : {}), + }, + { + ...(credentials ? { credentials } : {}), + publicPath: origin === config.productionUrl ? config.publicPath : '/', + timeoutMs: values['wait-for-propagation'] ? 180_000 : 0, + }, + ); + await renderReleaseSmoke(chromium, { + origin, + endpoint: origin === config.productionUrl ? productionEndpoint(config) : origin, + credentials, + protectedOrigin: values.protected, + releaseId: values['release-id'], + }); +} + +async function renderReleaseSmoke( + chromium: Pick, + { + origin, + endpoint, + credentials, + protectedOrigin, + releaseId, + }: { + origin: string; + endpoint: string; + credentials: ReturnType; + protectedOrigin: boolean; + releaseId: string; + }, +): Promise { + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ serviceWorkers: 'block' }); + try { + await scopeBrowserAccess(context, origin, credentials); + const page = await context.newPage(); + const response = await page.goto(endpoint, { waitUntil: 'networkidle' }); + assert.equal(response?.status(), 200, 'The app did not render.'); + assert.equal(new URL(page.url()).origin, origin, 'The browser left the configured origin.'); + assert.ok(await page.locator('main').isVisible(), 'The app main content is not visible.'); + if (protectedOrigin) assert.match(response.headers()['x-robots-tag'] ?? '', /noindex/); + process.stdout.write(`PASS: rendered ${origin}, release ${releaseId}\n`); + } finally { + await context.unrouteAll({ behavior: 'wait' }); + } + } finally { + await browser.close(); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release-source.ts b/.lvbt/web-platform/packages/web-platform/src/release-source.ts new file mode 100644 index 0000000..f0cb8ec --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/release-source.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +const sourceSchema = z.object({ + id: z.number().int().positive(), + run_attempt: z.number().int().positive(), + name: z.string(), + path: z.string(), + event: z.enum(['push', 'workflow_dispatch']), + head_branch: z.string(), + head_sha: z.string().regex(/^[a-f0-9]{40}$/), + status: z.literal('completed'), + conclusion: z.literal('success'), + repository: z.object({ full_name: z.string() }), + head_repository: z.object({ full_name: z.string() }), +}); + +export interface StagingWorkflow { + name: string; + path: string; + branch?: string; +} +export const legacyStagingWorkflow: StagingWorkflow = { + name: 'Deploy staging', + path: '.github/workflows/deploy-production.yml', + branch: 'main', +}; + +export function releaseSource( + value: unknown, + repository: string, + runId: string, + workflow: StagingWorkflow = legacyStagingWorkflow, +): { commit: string; releaseId: string } { + if (!/^[1-9][0-9]*$/.test(runId)) throw new Error('Select an Actions run ID.'); + const source = sourceSchema.parse(value); + if ( + source.name !== workflow.name || + source.path !== workflow.path || + source.head_branch !== (workflow.branch ?? 'main') || + String(source.id) !== runId || + source.repository.full_name !== repository || + source.head_repository.full_name !== repository + ) + throw new Error('The source run must belong to this repository.'); + return { commit: source.head_sha, releaseId: String(source.id) }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/release.ts b/.lvbt/web-platform/packages/web-platform/src/release.ts index 7d81aa5..f687a0e 100644 --- a/.lvbt/web-platform/packages/web-platform/src/release.ts +++ b/.lvbt/web-platform/packages/web-platform/src/release.ts @@ -9,3 +9,31 @@ export type { PreviewOperations, PreviewReceipt } from './pr-preview.js'; export { uploadPreview } from './pr-preview-upload.js'; export { sealArtifact, verifyReleaseResponse } from './release-artifact.js'; export type { ReleaseMarker } from './release-artifact.js'; + +export * from './saved-release-artifact.js'; +export * from './release-source.js'; +export * from './resolve-release.js'; +export * from './promotion-request.js'; +export * from './release-identity.js'; +export * from './access-auth.js'; +export * from './publication.js'; +export * from './release-config.js'; +export * from './worker-release-command.js'; +export * from './promote-command.js'; +export * from './record-publication-command.js'; +export * from './worker-preview-command.js'; +export * from './cf-release-artifact.js'; +export * from './release-browser.js'; +export * from './release-smoke-command.js'; +export * from './worker-release-configuration.js'; +export * from './cf-worker-release-artifact.js'; +export * from './worker-release-smoke.js'; +export * from './typed-worker-config.js'; +export * from './typed-worker-release-artifact.js'; +export * from './saved-release-migrations.js'; +export * from './legacy-worker-release-artifact.js'; +export * from './typed-worker-compatibility.js'; +export { isolatedPreviewBindings, type WorkerBindings } from './worker-bindings.js'; +export * from './release-path.js'; +export * from './release-attestation.js'; +export * from './release-pr-preview-command.js'; diff --git a/.lvbt/web-platform/packages/web-platform/src/resolve-release.ts b/.lvbt/web-platform/packages/web-platform/src/resolve-release.ts new file mode 100644 index 0000000..8d2ed43 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/resolve-release.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { releaseSource, type StagingWorkflow } from './release-source.js'; +import type { ReleaseIdentity } from './release-identity.js'; + +interface SourceReader { + previewIdentity: () => Promise; + getRun: (id: string) => Promise; + getArtifacts: (id: string) => Promise; + now?: () => number; + sleep?: (ms: number) => Promise; + timeoutMs?: number; +} +const artifactsSchema = z.object({ + artifacts: z.array(z.object({ name: z.string(), expired: z.boolean() })), +}); + +async function completedRun( + selected: string, + wait: boolean, + reader: SourceReader, +): Promise { + const now = reader.now ?? Date.now; + const sleep = reader.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const deadline = now() + (reader.timeoutMs ?? 600_000); + let value = await reader.getRun(selected); + while (wait && z.object({ status: z.string() }).parse(value).status !== 'completed') { + if (now() >= deadline) + throw new Error(`Staging run ${selected} did not finish in time. Nothing was published.`); + await sleep(Math.min(5_000, deadline - now())); + value = await reader.getRun(selected); + } + return value; +} + +export async function resolveRelease( + repository: string, + runId: string | undefined, + reader: SourceReader, + policy: { artifactPrefix?: string; stagingWorkflow?: StagingWorkflow } = {}, +): Promise { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) + throw new Error('Pass the configured owner/repository.'); + const preview = runId ? undefined : await reader.previewIdentity(); + const selected = runId ?? preview?.releaseId; + if (!selected || !/^[1-9][0-9]*$/.test(selected)) throw new Error('Select an Actions run ID.'); + // Preview activation precedes the final staging check. Pin this identity while that run finishes. + const value = await completedRun(selected, Boolean(preview), reader); + const source = releaseSource(value, repository, selected, policy.stagingWorkflow); + if (preview && source.commit !== preview.commit) + throw new Error('Preview identity does not match its staging run.'); + const artifacts = artifactsSchema.parse(await reader.getArtifacts(selected)); + const matches = artifacts.artifacts.filter( + (artifact) => artifact.name === `${policy.artifactPrefix ?? 'website-release'}-${selected}`, + ); + if (matches.length !== 1 || matches[0]?.expired) + throw new Error(`Saved release ${selected} is missing or expired.`); + return source; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/saved-release-artifact.ts b/.lvbt/web-platform/packages/web-platform/src/saved-release-artifact.ts new file mode 100644 index 0000000..024e869 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/saved-release-artifact.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import { cp, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; + +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const identitySchema = z + .object({ + commit: z.string().regex(/^[a-f0-9]{40}$/), + app: z + .string() + .regex(/^[a-z0-9][a-z0-9-]*$/) + .optional(), + releaseId: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/), + }) + .strict(); +const releaseSchema = identitySchema + .extend({ + formatVersion: z.union([z.literal(1), z.literal(2)]), + artifactHash: digest, + artifactKind: z.literal('worker').optional(), + files: z.array(z.tuple([z.string().min(1), digest])), + }) + .strict(); +export type WebsiteRelease = z.infer; +export type SavedReleaseIdentity = z.infer; + +function hash(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +async function inventory(directory: string, relative = ''): Promise<[string, string][]> { + const stat = await lstat(path.join(directory, relative)); + if (stat.isSymbolicLink()) throw new Error('Release content contains a symbolic link.'); + if (stat.isFile()) return [[relative, hash(await readFile(path.join(directory, relative)))]]; + if (!stat.isDirectory()) throw new Error('Unsupported release file type.'); + const files: [string, string][] = []; + for (const entry of await readdir(path.join(directory, relative))) { + if (relative === '' && entry === 'release.json') continue; + const child = relative ? `${relative}/${entry}` : entry; + files.push(...(await inventory(directory, child))); + } + return files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); +} + +function artifactHash( + identity: SavedReleaseIdentity & { artifactKind?: 'worker' | undefined; formatVersion?: 1 | 2 }, + files: [string, string][], +): string { + return hash( + JSON.stringify({ + formatVersion: identity.formatVersion ?? 1, + commit: identity.commit, + releaseId: identity.releaseId, + ...(identity.app ? { app: identity.app } : {}), + ...(identity.artifactKind ? { artifactKind: identity.artifactKind } : {}), + files, + }), + ); +} + +export interface ArtifactAcceptance { + forbiddenPaths?: string[] | undefined; + forbiddenLanguages?: string[] | undefined; +} + +async function acceptAssets( + source: string, + assets: [string, string][], + acceptance: ArtifactAcceptance, +): Promise { + const roots = await readdir(path.join(source, 'dist')); + for (const forbidden of acceptance.forbiddenPaths ?? []) { + if ( + roots.includes(forbidden) || + assets.some(([file]) => file === forbidden || file.startsWith(`${forbidden}/`)) + ) + throw new Error('Release contains preview-only routes.'); + } + for (const [name] of assets.filter(([file]) => file.endsWith('.html'))) { + const content = await readFile(path.join(source, 'dist', name), 'utf8'); + if ( + (acceptance.forbiddenLanguages ?? []).some((language) => + content.includes(`lang="${language}"`), + ) + ) + throw new Error('Release contains a preview-only test language.'); + } +} +export interface SavedReleaseOptions extends ArtifactAcceptance { + artifactKind?: 'worker'; + formatVersion?: 1 | 2; +} +export async function packageRelease( + source: string, + destination: string, + identity: SavedReleaseIdentity, + acceptance: SavedReleaseOptions = {}, +): Promise { + identitySchema.parse(identity); + const assets = await inventory(path.join(source, 'dist')); + await inventory(path.join(source, '.wrangler/worker')); + const config = await lstat(path.join(source, 'wrangler.jsonc')); + if (config.isSymbolicLink() || !config.isFile()) + throw new Error('Invalid release configuration.'); + await acceptAssets(source, assets, acceptance); + await mkdir(destination, { recursive: false }); + await cp(path.join(source, 'dist'), path.join(destination, 'dist'), { recursive: true }); + await mkdir(path.join(destination, '.wrangler')); + await cp(path.join(source, '.wrangler/worker'), path.join(destination, '.wrangler/worker'), { + recursive: true, + }); + await cp(path.join(source, 'wrangler.jsonc'), path.join(destination, 'wrangler.jsonc')); + await writeFile( + path.join(destination, 'dist/lvbt-release.json'), + `${JSON.stringify(identity)}\n`, + ); + return await sealSavedRelease( + destination, + identity, + acceptance.artifactKind, + acceptance.formatVersion ?? 1, + ); +} + +export async function sealSavedRelease( + directory: string, + identity: SavedReleaseIdentity, + artifactKind?: 'worker', + formatVersion: 1 | 2 = 1, +): Promise { + identitySchema.parse(identity); + const files = await inventory(directory); + if ( + (!artifactKind && !files.some(([name]) => name === 'dist/index.html')) || + !files.some(([name]) => name === '.wrangler/worker/index.js') || + !files.some(([name]) => name === 'wrangler.jsonc') + ) + throw new Error('Release requires its entry point and compiled Worker.'); + const taggedIdentity = { ...identity, formatVersion, ...(artifactKind ? { artifactKind } : {}) }; + const release: WebsiteRelease = { + ...taggedIdentity, + files, + artifactHash: artifactHash(taggedIdentity, files), + }; + await writeFile(path.join(directory, 'release.json'), `${JSON.stringify(release, null, 2)}\n`, { + flag: 'wx', + }); + return release; +} + +export async function verifyRelease(directory: string): Promise { + const marker = await lstat(path.join(directory, 'release.json')); + if (marker.isSymbolicLink() || !marker.isFile()) throw new Error('Invalid release manifest.'); + const release = releaseSchema.parse( + JSON.parse(await readFile(path.join(directory, 'release.json'), 'utf8')), + ); + const files = await inventory(directory); + if ( + JSON.stringify(files) !== JSON.stringify(release.files) || + artifactHash(release, files) !== release.artifactHash + ) + throw new Error('Release identity or files do not match the reviewed artifact.'); + if ( + (!release.artifactKind && !files.some(([name]) => name === 'dist/index.html')) || + !files.some(([name]) => name === '.wrangler/worker/index.js') || + !files.some(([name]) => name === 'wrangler.jsonc') + ) + throw new Error('Release is incomplete.'); + return release; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/saved-release-migrations.ts b/.lvbt/web-platform/packages/web-platform/src/saved-release-migrations.ts new file mode 100644 index 0000000..325db0b --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/saved-release-migrations.ts @@ -0,0 +1,326 @@ +import { verifyExpectedProductionVersion } from './expected-production-version.js'; +import { execFile } from 'node:child_process'; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs, promisify } from 'node:util'; +import { parse, type ParseError } from 'jsonc-parser'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { verifyReleaseAttestation } from './release-attestation.js'; +import { verifyCandidateReceipt } from './release-candidate.js'; +import { verifyNamedPreview, assertNamedPreviewProtection } from './named-worker-release.js'; +import { verifyRelease } from './saved-release-artifact.js'; +import { verifyWorkerReleaseConfiguration } from './worker-release-configuration.js'; + +const execute = promisify(execFile); +const subtree = '.wrangler/worker/migrations'; +const manifestFile = `${subtree}/manifest.json`; +const bindingName = z.string().regex(/^[A-Z][A-Z0-9_]*$/); +const sqlName = z + .string() + .regex(/^[^/\\]+\.sql$/) + .refine((name) => !name.startsWith('.')); +const manifestSchema = z.strictObject({ + version: z.literal(1), + bindings: z + .array(z.strictObject({ binding: bindingName, files: z.array(sqlName).min(1) })) + .min(1), +}); +const databaseSchema = z + .object({ + binding: z.string(), + database_id: z.string().min(1), + migrations_dir: z.string().optional(), + migrations_pattern: z.string().optional(), + }) + .loose(); +const savedConfigurationSchema = z + .object({ + d1_databases: z.array(databaseSchema), + env: z.object({ preview: z.object({ d1_databases: z.array(databaseSchema) }).loose() }).loose(), + }) + .loose(); +async function readConfiguration(directory: string) { + const errors: ParseError[] = []; + const value: unknown = parse( + await readFile(path.join(directory, 'wrangler.jsonc'), 'utf8'), + errors, + { allowTrailingComma: true }, + ); + if (errors.length) throw new Error('Invalid reviewed Wrangler configuration.'); + return savedConfigurationSchema.parse(value); +} +function unique(names: string[], description: string): void { + if (new Set(names).size !== names.length) throw new Error(`Duplicate ${description}.`); +} +async function reviewedDirectory(source: string, directory: string, appDirectory: string) { + if (path.isAbsolute(directory) || path.win32.isAbsolute(directory) || directory.includes('\\')) + throw new Error( + 'Migration directories must be relative to the app inside the reviewed repository.', + ); + const app = await realpath(source); + const root = await realpath( + path.resolve( + app, + ...appDirectory + .split('/') + .filter((part) => part !== '.') + .map(() => '..'), + ), + ); + const target = path.resolve(app, directory); + const relative = path.relative(root, target); + if ( + relative === '' || + relative.startsWith(`..${path.sep}`) || + relative === '..' || + path.isAbsolute(relative) + ) + throw new Error('Migration directories must stay inside the reviewed repository.'); + let current = root; + for (const part of relative.split(path.sep)) { + current = path.join(current, part); + const entry = await lstat(current); + if (entry.isSymbolicLink() || !entry.isDirectory()) + throw new Error('Migration directories cannot contain symlinks or non-directories.'); + } + return target; +} +function selectedDatabase(databases: z.infer[], binding: string) { + const matching = databases.filter((database) => database.binding === binding); + if (matching.length !== 1 || !matching[0]) + throw new Error( + `Saved configuration must contain exactly one ${binding} D1 binding per target.`, + ); + return matching[0]; +} + +async function migrationFiles(directory: string): Promise { + const files: string[] = []; + for (const name of (await readdir(directory)).sort()) { + const entry = await lstat(path.join(directory, name)); + if (entry.isSymbolicLink() || !entry.isFile()) + throw new Error( + 'Use regular SQL files in a flat migration directory; symlinks and nested directories are unsupported.', + ); + if (name.endsWith('.sql')) files.push(sqlName.parse(name)); + } + if (!files.length) throw new Error('No SQL migrations found in the declared directory.'); + return files; +} +function retainDatabasePaths( + saved: z.infer, + binding: string, +): void { + for (const databases of [saved.d1_databases, saved.env.preview.d1_databases]) { + const database = selectedDatabase(databases, binding); + if ( + database.migrations_pattern && + database.migrations_pattern !== `${database.migrations_dir ?? 'migrations'}/*.sql` + ) + throw new Error('Saved release migrations require a flat SQL migrations_pattern.'); + database.migrations_dir = `${subtree}/${binding}`; + if (database.migrations_pattern) + database.migrations_pattern = `${database.migrations_dir}/*.sql`; + } +} + +/** Freeze declared SQL before the existing release inventory is sealed. */ +export async function retainReleaseMigrations( + source: string, + destination: string, + config: ReleaseConfiguration, +): Promise { + if (!config.migrations?.length) return; + unique( + config.migrations.map((migration) => bindingName.parse(migration.binding)), + 'migration bindings', + ); + await verifyWorkerReleaseConfiguration(destination, config); + const saved = await readConfiguration(destination); + const retained = []; + // Validate all declarations before modifying the packaging directory. + for (const migration of config.migrations) { + const directory = await reviewedDirectory(source, migration.directory, config.appDirectory); + const files = await migrationFiles(directory); + retainDatabasePaths(saved, migration.binding); + retained.push({ binding: migration.binding, directory, files }); + } + for (const migration of retained) { + const target = path.join(destination, subtree, migration.binding); + await mkdir(target, { recursive: true }); + for (const file of migration.files) + await cp(path.join(migration.directory, file), path.join(target, file)); + } + await writeFile( + path.join(destination, manifestFile), + `${JSON.stringify({ version: 1, bindings: retained.map(({ binding, files }) => ({ binding, files })) }, null, 2)}\n`, + ); + await writeFile(path.join(destination, 'wrangler.jsonc'), `${JSON.stringify(saved, null, 2)}\n`); +} + +async function verifiedManifest( + directory: string, + release: Awaited>, +) { + const manifest = manifestSchema.parse( + JSON.parse(await readFile(path.join(directory, manifestFile), 'utf8')), + ); + unique( + manifest.bindings.map(({ binding }) => binding), + 'saved migration bindings', + ); + const saved = await readConfiguration(directory); + for (const migration of manifest.bindings) { + unique(migration.files, 'saved migration filenames'); + const relative = `${subtree}/${migration.binding}`; + for (const databases of [saved.d1_databases, saved.env.preview.d1_databases]) { + const database = selectedDatabase(databases, migration.binding); + if ( + database.migrations_dir !== relative || + (database.migrations_pattern && database.migrations_pattern !== `${relative}/*.sql`) + ) + throw new Error('Saved migration directories must match the sealed SQL inventory.'); + } + const actual = release.files + .filter(([file]) => file.startsWith(`${relative}/`)) + .map(([file]) => file.slice(relative.length + 1)) + .sort(); + if (JSON.stringify(actual) !== JSON.stringify([...migration.files].sort())) + throw new Error('Saved migration SQL does not match the sealed declaration.'); + } + return manifest; +} +async function applyMigrations( + directory: string, + manifest: z.infer, + target: string, + artifactHash: string, +): Promise { + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-migrate-')); + try { + const copy = path.join(temporary, 'release'); + await cp(directory, copy, { recursive: true }); + // Reverify the working copy before invoking Wrangler, which may write cache files. + if ((await verifyRelease(copy)).artifactHash !== artifactHash) + throw new Error('Saved release changed while preparing migrations.'); + for (const { binding } of manifest.bindings) { + try { + const { stdout } = await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'd1', + 'migrations', + 'apply', + binding, + '--remote', + '--config', + path.join(copy, 'wrangler.jsonc'), + '--env', + target === 'preview' ? 'preview' : '', + ], + { + env: { ...process.env, CI: 'true', WRANGLER_LOG_SANITIZE: 'true' }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + process.stdout.write(stdout); + } catch (cause) { + throw new Error( + `Migration apply failed for ${binding}; reconcile the selected database's applied migrations before retrying or promoting. Earlier migrations may already have succeeded.`, + { cause }, + ); + } + } + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +async function verifyMigrationAuthority( + config: ReleaseConfiguration, + input: { + directory: string; + target: string; + attestation?: string | undefined; + candidate?: string | undefined; + }, + release: Awaited>, +): Promise { + await verifyReleaseAttestation(config, input.directory, input.attestation); + await verifyWorkerReleaseConfiguration(input.directory, config); + if (input.target === 'production' && config.previewOnly) + throw new Error('Preview-only apps cannot migrate production.'); + if (config.publicationMode !== 'named-staging') return; + if (input.target === 'preview') await assertNamedPreviewProtection(config); + else if (input.candidate) await verifyCandidateReceipt(config, release, input.candidate); + else await verifyNamedPreview(config, release); +} +/** Apply only SQL and database identities contained in a verified saved release. */ +export async function runReleaseMigrations( + config: ReleaseConfiguration, + args: string[], +): Promise { + const { values } = parseArgs({ + args, + options: { + directory: { type: 'string' }, + target: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + 'attestation-directory': { type: 'string' }, + 'candidate-directory': { type: 'string' }, + 'expected-version': { type: 'string' }, + }, + }); + if (!values.directory) throw new Error('Pass --directory with the saved release.'); + if (values.target !== 'preview' && values.target !== 'production') + throw new Error('Pass --target preview or production.'); + const directory = path.resolve(values.directory); + const release = await verifyRelease(directory); + if (release.app !== config.profile) throw new Error('Saved release belongs to another app.'); + if (values.commit && release.commit !== values.commit) + throw new Error('Release commit does not match the selected Actions run.'); + if (values['release-id'] && release.releaseId !== values['release-id']) + throw new Error('Release ID does not match the selected Actions run.'); + await verifyMigrationAuthority( + config, + { + directory, + target: values.target, + attestation: values['attestation-directory'], + candidate: values['candidate-directory'], + }, + release, + ); + if (values.target === 'production') + await verifyExpectedProductionVersion(config, values['expected-version'], directory); + if (!release.files.some(([file]) => file === manifestFile)) { + // Original retained releases did not carry SQL. Preserve their reviewed no-migration behavior. + if (release.formatVersion === 1) return; + if (config.migrations?.length) + throw new Error( + 'This release has no saved migrations; package a new reviewed release with release.migrations.', + ); + return; + } + await applyMigrations( + directory, + await verifiedManifest(directory, release), + values.target, + release.artifactHash, + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/typed-worker-compatibility.ts b/.lvbt/web-platform/packages/web-platform/src/typed-worker-compatibility.ts new file mode 100644 index 0000000..bed85b9 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/typed-worker-compatibility.ts @@ -0,0 +1,74 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { readTypedWorkerConfiguration, reviewedReleasePath } from './typed-worker-input.js'; + +async function relocateConfiguration( + source: string, + destination: string, + value: Record, + config: ReleaseConfiguration, +): Promise> { + const result = { ...value }; + if (result.assets) { + if (!config.assetsDirectory) + throw new Error('Declare assetsDirectory for the canonical Worker assets.'); + const assets = await reviewedReleasePath( + source, + config.assetsDirectory, + config.appDirectory, + false, + ); + result.assets = { + ...z.record(z.string(), z.unknown()).parse(result.assets), + directory: path.relative(destination, assets), + }; + } + const databases = z.array(z.record(z.string(), z.unknown())).parse(result.d1_databases ?? []); + for (const migration of config.migrations ?? []) { + const database = databases.find((entry) => entry.binding === migration.binding); + if (!database) + throw new Error( + `Migration binding ${migration.binding} is absent from canonical configuration.`, + ); + const directory = await reviewedReleasePath( + source, + migration.directory, + config.appDirectory, + false, + ); + database.migrations_dir = path.relative(destination, directory); + } + if (databases.length) result.d1_databases = databases; + return result; +} +export async function generateTypedWorkerCompatibility( + source: string, + destinationDirectory: string, + config: ReleaseConfiguration, +): Promise> { + const destination = await reviewedReleasePath( + source, + path.relative(source, destinationDirectory), + config.appDirectory, + ); + const { canonical, generated } = await readTypedWorkerConfiguration(source, config); + const main = await reviewedReleasePath( + source, + path.relative(source, path.resolve(path.dirname(canonical), z.string().parse(generated.main))), + config.appDirectory, + false, + ); + const production = await relocateConfiguration(source, destination, generated, config); + const preview = z + .object({ preview: z.record(z.string(), z.unknown()) }) + .parse(generated.env).preview; + const result = { + ...production, + main: path.relative(destination, main), + env: { preview: await relocateConfiguration(source, destination, preview, config) }, + }; + await writeFile(path.join(destination, 'wrangler.jsonc'), JSON.stringify(result, null, 2) + '\n'); + return result; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/typed-worker-config.ts b/.lvbt/web-platform/packages/web-platform/src/typed-worker-config.ts new file mode 100644 index 0000000..48fe505 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/typed-worker-config.ts @@ -0,0 +1,202 @@ +import { z } from 'zod'; +import { releaseMarkerAssets } from './release-marker-assets.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { + isolatedPreviewBindings, + workerBindingsSchema, + wranglerBindings, + type WorkerBindings, +} from './worker-bindings.js'; +const trigger = z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('fetch'), pattern: z.string(), zone: z.string() }), + z.strictObject({ type: z.literal('scheduled'), schedule: z.string() }), +]); +const worker = z.strictObject({ + name: z.string(), + entrypoint: z.string(), + compatibilityDate: z.string(), + compatibilityFlags: z.array(z.string()).optional(), + observability: z.record(z.string(), z.unknown()).optional(), + logpush: z.boolean().optional(), + tailConsumers: z.array(z.record(z.string(), z.unknown())).optional(), + assets: z + .strictObject({ + htmlHandling: z.string().optional(), + notFoundHandling: z.string().optional(), + runWorkerFirst: z.union([z.boolean(), z.array(z.string())]).optional(), + }) + .optional(), + triggers: z.array(trigger).default([]), + domains: z.array(z.string()).default([]), + exports: z + .record( + z.string(), + z.strictObject({ type: z.literal('durable-object'), storage: z.literal('sqlite') }), + ) + .optional(), + env: workerBindingsSchema, + workersDev: z.boolean().optional(), + previewUrls: z.boolean().optional(), + unsafe: z + .strictObject({ + metadata: z.strictObject({ keep_bindings: z.array(z.enum(['secret_text', 'secret_key'])) }), + }) + .optional(), +}); +function snakeSettings(value: unknown): unknown { + if (Array.isArray(value)) return value.map(snakeSettings); + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), + snakeSettings(child), + ]), + ); + return value; +} +export async function resolveTypedConfiguration(input: unknown, preview = false): Promise { + const value: unknown = await input; + if (typeof value !== 'function') return value; + const factory = z + .custom<(context: { mode: string; isPreview: boolean }) => unknown>( + (candidate) => typeof candidate === 'function', + ) + .parse(value); + return await factory({ mode: preview ? 'preview' : 'production', isPreview: preview }); +} +function durableNamespaces( + built: z.infer, + preview: WorkerBindings, + config: ReleaseConfiguration, +): void { + for (const [name, value] of Object.entries(built.env)) { + if (value.type !== 'durable-object') continue; + const candidate = preview[name]; + if ( + value.worker !== config.productionWorker || + candidate?.type !== 'durable-object' || + candidate.worker !== config.previewWorker || + !built.exports?.[value.exportName] + ) + throw new Error( + 'Durable Object namespace must retain its declared class on the selected production or preview Worker.', + ); + } +} +const canonical = z.strictObject({ + worker, + accountId: z + .string() + .regex(/^[a-f0-9]{32}$/) + .optional(), +}); +function matchesPublicRoute(built: z.infer, config: ReleaseConfiguration): boolean { + const hostname = new URL(config.productionUrl).hostname; + const prefix = config.publicPath ?? '/'; + if (prefix !== '/' && built.domains.includes(hostname)) return false; + if (prefix === '/' && built.domains.includes(hostname)) return true; + const routePrefix = hostname + prefix; + return built.triggers.some( + (item) => + item.type === 'fetch' && + (item.pattern === routePrefix.slice(0, -1) || item.pattern.startsWith(routePrefix)), + ); +} +function canonicalWorkers(value: unknown, config: ReleaseConfiguration, previewValue: unknown) { + const built = canonical.parse(value).worker; + const candidate = previewValue === undefined ? built : canonical.parse(previewValue).worker; + if (previewValue !== undefined && candidate.name !== config.previewWorker) + throw new Error('Canonical preview config does not identify the configured preview Worker.'); + if (built.name !== config.productionWorker) + throw new Error('Canonical config does not identify the configured production Worker.'); + if (!config.previewOnly && !matchesPublicRoute(built, config)) + throw new Error( + 'Canonical routes do not identify the configured production origin and public path.', + ); + return { built, candidate }; +} +function workerAssets( + value: z.infer['worker']['assets'], + binding: string | undefined, + publicPath: string | undefined, +): Record | undefined { + return value + ? releaseMarkerAssets( + { ...(snakeSettings(value) as Record), binding }, + publicPath, + ) + : undefined; +} +export function typedWorkerConfiguration( + value: unknown, + config: ReleaseConfiguration, + previewValue?: unknown, +): Record { + const { built, candidate } = canonicalWorkers(value, config, previewValue); + const preview = isolatedPreviewBindings( + built.env, + config.previewBindings, + config.previewReadOnlyBindings, + ); + durableNamespaces(built, preview, config); + const assetNames = Object.entries(built.env) + .filter(([, binding]) => binding.type === 'assets') + .map(([name]) => name); + if (assetNames.length > 1 || Boolean(built.assets) !== Boolean(assetNames.length)) + throw new Error('Declare one assets binding with its canonical asset settings.'); + const assets = workerAssets(built.assets, assetNames[0], config.publicPath); + const previewAssets = workerAssets(candidate.assets, assetNames[0], config.publicPath); + const unsafe = built.unsafe ?? { metadata: { keep_bindings: ['secret_text', 'secret_key'] } }; + const productionBindings = wranglerBindings(built.env); + const previewBindings = wranglerBindings(preview); + return { + account_id: canonical.parse(value).accountId, + name: built.name, + main: built.entrypoint, + compatibility_date: built.compatibilityDate, + compatibility_flags: built.compatibilityFlags ?? [], + observability: snakeSettings(built.observability), + logpush: built.logpush, + tail_consumers: snakeSettings(built.tailConsumers), + workers_dev: built.workersDev ?? false, + preview_urls: config.publicationMode !== 'named-staging', + assets, + exports: built.exports, + routes: [ + ...built.domains.map((domain) => ({ pattern: domain, custom_domain: true })), + ...built.triggers.flatMap((item) => + item.type === 'fetch' ? [{ pattern: item.pattern, zone_name: item.zone }] : [], + ), + ], + triggers: { + crons: built.triggers.flatMap((item) => (item.type === 'scheduled' ? [item.schedule] : [])), + }, + ...productionBindings, + vars: { + ...(productionBindings.vars as Record), + LVBT_DEPLOYMENT_ENV: 'production', + }, + unsafe, + env: { + preview: { + name: config.previewWorker, + assets: previewAssets, + observability: snakeSettings(candidate.observability), + logpush: candidate.logpush, + tail_consumers: previewValue === undefined ? [] : snakeSettings(candidate.tailConsumers), + unsafe: candidate.unsafe ?? unsafe, + workers_dev: new URL(config.previewUrl).hostname.endsWith('.workers.dev'), + preview_urls: config.publicationMode !== 'named-staging', + routes: new URL(config.previewUrl).hostname.endsWith('.workers.dev') + ? [] + : [{ pattern: new URL(config.previewUrl).hostname, custom_domain: true }], + triggers: { crons: [] }, + ...previewBindings, + vars: { + ...(previewBindings.vars as Record), + LVBT_DEPLOYMENT_ENV: 'preview', + }, + }, + }, + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/typed-worker-input.ts b/.lvbt/web-platform/packages/web-platform/src/typed-worker-input.ts new file mode 100644 index 0000000..3734a45 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/typed-worker-input.ts @@ -0,0 +1,60 @@ +import { lstat, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { resolveTypedConfiguration, typedWorkerConfiguration } from './typed-worker-config.js'; + +function repositoryRoot(source: string, appDirectory: string): string { + return path.resolve( + source, + ...path + .normalize(appDirectory) + .split(path.sep) + .filter((segment) => segment !== '.') + .map(() => '..'), + ); +} +async function existingAncestor(file: string): Promise { + try { + return await realpath(file); + } catch (error) { + if (!z.object({ code: z.literal('ENOENT') }).safeParse(error).success) throw error; + return await existingAncestor(path.dirname(file)); + } +} +export async function reviewedReleasePath( + source: string, + relative: string, + appDirectory: string, + requireExists = true, +): Promise { + const root = repositoryRoot(source, appDirectory); + const canonicalRoot = await realpath(root); + const file = path.resolve(source, relative); + const canonical = requireExists ? await realpath(file) : await existingAncestor(file); + const contained = (value: string): boolean => + value === root || value.startsWith(`${root}${path.sep}`); + if ( + !contained(file) || + !(canonical === canonicalRoot || canonical.startsWith(`${canonicalRoot}${path.sep}`)) + ) + throw new Error('Canonical release inputs must stay inside the reviewed repository.'); + if (requireExists && (await lstat(file)).isSymbolicLink()) + throw new Error('Canonical release inputs must stay inside the reviewed repository.'); + return file; +} +export async function readTypedWorkerConfiguration( + source: string, + config: ReleaseConfiguration, +): Promise<{ canonical: string; imported: unknown; generated: Record }> { + if (!config.typedConfig) throw new Error('Declare the canonical typedConfig before packaging.'); + const canonical = await reviewedReleasePath(source, config.typedConfig, config.appDirectory); + const input: unknown = await z + .object({ default: z.unknown() }) + .parse(await import(pathToFileURL(canonical).href)).default; + const imported = await resolveTypedConfiguration(input); + const preview = + typeof input === 'function' ? await resolveTypedConfiguration(input, true) : undefined; + return { canonical, imported, generated: typedWorkerConfiguration(imported, config, preview) }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/typed-worker-release-artifact.ts b/.lvbt/web-platform/packages/web-platform/src/typed-worker-release-artifact.ts new file mode 100644 index 0000000..e864235 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/typed-worker-release-artifact.ts @@ -0,0 +1,193 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { z } from 'zod'; +import type { ReleaseConfiguration } from './release-config.js'; +import { packageRelease, sealSavedRelease, type WebsiteRelease } from './saved-release-artifact.js'; +import { configuredReleaseIdentity, workerReleaseEntry } from './worker-release-entry.js'; +import { readTypedWorkerConfiguration, reviewedReleasePath } from './typed-worker-input.js'; +import type { ArtifactAcceptance } from './saved-release-artifact.js'; +import { retainReleaseMigrations } from './saved-release-migrations.js'; +import { verifyReadOnlyWorkerModules } from './read-only-worker-modules.js'; +const execute = promisify(execFile); + +async function retainedConfiguration( + generated: Record, + temporary: string, + assets: string | undefined, +): Promise { + const preview = z + .object({ preview: z.record(z.string(), z.unknown()) }) + .parse(generated.env).preview; + const assetSettings = assets + ? { ...z.record(z.string(), z.unknown()).parse(generated.assets), directory: './dist' } + : undefined; + await writeFile( + path.join(temporary, 'wrangler.jsonc'), + JSON.stringify( + { + ...generated, + main: '.wrangler/worker/index.js', + assets: assetSettings, + env: { + preview: { + ...preview, + assets: assets + ? { + ...z.record(z.string(), z.unknown()).parse(preview.assets), + directory: assetSettings?.directory, + } + : undefined, + }, + }, + }, + null, + 2, + ), + ); +} +async function sealTypedBuild( + temporary: string, + destination: string, + input: { + identity: { commit: string; releaseId: string }; + assets: string | undefined; + acceptance: ArtifactAcceptance | undefined; + }, +): Promise { + if (input.assets) { + await cp(input.assets, path.join(temporary, 'dist'), { recursive: true }); + return await packageRelease(temporary, destination, input.identity, { + ...input.acceptance, + formatVersion: 2, + ...(!(await readdir(input.assets)).includes('index.html') + ? { artifactKind: 'worker' as const } + : {}), + }); + } + await mkdir(destination); + await cp(path.join(temporary, '.wrangler'), path.join(destination, '.wrangler'), { + recursive: true, + }); + await cp(path.join(temporary, 'wrangler.jsonc'), path.join(destination, 'wrangler.jsonc')); + await writeFile(path.join(destination, 'lvbt-release.json'), JSON.stringify(input.identity)); + return await sealSavedRelease(destination, input.identity, 'worker', 2); +} +async function retainSourceDeclarations( + temporary: string, + imported: unknown, + input: { canonical: string; config: ReleaseConfiguration }, +): Promise { + await writeFile( + path.join(temporary, '.wrangler/worker/preview-capabilities.json'), + JSON.stringify({ readOnlyBindings: input.config.previewReadOnlyBindings ?? [] }), + ); + await writeFile( + path.join(temporary, '.wrangler/worker/config-source.json'), + JSON.stringify({ + path: input.config.typedConfig, + sha256: createHash('sha256') + .update(await readFile(input.canonical)) + .digest('hex'), + }), + ); + const production = z + .object({ worker: z.object({ env: z.record(z.string(), z.unknown()) }) }) + .parse(imported).worker.env; + await writeFile( + path.join(temporary, '.wrangler/worker/bindings.json'), + JSON.stringify({ production, preview: input.config.previewBindings }, null, 2), + ); +} +function typedEntry( + main: string, + identity: { commit: string; releaseId: string }, + generated: Record, + config: ReleaseConfiguration, +): string { + return workerReleaseEntry(main, identity, { + ...config, + durableExports: Object.keys(z.record(z.string(), z.unknown()).parse(generated.exports ?? {})), + }); +} +export async function packageTypedWorkerRelease( + source: string, + destination: string, + identity: { commit: string; releaseId: string }, + config: ReleaseConfiguration, +): Promise { + identity = configuredReleaseIdentity(identity, config); + const { canonical, imported, generated } = await readTypedWorkerConfiguration(source, config); + const main = await reviewedReleasePath( + source, + path.relative(source, path.resolve(path.dirname(canonical), z.string().parse(generated.main))), + config.appDirectory, + ); + const assets = config.assetsDirectory + ? await reviewedReleasePath(source, config.assetsDirectory, config.appDirectory) + : undefined; + if (Boolean(generated.assets) !== Boolean(assets)) + throw new Error('Declare assetsDirectory for the canonical Worker assets.'); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-typed-worker-')); + try { + await mkdir(path.join(temporary, '.wrangler/worker'), { recursive: true }); + await writeFile( + path.join(temporary, 'index.ts'), + typedEntry(main, identity, generated, config), + ); + const assetSettings = assets + ? { ...z.record(z.string(), z.unknown()).parse(generated.assets), directory: assets } + : undefined; + const buildConfig = path.join(temporary, 'build.json'); + const preview = z + .object({ preview: z.record(z.string(), z.unknown()) }) + .parse(generated.env).preview; + await writeFile( + buildConfig, + JSON.stringify({ + ...generated, + main: path.join(temporary, 'index.ts'), + assets: assetSettings, + env: { + preview: { + ...preview, + assets: assets + ? { + ...z.record(z.string(), z.unknown()).parse(preview.assets), + directory: assetSettings?.directory, + } + : undefined, + }, + }, + }), + ); + await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'deploy', + '--dry-run', + '--config', + buildConfig, + '--outdir', + path.join(temporary, '.wrangler/worker'), + ], + { cwd: source, maxBuffer: 16 * 1024 * 1024 }, + ); + await retainedConfiguration(generated, temporary, assets); + await retainSourceDeclarations(temporary, imported, { canonical, config }); + if (config.previewReadOnlyBindings?.length) await verifyReadOnlyWorkerModules(temporary); + if (config.migrations?.length) await retainReleaseMigrations(source, temporary, config); + return await sealTypedBuild(temporary, destination, { + identity, + assets, + acceptance: config.artifactAcceptance, + }); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-bindings.ts b/.lvbt/web-platform/packages/web-platform/src/worker-bindings.ts new file mode 100644 index 0000000..5b6525d --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-bindings.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +const simple = z.strictObject({ + limit: z.number().int().positive(), + period: z.union([z.literal(10), z.literal(60)]), +}); +const binding = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('d1'), + name: z.string().min(1), + id: z + .string() + .min(1) + .refine( + (id) => id !== '00000000-0000-0000-0000-000000000000', + 'Initialize the selected D1 database before releasing.', + ), + }), + z.strictObject({ type: z.literal('r2'), name: z.string().min(1) }), + z.strictObject({ + type: z.literal('durable-object'), + worker: z.string().min(1), + exportName: z.string().min(1), + }), + z.strictObject({ type: z.literal('analytics-engine-dataset'), name: z.string().min(1) }), + z.strictObject({ type: z.literal('rate-limit'), namespace: z.string().regex(/^\d+$/), simple }), + z.strictObject({ type: z.literal('text'), value: z.string() }), + z.strictObject({ type: z.literal('assets') }), + z.strictObject({ type: z.literal('secret') }), +]); +export const workerBindingsSchema = z.record(z.string().regex(/^[A-Z][A-Z0-9_]*$/), binding); +export type WorkerBindings = z.infer; +function isolated(value: WorkerBindings[string], candidate: WorkerBindings[string]): boolean { + if (value.type !== candidate.type) return false; + if (value.type === 'd1' && candidate.type === 'd1') + return value.id !== candidate.id && value.name !== candidate.name; + if (value.type === 'r2' && candidate.type === 'r2') return value.name !== candidate.name; + if (value.type === 'durable-object' && candidate.type === 'durable-object') + return value.worker !== candidate.worker && value.exportName === candidate.exportName; + if (value.type === 'analytics-engine-dataset' && candidate.type === 'analytics-engine-dataset') + return value.name !== candidate.name; + if (value.type === 'rate-limit' && candidate.type === 'rate-limit') + return ( + BigInt(value.namespace) !== BigInt(candidate.namespace) && + JSON.stringify(value.simple) === JSON.stringify(candidate.simple) + ); + return true; +} +export function isolatedPreviewBindings( + production: WorkerBindings, + input: unknown, + readonlyBindings: string[] = [], +): WorkerBindings { + const preview = workerBindingsSchema.parse(input); + if (Object.keys(preview).sort().join(',') !== Object.keys(production).sort().join(',')) + throw new Error('Declare every isolated preview binding explicitly.'); + if (new Set(readonlyBindings).size !== readonlyBindings.length) + throw new Error('Duplicate preview read-only bindings.'); + for (const name of readonlyBindings) { + const value = production[name], + candidate = preview[name]; + if (value?.type !== 'r2' || candidate?.type !== 'r2' || value.name !== candidate.name) + throw new Error('Preview read-only capabilities require an explicitly shared R2 binding.'); + } + for (const [name, value] of Object.entries(production)) { + const candidate = preview[name]; + if (!candidate || (!readonlyBindings.includes(name) && !isolated(value, candidate))) + throw new Error(`Preview binding ${name} must retain its type and use isolated resources.`); + } + return preview; +} +export function wranglerBindings(values: WorkerBindings): Record { + return { + durable_objects: { + bindings: Object.entries(values).flatMap(([name, value]) => + value.type === 'durable-object' + ? [{ name, class_name: value.exportName, script_name: value.worker }] + : [], + ), + }, + vars: Object.fromEntries( + Object.entries(values).flatMap(([name, value]) => + value.type === 'text' ? [[name, value.value]] : [], + ), + ), + d1_databases: Object.entries(values).flatMap(([name, value]) => + value.type === 'd1' + ? [{ binding: name, database_name: value.name, database_id: value.id }] + : [], + ), + r2_buckets: Object.entries(values).flatMap(([name, value]) => + value.type === 'r2' ? [{ binding: name, bucket_name: value.name }] : [], + ), + analytics_engine_datasets: Object.entries(values).flatMap(([name, value]) => + value.type === 'analytics-engine-dataset' ? [{ binding: name, dataset: value.name }] : [], + ), + ratelimits: Object.entries(values).flatMap(([name, value]) => + value.type === 'rate-limit' + ? [{ name, namespace_id: value.namespace, simple: value.simple }] + : [], + ), + }; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-preview-command.ts b/.lvbt/web-platform/packages/web-platform/src/worker-preview-command.ts new file mode 100644 index 0000000..9ee3a1d --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-preview-command.ts @@ -0,0 +1,95 @@ +import { verifyWorkerReleaseConfiguration } from './worker-release-configuration.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { parseArgs } from 'node:util'; + +import { previewUploadReceipt } from './pr-preview-config.js'; + +const execute = promisify(execFile); + +function workerSecrets(workerSecretNames: readonly string[]): Record { + const entries = workerSecretNames.map((name) => [name, process.env[name]?.trim()] as const); + const missing = entries.filter(([, value]) => !value).map(([name]) => name); + if (missing.length > 0) + throw new Error(`Set the Worker candidate secrets: ${missing.join(', ')}.`); + return Object.fromEntries(entries) as Record; +} + +export async function runWorkerPreview( + config: ReleaseConfiguration, + args: string[] = process.argv.slice(2), + secretNames: readonly string[] = [], +): Promise { + const { values } = parseArgs({ + args, + options: { + alias: { type: 'string' }, + message: { type: 'string' }, + env: { type: 'string' }, + secrets: { type: 'boolean', default: false }, + }, + }); + const alias = values.alias; + if (!alias || !/^[a-z][a-z0-9-]*$/.test(alias)) + throw new Error('Pass a lowercase Worker preview alias with --alias.'); + // Pull request previews use the `preview` environment: a separate Worker bound to the preview + // database. Without --env the version is uploaded to the production Worker. + const environment = values.env; + if (environment !== undefined && environment !== 'preview') + throw new Error('The only Worker environment is --env preview.'); + const workerName = environment ? config.previewWorker : config.productionWorker; + if (!config.workersDevSubdomain) + throw new Error('Configure the reviewed workersDevSubdomain before uploading a preview.'); + + await verifyWorkerReleaseConfiguration(process.cwd(), config); + const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-worker-preview-')); + try { + const receiptPath = path.join(directory, 'wrangler.jsonl'); + const secretsPath = path.join(directory, 'secrets.json'); + if (values.secrets) + await writeFile(secretsPath, `${JSON.stringify(workerSecrets(secretNames))}\n`, { + mode: 0o600, + }); + await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'versions', + 'upload', + '--name', + workerName, + '--preview-alias', + alias, + '--message', + values.message ?? `Website preview ${alias}`, + ...(environment ? ['--env', environment] : []), + ...(values.secrets ? ['--secrets-file', secretsPath] : []), + ], + { + env: { + ...process.env, + WRANGLER_LOG_SANITIZE: 'true', + WRANGLER_OUTPUT_FILE_PATH: receiptPath, + }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + + const receipt = previewUploadReceipt( + await readFile(receiptPath, 'utf8'), + workerName, + config.workersDevSubdomain, + ); + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile(output, `url=${receipt.url}\nversion=${receipt.version}\n`, { flag: 'a' }); + process.stdout.write(`${JSON.stringify(receipt)}\n`); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-release-command.ts b/.lvbt/web-platform/packages/web-platform/src/worker-release-command.ts new file mode 100644 index 0000000..193d17d --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-release-command.ts @@ -0,0 +1,342 @@ +import { verifyExpectedProductionVersion } from './expected-production-version.js'; +import { productionEndpoint } from './release-path.js'; +import { createCandidateReceipt, verifyCandidateReceipt } from './release-candidate.js'; +import { verifyReleaseAttestation } from './release-attestation.js'; +import { + assertNamedPreviewProtection, + deployNamedSavedRelease, + verifyNamedPreview, +} from './named-worker-release.js'; +import { verifyWorkerReleaseConfiguration } from './worker-release-configuration.js'; +import { packageCfRelease } from './cf-release-artifact.js'; +import { packageTypedWorkerRelease } from './typed-worker-release-artifact.js'; +import { packageLegacyWorkerRelease } from './legacy-worker-release-artifact.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import { execFile } from 'node:child_process'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs, promisify } from 'node:util'; +import { previewUploadReceipt } from './pr-preview-config.js'; +import { verifyRelease, type WebsiteRelease } from './saved-release-artifact.js'; +import { releaseSource } from './release-source.js'; +import { selectedLegacyRelease } from './legacy-release-attestation.js'; +import { accessCredentials } from './access-auth.js'; +import { readReleaseIdentity } from './release-identity.js'; +import { resolveRelease } from './resolve-release.js'; +import { githubJson } from './release-github.js'; + +const execute = promisify(execFile); +function printRelease(release: WebsiteRelease): void { + process.stdout.write( + `${JSON.stringify({ commit: release.commit, releaseId: release.releaseId, artifactHash: release.artifactHash, fileCount: release.files.length })}\n`, + ); +} +interface ReleaseOptions { + directory?: string; + commit?: string; + 'release-id'?: string; + target?: string; + version?: string; + 'run-file'?: string; + repository?: string; + 'run-id'?: string; + 'attestation-directory'?: string; + 'candidate-directory'?: string; + 'expected-version'?: string; + verification?: string; +} +async function sourceRelease(config: ReleaseConfiguration, values: ReleaseOptions): Promise { + if (!values.repository || values.repository !== config.repository) + throw new Error('Pass the configured --repository.'); + const source = + values['run-file'] && values['run-id'] + ? releaseSource( + JSON.parse(await readFile(values['run-file'], 'utf8')), + values.repository, + values['run-id'], + config.stagingWorkflow, + ) + : await resolveRelease( + values.repository, + values['run-id'] === '' ? undefined : values['run-id'], + { + previewIdentity: async () => { + const credentials = accessCredentials(process.env); + if (!credentials) + throw new Error( + 'The worker-preview environment needs its Access service credentials.', + ); + return await readReleaseIdentity(config.previewUrl, { + credentials, + app: config.profile, + }); + }, + getRun: async (id) => + await githubJson(['api', `repos/${values.repository}/actions/runs/${id}`]), + getArtifacts: async (id) => + await githubJson([ + 'api', + `repos/${values.repository}/actions/runs/${id}/artifacts?per_page=100`, + ]), + }, + config, + ); + const legacy = await selectedLegacyRelease(config, source); + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile( + output, + `commit=${source.commit}\nrelease-id=${source.releaseId}\nlegacy=${legacy}\n`, + { + flag: 'a', + }, + ); + process.stdout.write(`${JSON.stringify({ ...source, legacy })}\n`); +} +async function uploadVersionRelease( + config: ReleaseConfiguration, + directory: string, + release: WebsiteRelease, + target: string, +): Promise { + if (!config.workersDevSubdomain) + throw new Error( + 'Configure the reviewed workersDevSubdomain before uploading a release candidate.', + ); + await verifyWorkerReleaseConfiguration(directory, config); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-upload-')); + try { + // Wrangler may write cache files; its working copy cannot modify the saved release. + const copy = path.join(temporary, 'release'); + await cp(directory, copy, { recursive: true }); + if ((await verifyRelease(copy)).artifactHash !== release.artifactHash) + throw new Error('Saved release changed while preparing upload.'); + const receiptPath = path.join(temporary, 'receipt.jsonl'); + await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'versions', + 'upload', + '--name', + target === 'preview' ? config.previewWorker : config.productionWorker, + '--config', + path.join(copy, 'wrangler.jsonc'), + '--env', + target === 'preview' ? 'preview' : '', + '--no-bundle', + '--preview-alias', + `release-${release.releaseId}`, + '--message', + `Release ${release.releaseId} at ${release.commit}`, + ], + { + env: { + ...process.env, + WRANGLER_LOG_SANITIZE: 'true', + WRANGLER_OUTPUT_FILE_PATH: receiptPath, + }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + const receipt = previewUploadReceipt( + await readFile(receiptPath, 'utf8'), + target === 'preview' ? config.previewWorker : config.productionWorker, + config.workersDevSubdomain, + ); + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile( + output, + `url=${receipt.url}\nversion=${receipt.version}\nartifact-hash=${release.artifactHash}\n`, + { flag: 'a' }, + ); + process.stdout.write( + `${JSON.stringify({ ...receipt, releaseId: release.releaseId, commit: release.commit, artifactHash: release.artifactHash })}\n`, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} +async function activateVersionRelease( + config: ReleaseConfiguration, + values: ReleaseOptions, + directory: string, + release: WebsiteRelease, +): Promise { + const target = values.target; + if (target !== 'preview' && target !== 'production') + throw new Error('Pass --target preview or production.'); + if (!values.version || !/^[a-f0-9-]{36}$/.test(values.version)) + throw new Error('Pass an explicit Worker version ID.'); + await verifyWorkerReleaseConfiguration(directory, config); + const temporary = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-activate-')); + try { + const copy = path.join(temporary, 'release'); + await cp(directory, copy, { recursive: true }); + if ((await verifyRelease(copy)).artifactHash !== release.artifactHash) + throw new Error('Saved release changed while preparing activation.'); + const { stdout } = await execute( + 'pnpm', + [ + 'exec', + 'wrangler', + 'versions', + 'deploy', + `${values.version}@100%`, + '--name', + target === 'preview' ? config.previewWorker : config.productionWorker, + '--config', + path.join(copy, 'wrangler.jsonc'), + '--env', + target === 'preview' ? 'preview' : '', + '--yes', + ], + { maxBuffer: 16 * 1024 * 1024 }, + ); + process.stdout.write(stdout); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} +async function namedOperation( + config: ReleaseConfiguration, + directory: string, + release: WebsiteRelease, + operation: { values: ReleaseOptions; upload: boolean }, +): Promise { + const { values, upload } = operation; + await verifyWorkerReleaseConfiguration(directory, config); + if (upload) await assertNamedPreviewProtection(config); + else if (values['candidate-directory']) + await verifyCandidateReceipt(config, release, values['candidate-directory']); + else await verifyNamedPreview(config, release); + const receipt = + !upload && values.target === 'preview' + ? { version: values.version } + : await deployNamedSavedRelease( + config, + directory, + release, + upload ? 'preview' : 'production', + ); + const origin = + upload || values.target === 'preview' ? config.previewUrl : productionEndpoint(config); + const protectedPreview = upload || values.target === 'preview'; + const output = process.env.GITHUB_OUTPUT; + if (output) + await writeFile( + output, + `url=${origin}\nversion=${receipt.version ?? ''}\nartifact-hash=${release.artifactHash}\nprotected=${protectedPreview}\n`, + { flag: 'a' }, + ); + process.stdout.write( + `${JSON.stringify({ ...receipt, url: origin, protected: protectedPreview, artifactHash: release.artifactHash })}\n`, + ); +} +function verifySelectedIdentity(release: WebsiteRelease, values: ReleaseOptions): void { + if (values.commit && release.commit !== values.commit) + throw new Error('Release commit does not match the selected Actions run.'); + if (values['release-id'] && release.releaseId !== values['release-id']) + throw new Error('Release ID does not match the selected Actions run.'); +} +async function packageSelectedRelease( + config: ReleaseConfiguration, + directory: string, + values: ReleaseOptions, +): Promise { + if (!values.commit || !values['release-id']) throw new Error('Pass --commit and --release-id.'); + const identity = { + commit: values.commit, + releaseId: values['release-id'], + ...(config.profile ? { app: config.profile } : {}), + }; + if (config.artifactSource === 'typed-worker') + return await packageTypedWorkerRelease(process.cwd(), directory, identity, config); + if (config.artifactSource === 'cf-output') + return await packageCfRelease(process.cwd(), directory, identity, config); + return await packageLegacyWorkerRelease(process.cwd(), directory, identity, config); +} +export async function runWorkerRelease( + config: ReleaseConfiguration, + args: string[] = process.argv.slice(2), +): Promise { + const { positionals, values } = parseArgs({ + args, + allowPositionals: true, + options: { + directory: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + target: { type: 'string' }, + version: { type: 'string' }, + 'run-file': { type: 'string' }, + repository: { type: 'string' }, + 'run-id': { type: 'string' }, + 'attestation-directory': { type: 'string' }, + 'candidate-directory': { type: 'string' }, + 'expected-version': { type: 'string' }, + verification: { type: 'string' }, + }, + }); + if ( + process.env.LVBT_RELEASE_PUBLICATION_MODE && + process.env.LVBT_RELEASE_PUBLICATION_MODE !== (config.publicationMode ?? 'version') + ) + throw new Error('Workflow publication mode does not match the selected release configuration.'); + const command = positionals[0]; + const directory = values.directory ? path.resolve(values.directory) : undefined; + if (command === 'source') { + await sourceRelease(config, values); + return; + } + if (!directory) throw new Error('Pass --directory.'); + if (command === 'package') { + printRelease(await packageSelectedRelease(config, directory, values)); + return; + } + if (!['verify', 'upload', 'activate', 'candidate'].includes(command ?? '')) + throw new Error('Use source, package, verify, upload, or activate.'); + await runSavedOperation(config, values, directory, command); +} +async function runSavedOperation( + config: ReleaseConfiguration, + values: ReleaseOptions, + directory: string, + command: string | undefined, +): Promise { + const release = await verifyRelease(directory); + verifySelectedIdentity(release, values); + if (release.app !== config.profile) throw new Error('Saved release belongs to another app.'); + if (command === 'candidate') { + if (!values['candidate-directory'] || !values.version) + throw new Error('Pass --candidate-directory and --version.'); + await verifyReleaseAttestation(config, directory, values['attestation-directory']); + await verifyNamedPreview(config, release); + await createCandidateReceipt(config, release, { + directory: values['candidate-directory'], + version: values.version, + browser: values.verification ?? '', + }); + return; + } + if (command === 'verify') { + await verifyReleaseAttestation(config, directory, values['attestation-directory']); + printRelease(release); + return; + } + const target = values.target; + if (target !== 'preview' && target !== 'production') + throw new Error('Pass --target preview or production.'); + if (target === 'production' && config.previewOnly) + throw new Error('Preview-only apps cannot publish production releases.'); + await verifyReleaseAttestation(config, directory, values['attestation-directory']); + if (target === 'production') + await verifyExpectedProductionVersion(config, values['expected-version'], directory); + if (config.publicationMode === 'named-staging') + await namedOperation(config, directory, release, { values, upload: command === 'upload' }); + else if (command === 'activate') await activateVersionRelease(config, values, directory, release); + else await uploadVersionRelease(config, directory, release, target); +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-release-configuration.ts b/.lvbt/web-platform/packages/web-platform/src/worker-release-configuration.ts new file mode 100644 index 0000000..351ceb2 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-release-configuration.ts @@ -0,0 +1,180 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parse, type ParseError } from 'jsonc-parser'; +import { z } from 'zod'; +import { verifyReadOnlyWorkerModules } from './read-only-worker-modules.js'; +const databases = z.array( + z + .object({ + binding: z.string(), + database_id: z + .string() + .min(1) + .refine( + (id) => id !== '00000000-0000-0000-0000-000000000000', + 'Initialize the selected D1 database before releasing.', + ), + }) + .loose(), +); +const buckets = z.array(z.object({ binding: z.string(), bucket_name: z.string().min(1) }).loose()); +const durableObjects = z + .object({ + bindings: z.array( + z + .object({ name: z.string(), class_name: z.string(), script_name: z.string().optional() }) + .loose(), + ), + }) + .loose(); +const counters = z.array( + z + .object({ + name: z.string(), + namespace_id: z.string().regex(/^\d+$/), + simple: z.object({ limit: z.number(), period: z.number() }).loose(), + }) + .loose(), +); +const workerConfiguration = z + .object({ + name: z.string(), + durable_objects: durableObjects.optional(), + ratelimits: counters.optional(), + d1_databases: databases.optional(), + r2_buckets: buckets.optional(), + env: z + .object({ + preview: z + .object({ + name: z.string().optional(), + durable_objects: durableObjects.optional(), + ratelimits: counters.optional(), + d1_databases: databases.optional(), + r2_buckets: buckets.optional(), + triggers: z.object({ crons: z.array(z.string()) }).optional(), + }) + .loose(), + }) + .loose(), + }) + .loose(); +function assertDurableObjectNamespaces( + config: z.infer, + expected: { productionWorker: string; previewWorker: string }, +): void { + const production = config.durable_objects?.bindings ?? []; + const preview = config.env.preview.durable_objects?.bindings ?? []; + for (const value of production) { + const candidate = preview.find((entry) => entry.name === value.name); + if ( + candidate?.class_name !== value.class_name || + (value.script_name ?? expected.productionWorker) !== expected.productionWorker || + (candidate.script_name ?? expected.previewWorker) !== expected.previewWorker + ) + throw new Error( + 'Preview Durable Objects must retain the class and use the declared separate Worker namespace.', + ); + } +} +function assertCounterNamespaces(config: z.infer): void { + const productionCounters = config.ratelimits ?? []; + const previewCounters = config.env.preview.ratelimits ?? []; + for (const value of productionCounters) { + const candidate = previewCounters.find((entry) => entry.name === value.name); + if ( + candidate?.simple.limit !== value.simple.limit || + candidate.simple.period !== value.simple.period + ) + throw new Error('Preview rate limits must retain the declared production behavior.'); + } + if ( + previewCounters.some((candidate) => + productionCounters.some( + (value) => BigInt(value.namespace_id) === BigInt(candidate.namespace_id), + ), + ) + ) + throw new Error('Preview rate limits must use separate account-scoped namespaces.'); +} +export function assertWorkerReleaseConfiguration( + value: unknown, + expected: { + productionWorker: string; + previewWorker: string; + previewReadOnlyBindings?: string[] | undefined; + }, +): void { + const config = workerConfiguration.parse(value); + if ( + config.name !== expected.productionWorker || + (config.env.preview.name ?? `${config.name}-preview`) !== expected.previewWorker + ) + throw new Error('Reviewed configuration does not match the selected Worker namespaces.'); + assertDurableObjectNamespaces(config, expected); + assertCounterNamespaces(config); + const production = config.d1_databases ?? []; + const preview = config.env.preview.d1_databases ?? []; + if ( + production.some( + (database) => !preview.some((candidate) => candidate.binding === database.binding), + ) || + preview.some((database) => + production.some((candidate) => candidate.database_id === database.database_id), + ) + ) + throw new Error('Preview D1 bindings must exist and use separate databases from production.'); + const readonly = expected.previewReadOnlyBindings ?? []; + const productionBuckets = config.r2_buckets ?? []; + const previewBuckets = config.env.preview.r2_buckets ?? []; + if ( + productionBuckets.some( + (bucket) => !previewBuckets.some((candidate) => candidate.binding === bucket.binding), + ) || + previewBuckets.some((bucket) => + productionBuckets.some( + (candidate) => + candidate.bucket_name === bucket.bucket_name && + !(readonly.includes(bucket.binding) && candidate.binding === bucket.binding), + ), + ) + ) + throw new Error('Preview R2 bindings must exist and use separate buckets from production.'); + if (config.env.preview.triggers?.crons.length) + throw new Error('Preview releases cannot enable scheduled production jobs.'); +} +export async function verifyWorkerReleaseConfiguration( + directory: string, + expected: { + productionWorker: string; + previewWorker: string; + previewReadOnlyBindings?: string[] | undefined; + }, +): Promise { + const errors: ParseError[] = []; + const value: unknown = parse( + await readFile(path.join(directory, 'wrangler.jsonc'), 'utf8'), + errors, + { allowTrailingComma: true }, + ); + if (errors.length) throw new Error('Invalid reviewed Wrangler configuration.'); + assertWorkerReleaseConfiguration(value, expected); + if (expected.previewReadOnlyBindings?.length) { + await verifyReadOnlyWorkerModules(directory); + const capabilities = z + .strictObject({ readOnlyBindings: z.array(z.string()) }) + .parse( + JSON.parse( + await readFile( + path.join(directory, '.wrangler/worker/preview-capabilities.json'), + 'utf8', + ), + ), + ); + if ( + JSON.stringify([...capabilities.readOnlyBindings].sort()) !== + JSON.stringify([...expected.previewReadOnlyBindings].sort()) + ) + throw new Error('Saved preview read-only capabilities differ from the reviewed policy.'); + } +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-release-entry.ts b/.lvbt/web-platform/packages/web-platform/src/worker-release-entry.ts new file mode 100644 index 0000000..41b827c --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-release-entry.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { releaseMarkerPath } from './release-path.js'; +import type { ReleaseConfiguration } from './release-config.js'; +import type { SavedReleaseIdentity } from './saved-release-artifact.js'; +export function configuredReleaseIdentity( + identity: SavedReleaseIdentity, + config: ReleaseConfiguration, +): SavedReleaseIdentity { + if (identity.app && identity.app !== config.profile) + throw new Error('Release identity belongs to another app.'); + return { ...identity, ...(config.profile ? { app: config.profile } : {}) }; +} +export function workerReleaseEntry( + main: string, + identity: SavedReleaseIdentity, + config: Pick & { + durableExports?: string[]; + }, +): string { + const module = JSON.stringify(main); + const names = (config.durableExports ?? []).map((name) => + z + .string() + .regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/) + .parse(name), + ); + const constructors = names + .map( + (name) => + `export class ${name} extends original.${name} { constructor(ctx,env) { super(ctx,previewEnvironment(env)); } }`, + ) + .join('\n'); + return `import application from ${module};\nimport * as original from ${module};\nexport * from ${module}; +const identity=${JSON.stringify(identity)}; +const markerPaths=${JSON.stringify(['/lvbt-release.json', releaseMarkerPath(config.publicPath)])}; +const readonlyBindings=${JSON.stringify(config.previewReadOnlyBindings ?? [])}; +function previewEnvironment(env) { + if (env.LVBT_DEPLOYMENT_ENV !== 'preview') return env; + const detached=Object.create(null); + for (const name of Reflect.ownKeys(env)) { + const value=Reflect.get(env,name); + let binding=value; + if (readonlyBindings.includes(name)) { + const facade=Object.create(null); + for (const method of ['get','head','list']) if (typeof value[method]==='function') + Object.defineProperty(facade,method,{value:value[method].bind(value),enumerable:true}); + binding=new Proxy(Object.freeze(facade),{get(bucket,method){ + if (!['get','head','list'].includes(method)) throw new Error('Preview binding '+name+' is read-only.'); + return Reflect.get(bucket,method); + }}); + } + Object.defineProperty(detached,name,{value:binding,enumerable:true}); + } + return Object.freeze(detached); +} +${constructors} +export default new Proxy(application,{get(target,name){ + if (name !== 'fetch') { + const value=Reflect.get(target,name); + return typeof value==='function' ? (...args)=>{args[1]=previewEnvironment(args[1]);return value.apply(target,args)} : value; + } + return async (request,env,ctx)=>{ + let response; + if (markerPaths.includes(new URL(request.url).pathname)) response=Response.json(identity,{headers:{'Cache-Control':'no-store'}}); + else response=await target.fetch(request,previewEnvironment(env),ctx); + if (env.LVBT_DEPLOYMENT_ENV !== 'preview') return response; + const headers=new Headers(response.headers); + headers.set('X-Robots-Tag','noindex, nofollow, noarchive'); + headers.set('Cache-Control','private, no-store'); + return new Response(response.body,{status:response.status,statusText:response.statusText,headers}); + }; +}});\n`; +} diff --git a/.lvbt/web-platform/packages/web-platform/src/worker-release-smoke.ts b/.lvbt/web-platform/packages/web-platform/src/worker-release-smoke.ts new file mode 100644 index 0000000..79d9fb5 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/src/worker-release-smoke.ts @@ -0,0 +1,164 @@ +import { parseArgs } from 'node:util'; +import { z } from 'zod'; +import { accessCredentials, accessHeaders, type AccessCredentials } from './access-auth.js'; +import { waitForReleaseIdentity, type ReleaseIdentity } from './release-identity.js'; +import type { ReleaseConfiguration } from './release-config.js'; +export const workerSmokeSchema = z.strictObject({ + path: z.string().regex(/^\/(?!\/)/), + status: z.number().int().min(100).max(599), + body: z.string().optional(), +}); +export type WorkerSmoke = z.infer; +type Request = ( + url: string, + options: { headers: Record; redirect: 'manual'; signal: AbortSignal }, +) => Promise; +interface SmokeInput { + origin: string; + identity: ReleaseIdentity; + smoke: WorkerSmoke; + publicPath?: string | undefined; + protected?: boolean; + credentials?: AccessCredentials | undefined; + request?: Request; + timeoutMs?: number; +} + +export async function verifyWorkerReleaseSmoke( + input: SmokeInput, +): Promise<{ origin: string; path: string; identity: ReleaseIdentity }> { + const origin = new URL(input.origin); + if (origin.protocol !== 'https:' || origin.origin !== input.origin) + throw new Error('API smoke requires an HTTPS origin without a path.'); + const smoke = workerSmokeSchema.parse(input.smoke); + const url = new URL(smoke.path, origin).href; + if (new URL(url).origin !== input.origin) + throw new Error('API smoke path must stay inside the configured origin.'); + const request = input.request ?? fetch; + if (input.protected) { + if (!input.credentials) + throw new Error('Protected staging verification requires Access credentials.'); + const anonymous = await request(url, { + headers: {}, + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + if (![302, 401, 403].includes(anonymous.status)) + throw new Error('Anonymous request reached staging.'); + await anonymous.arrayBuffer(); + } + const identity = await waitForReleaseIdentity(input.origin, input.identity, { + ...(input.credentials ? { credentials: input.credentials } : {}), + request, + publicPath: input.publicPath, + timeoutMs: input.timeoutMs ?? 0, + }); + const response = await request(url, { + headers: accessHeaders(url, input.origin, input.credentials), + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + if (response.status !== smoke.status) + throw new Error( + `API smoke ${smoke.path} returned ${response.status}; expected ${smoke.status}.`, + ); + const body = await response.text(); + if (smoke.body !== undefined && body !== smoke.body) + throw new Error(`API smoke ${smoke.path} response body differs from the declared behavior.`); + return { origin: input.origin, path: smoke.path, identity }; +} +function customOrigin(url: URL, value: string): boolean { + return ( + url.hostname !== 'workers.dev' && + !url.hostname.endsWith('.workers.dev') && + url.origin === value && + url.protocol === 'https:' + ); +} +function validWorkerLabel(input: { + workerLabel: string; + prefix: string; + worker: string; + configured: boolean; +}): boolean { + return ( + (input.configured && input.workerLabel === input.worker) || + (/^[a-f0-9]{8}$/.test(input.prefix) && input.workerLabel === `${input.prefix}-${input.worker}`) + ); +} +export function validateWorkerSmokeOrigin( + value: string, + config: Pick< + ReleaseConfiguration, + 'previewUrl' | 'previewWorker' | 'productionUrl' | 'productionWorker' | 'workersDevSubdomain' + >, + protectedPreview: boolean, +): void { + const configuredOrigin = protectedPreview ? config.previewUrl : config.productionUrl; + const worker = protectedPreview ? config.previewWorker : config.productionWorker; + const url = new URL(value); + const configured = value === configuredOrigin; + if (configured && customOrigin(url, value)) return; + const labels = url.hostname.split('.'); + const workerLabel = labels[0] ?? ''; + const prefix = workerLabel.slice(0, 8); + if ( + url.protocol !== 'https:' || + Boolean(url.port) || + url.origin !== value || + labels.length !== 4 || + labels[1] !== config.workersDevSubdomain || + labels[2] !== 'workers' || + labels[3] !== 'dev' || + !validWorkerLabel({ workerLabel, prefix, worker, configured }) + ) + throw new Error( + `Checks require the configured ${protectedPreview ? 'preview' : 'production'} Worker origin.`, + ); +} + +export async function runWorkerReleaseSmoke( + config: ReleaseConfiguration, + args: string[] = process.argv.slice(2), +): Promise { + const { values } = parseArgs({ + args, + options: { + url: { type: 'string' }, + commit: { type: 'string' }, + 'release-id': { type: 'string' }, + protected: { type: 'boolean', default: false }, + public: { type: 'boolean', default: false }, + 'wait-for-propagation': { type: 'boolean', default: false }, + }, + }); + if (!config.smoke) + throw new Error('Declare the Worker smoke path, expected status, and optional body.'); + if (!values.url || !values.commit || !values['release-id']) + throw new Error('Pass --url, --commit, and --release-id for API verification.'); + if (values.public === values.protected) + throw new Error('Select exactly one of --public and --protected.'); + validateWorkerSmokeOrigin(values.url, config, values.protected); + const verified = await verifyWorkerReleaseSmoke({ + origin: values.url, + identity: { + commit: values.commit, + releaseId: values['release-id'], + ...(config.profile ? { app: config.profile } : {}), + }, + publicPath: values.url === config.productionUrl ? config.publicPath : '/', + smoke: { + ...config.smoke, + path: + values.url === config.productionUrl + ? `${config.publicPath ?? '/'}${config.smoke.path.slice(1)}` + : config.smoke.path, + }, + protected: values.protected, + credentials: values.protected ? accessCredentials(process.env) : undefined, + timeoutMs: values['wait-for-propagation'] ? 180_000 : 0, + }); + process.stdout.write( + `PASS: verified API ${verified.origin}${verified.path}, release ${verified.identity.releaseId}\n`, + ); +} diff --git a/.lvbt/web-platform/packages/web-platform/tests/cf-release-artifact.test.ts b/.lvbt/web-platform/packages/web-platform/tests/cf-release-artifact.test.ts new file mode 100644 index 0000000..0311b43 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/cf-release-artifact.test.ts @@ -0,0 +1,99 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { packageCfRelease } from '../src/cf-release-artifact.js'; +import { verifyRelease } from '../src/saved-release-artifact.js'; +const configuration = { + repository: 'Example/app', + appDirectory: 'apps/deploy', + productionUrl: 'https://example.org', + previewUrl: 'https://preview.example.org', + productionWorker: 'app', + previewWorker: 'app-preview', + artifactPrefix: 'app-release', + stagingWorkflow: { name: 'Deploy staging', path: '.github/workflows/deploy.yml', branch: 'main' }, + promotionWorkflow: { file: 'promote.yml', titlePrefix: 'Promote app', branch: 'main' }, +}; +test('a cf static build becomes a reviewed Worker release through the existing inventory and verifier', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cf-release-')); + try { + const source = path.join(root, 'source'); + const worker = path.join(source, '.cloudflare/output/v0/workers/default'); + await mkdir(path.join(worker, 'assets'), { recursive: true }); + await writeFile(path.join(worker, 'assets/index.html'), '

Reviewed static app

'); + await writeFile( + path.join(worker, 'worker.config.json'), + JSON.stringify({ + name: 'app', + compatibilityDate: '2026-08-31', + assets: { notFoundHandling: '404-page' }, + observability: { enabled: true }, + previewUrls: true, + }), + ); + const directory = path.join(root, 'release'); + const release = await packageCfRelease( + source, + directory, + { commit: 'a'.repeat(40), releaseId: '123' }, + configuration, + ); + expect(await verifyRelease(directory)).toEqual(release); + const config = z + .object({ + name: z.string(), + assets: z.object({ directory: z.string() }), + vars: z.object({ LVBT_DEPLOYMENT_ENV: z.string() }), + env: z.object({ + preview: z.object({ + name: z.string(), + routes: z.array(z.object({ pattern: z.string(), custom_domain: z.boolean() })), + vars: z.object({ LVBT_DEPLOYMENT_ENV: z.string() }), + }), + }), + }) + .parse(JSON.parse(await readFile(path.join(directory, 'wrangler.jsonc'), 'utf8'))); + expect(config.name).toBe('app'); + expect(config.env.preview.name).toBe('app-preview'); + expect(config.assets.directory).toBe('./dist'); + expect(config.env.preview.routes).toEqual([ + { pattern: 'preview.example.org', custom_domain: true }, + ]); + expect(config.vars.LVBT_DEPLOYMENT_ENV).toBe('production'); + expect(config.env.preview.vars.LVBT_DEPLOYMENT_ENV).toBe('preview'); + expect(await readFile(path.join(directory, 'dist/index.html'), 'utf8')).toBe( + '

Reviewed static app

', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('static cf normalization cannot silently discard production data bindings', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cf-bound-release-')); + try { + const worker = path.join(root, '.cloudflare/output/v0/workers/default'); + await mkdir(path.join(worker, 'assets'), { recursive: true }); + await writeFile( + path.join(worker, 'worker.config.json'), + JSON.stringify({ + name: 'app', + compatibilityDate: '2026-08-31', + assets: {}, + d1Databases: [{ binding: 'DB', databaseId: 'production' }], + }), + ); + await expect( + packageCfRelease( + root, + path.join(root, 'release'), + { commit: 'a'.repeat(40), releaseId: '123' }, + configuration, + ), + ).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/cf-worker-release-artifact.test.ts b/.lvbt/web-platform/packages/web-platform/tests/cf-worker-release-artifact.test.ts new file mode 100644 index 0000000..b495d3e --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/cf-worker-release-artifact.test.ts @@ -0,0 +1,217 @@ +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, expect, it } from 'vitest'; +import type { ReleaseConfiguration } from '../src/release-config'; +import { verifyRelease } from '../src/saved-release-artifact'; +import { packageWorkerCfRelease } from '../src/cf-worker-release-artifact'; +interface WranglerTarget { + analytics_engine_datasets: unknown[]; + ratelimits: { namespace_id: string }[]; + vars: Record; +} +interface WranglerConfig extends WranglerTarget { + env: { preview: WranglerTarget }; +} +interface WorkerRuntime { + default: { fetch: (request: Request, env: unknown, ctx: unknown) => Promise }; + named: string; +} +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const identity = { commit: 'a'.repeat(40), releaseId: '100' }; +const configuration: ReleaseConfiguration & { previewBindings: Record } = { + repository: 'LasVegasForTransit/analytics', + appDirectory: 'apps/collector', + productionWorker: 'lvbt-analytics-events', + previewWorker: 'lvbt-analytics-events-preview', + productionUrl: 'https://events.lasvegasfortransit.org', + previewUrl: 'https://events-preview.example.test', + artifactPrefix: 'analytics-release', + artifactSource: 'cf-output', + stagingWorkflow: { + name: 'Deploy collector staging', + path: '.github/workflows/deploy-collector-staging.yml', + branch: 'main', + }, + promotionWorkflow: { + file: 'promote-collector.yml', + titlePrefix: 'Promote collector release', + branch: 'main', + }, + previewBindings: { + EVENTS: { type: 'analytics-engine-dataset', name: 'lvbt_events_preview' }, + EVENT_LIMITER: { type: 'rate-limit', namespace: '1002', simple: { limit: 30, period: 60 } }, + LVBT_EVENTS_SECRET: { type: 'secret' }, + }, +}; +const descriptor = { + name: 'lvbt-analytics-events', + compatibilityDate: '2026-08-22', + compatibilityFlags: ['nodejs_compat'], + domains: ['events.lasvegasfortransit.org'], + observability: { enabled: true, headSamplingRate: 1 }, + workersDev: false, + env: { + EVENTS: { type: 'analytics-engine-dataset', name: 'lvbt_events' }, + EVENT_LIMITER: { type: 'rate-limit', namespace: '1001', simple: { limit: 30, period: 60 } }, + LVBT_EVENTS_SECRET: { type: 'secret' }, + }, + manifest: { + type: 'complete', + mainModule: 'index.js', + modules: { 'index.js': { type: 'esm' }, 'events.js': { type: 'esm' } }, + }, +}; +const original = `import {health} from './events.js';\nexport const named = 'preserved';\nexport default {async fetch(request,env){if(new URL(request.url).pathname==='/health')return new Response(health);await env.EVENT_LIMITER.limit({key:'test'});env.EVENTS.writeDataPoint({blobs:['test']});return new Response(null,{status:204});}};\n`; +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'lvbt-worker-release-')); + roots.push(root); + await writeFile(path.join(root, 'package.json'), '{"type":"module"}'); + const source = path.join(root, 'source'); + const output = path.join(source, '.cloudflare/output/v0/workers/default'); + await mkdir(path.join(output, 'bundle'), { recursive: true }); + await writeFile(path.join(output, 'worker.config.json'), JSON.stringify(descriptor)); + await writeFile(path.join(output, 'bundle/index.js'), original); + await writeFile(path.join(output, 'bundle/events.js'), "export const health='ok';\n"); + return { source, destination: path.join(root, 'saved'), output }; +} +it('seals original worker code and explicitly isolated bindings into a saved v1 worker release', async () => { + const setup = await fixture(); + const release = await packageWorkerCfRelease( + setup.source, + setup.destination, + identity, + configuration, + ); + expect(release.artifactKind).toBe('worker'); + expect( + await readFile(path.join(setup.destination, '.wrangler/worker/bundle/index.js'), 'utf8'), + ).toBe(original); + expect(await verifyRelease(setup.destination)).toEqual(release); + const config = JSON.parse( + await readFile(path.join(setup.destination, 'wrangler.jsonc'), 'utf8'), + ) as WranglerConfig; + expect(config.analytics_engine_datasets).toEqual([{ binding: 'EVENTS', dataset: 'lvbt_events' }]); + expect(config.env.preview.analytics_engine_datasets).toEqual([ + { binding: 'EVENTS', dataset: 'lvbt_events_preview' }, + ]); + expect(config.ratelimits[0]?.namespace_id).toBe('1001'); + expect(config.env.preview.ratelimits[0]?.namespace_id).toBe('1002'); + expect(config.vars.LVBT_EVENTS_SECRET).toBeUndefined(); + expect(config.env.preview.vars.LVBT_EVENTS_SECRET).toBeUndefined(); +}); +it('saved marker describes the packaged identity while the original runtime keeps handling requests', async () => { + const setup = await fixture(); + await packageWorkerCfRelease(setup.source, setup.destination, identity, configuration); + const runtime = (await import( + pathToFileURL(path.join(setup.destination, '.wrangler/worker/index.js')).href + )) as WorkerRuntime; + const writes: unknown[] = []; + const calls: string[] = []; + const env = { + EVENTS: { writeDataPoint: (data: unknown) => writes.push(data) }, + EVENT_LIMITER: { + limit: () => { + calls.push('preview'); + return { success: true }; + }, + }, + }; + const marker = await runtime.default.fetch( + new Request('https://preview.example.test/lvbt-release.json'), + env, + {}, + ); + expect(await marker.json()).toEqual(identity); + expect(writes).toHaveLength(0); + expect(calls).toHaveLength(0); + expect( + await ( + await runtime.default.fetch(new Request('https://preview.example.test/health'), env, {}) + ).text(), + ).toBe('ok'); + expect( + (await runtime.default.fetch(new Request('https://preview.example.test/e'), env, {})).status, + ).toBe(204); + expect(writes).toEqual([{ blobs: ['test'] }]); + expect(calls).toEqual(['preview']); + expect(runtime.named).toBe('preserved'); +}); +it('tampered compiled code or saved marker cannot verify', async () => { + const setup = await fixture(); + await packageWorkerCfRelease(setup.source, setup.destination, identity, configuration); + await writeFile(path.join(setup.destination, '.wrangler/worker/bundle/index.js'), 'changed code'); + await expect(verifyRelease(setup.destination)).rejects.toThrow(/reviewed|match|artifact/i); +}); +it('missing or production-equivalent preview resources and unsupported bindings fail closed', async () => { + for (const config of [ + { ...configuration, previewBindings: {} }, + { + ...configuration, + previewBindings: { ...configuration.previewBindings, EVENTS: descriptor.env.EVENTS }, + }, + { + ...configuration, + previewBindings: { + ...configuration.previewBindings, + EVENT_LIMITER: descriptor.env.EVENT_LIMITER, + }, + }, + ]) { + const setup = await fixture(); + await expect( + packageWorkerCfRelease(setup.source, setup.destination, identity, config), + ).rejects.toThrow(/preview|isolat|binding/i); + } + const setup = await fixture(); + await writeFile( + path.join(setup.output, 'worker.config.json'), + JSON.stringify({ + ...descriptor, + env: { ...descriptor.env, UNSUPPORTED: { type: 'durable-object', className: 'Session' } }, + }), + ); + await expect( + packageWorkerCfRelease(setup.source, setup.destination, identity, configuration), + ).rejects.toThrow(/unsupported|binding|type/i); +}); + +it('changed saved identity marker and unlisted or escaping modules cannot be accepted', async () => { + const marker = await fixture(); + await packageWorkerCfRelease(marker.source, marker.destination, identity, configuration); + await writeFile( + path.join(marker.destination, 'lvbt-release.json'), + JSON.stringify({ ...identity, commit: 'b'.repeat(40) }), + ); + await expect(verifyRelease(marker.destination)).rejects.toThrow(/match|reviewed|artifact/i); + const missing = await fixture(); + await writeFile( + path.join(missing.output, 'worker.config.json'), + JSON.stringify({ + ...descriptor, + manifest: { ...descriptor.manifest, mainModule: 'unlisted.js' }, + }), + ); + await expect( + packageWorkerCfRelease(missing.source, missing.destination, identity, configuration), + ).rejects.toThrow(/main module/); + const escaped = await fixture(); + await writeFile( + path.join(escaped.output, 'worker.config.json'), + JSON.stringify({ + ...descriptor, + manifest: { + ...descriptor.manifest, + mainModule: '../outside.js', + modules: { '../outside.js': { type: 'esm' } }, + }, + }), + ); + await expect( + packageWorkerCfRelease(escaped.source, escaped.destination, identity, configuration), + ).rejects.toThrow(/bundle|module|inside/); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/expected-production-version.test.ts b/.lvbt/web-platform/packages/web-platform/tests/expected-production-version.test.ts new file mode 100644 index 0000000..1575e24 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/expected-production-version.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest'; +import { verifyExpectedProductionVersion } from '../src/expected-production-version.js'; +const expected = '1c4deaba-ee53-4c3f-ba65-176ae596cad5'; +const config = { productionWorker: 'example-production' }; + +test('the expected version guard reads only the selected production namespace and rejects changed or split traffic', async () => { + await Promise.resolve(); + const commands: string[][] = []; + const execute = async (args: string[]) => { + await Promise.resolve(); + commands.push(args); + return JSON.stringify([ + { created_on: '2026-09-05T00:00:00Z', versions: [{ version_id: expected, percentage: 100 }] }, + ]); + }; + await verifyExpectedProductionVersion(config, expected, '/saved/release', execute); + expect(commands).toEqual([ + [ + 'exec', + 'wrangler', + 'deployments', + 'list', + '--name', + config.productionWorker, + '--config', + '/saved/release/wrangler.jsonc', + '--env', + '', + '--json', + ], + ]); + await expect( + verifyExpectedProductionVersion(config, expected, undefined, () => + Promise.resolve( + JSON.stringify([ + { + created_on: '2026-09-05T00:00:00Z', + versions: [{ version_id: '2ae50b24-3d42-48d2-a784-627b60841961', percentage: 100 }], + }, + ]), + ), + ), + ).rejects.toThrow(/changed/); + await expect( + verifyExpectedProductionVersion(config, expected, undefined, () => + Promise.resolve( + JSON.stringify([ + { + created_on: '2026-09-05T00:00:00Z', + versions: [ + { version_id: expected, percentage: 50 }, + { version_id: '2ae50b24-3d42-48d2-a784-627b60841961', percentage: 50 }, + ], + }, + ]), + ), + ), + ).rejects.toThrow(); +}); + +test('omitted guards issue no command, invalid selectors fail before querying the provider', async () => { + await Promise.resolve(); + let requests = 0; + const execute = async () => { + await Promise.resolve(); + requests++; + return '[]'; + }; + await verifyExpectedProductionVersion(config, undefined, undefined, execute); + await expect( + verifyExpectedProductionVersion(config, 'not-a-version', undefined, execute), + ).rejects.toThrow(); + expect(requests).toBe(0); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/legacy-attestation-workflow.test.ts b/.lvbt/web-platform/packages/web-platform/tests/legacy-attestation-workflow.test.ts new file mode 100644 index 0000000..83ddc60 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/legacy-attestation-workflow.test.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test } from 'vitest'; +test('legacy selection skips the absent proof without granting library authorization', async () => { + const publish = await readFile( + new URL('../../../.github/workflows/release-publish.yml', import.meta.url), + 'utf8', + ); + const source = await readFile( + new URL('../../../.github/workflows/release-source.yml', import.meta.url), + 'utf8', + ); + expect(source).toContain('value: ${{ jobs.source.outputs.legacy }}'); + expect(source).toContain('legacy: ${{ steps.source.outputs.legacy }}'); + expect(publish).toMatch(/legacy-artifact:\s+type: boolean\s+required: false\s+default: false/); + expect( + publish.match(/if: inputs.attestation-prefix != '' && !inputs.legacy-artifact/g), + ).toHaveLength(3); + const proofSelections = publish.split('\n').filter((line) => line.includes('then proof=')); + expect(proofSelections.length).toBeGreaterThan(0); + for (const line of proofSelections) expect(line).toContain('"$LEGACY_ARTIFACT" != true'); + expect(publish).not.toContain('--legacy'); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/legacy-release-attestation.test.ts b/.lvbt/web-platform/packages/web-platform/tests/legacy-release-attestation.test.ts new file mode 100644 index 0000000..14c6793 --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/legacy-release-attestation.test.ts @@ -0,0 +1,330 @@ +import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { sealSavedRelease } from '../src/saved-release-artifact.js'; +import { verifyReleaseAttestation } from '../src/release-attestation.js'; +import { + legacyArtifactsSchema, + selectedLegacyRelease, + verifyLegacyReleaseAttestation, +} from '../src/legacy-release-attestation.js'; + +const commit = 'a'.repeat(40); +const record = { + runId: '123', + sourceCommit: commit, + artifactId: 456, + expiresAt: '2027-01-01T00:00:00Z', +}; +const config = { + repository: 'LasVegasForTransit/website', + artifactPrefix: 'website-release', + stagingWorkflow: { + name: 'Deploy staging', + path: '.github/workflows/deploy-production.yml', + branch: 'main', + }, + attestation: { legacyArtifacts: [record] }, +}; +const roots: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function artifact(formatVersion: 1 | 2 = 1, content = 'reviewed bytes') { + const root = await mkdtemp(path.join(os.tmpdir(), 'legacy-attestation-fixture-')); + roots.push(root); + await mkdir(path.join(root, '.wrangler/worker'), { recursive: true }); + await writeFile(path.join(root, 'wrangler.jsonc'), '{}'); + await writeFile(path.join(root, '.wrangler/worker/index.js'), content); + await sealSavedRelease(root, { commit, releaseId: record.runId }, 'worker', formatVersion); + return root; +} +function remote(source: string) { + const run = { + id: 123, + run_attempt: 1, + name: config.stagingWorkflow.name, + path: config.stagingWorkflow.path, + event: 'push', + head_branch: 'main', + head_sha: commit, + status: 'completed', + conclusion: 'success', + repository: { full_name: config.repository, id: 789 }, + head_repository: { full_name: config.repository, id: 789 }, + }; + const metadata = { + id: record.artifactId, + name: `website-release-${record.runId}`, + expired: false, + expires_at: record.expiresAt, + workflow_run: { + id: 123, + head_sha: commit, + head_branch: 'main', + repository_id: 789, + head_repository_id: 789, + }, + }; + const json = vi.fn((args: string[]): Promise => { + const route = args[1]; + if (route === `repos/${config.repository}`) + return Promise.resolve({ id: 789, default_branch: 'main' }); + if (route?.endsWith('/runs/123')) return Promise.resolve(run); + if (route?.endsWith('/artifacts/456')) return Promise.resolve(metadata); + if (route?.endsWith('/runs/123/artifacts?per_page=100')) + return Promise.resolve({ total_count: 1, artifacts: [metadata] }); + throw new Error(`Unexpected read ${route}`); + }); + const download = vi.fn(async (args: string[]) => { + const destination = args[args.indexOf('--dir') + 1]; + if (!destination) throw new Error('Missing private download destination'); + await cp(source, destination, { recursive: true }); + return ''; + }); + return { run, metadata, json, download, now: () => Date.parse('2026-10-07T00:00:00Z') }; +} + +test('only the pinned retained v1 bytes establish unsigned compatibility and private files are removed', async () => { + const local = await artifact(); + const readers = remote(await artifact()); + await expect(verifyLegacyReleaseAttestation(config, local, readers)).resolves.toBe(true); + expect(readers.download).toHaveBeenCalledWith([ + 'run', + 'download', + '123', + '--repo', + config.repository, + '--name', + 'website-release-123', + '--dir', + expect.any(String), + ]); + const destination = readers.download.mock.calls[0]?.[0].at(-1); + if (!destination) throw new Error('No private download was captured'); + await expect( + import('node:fs/promises').then(({ lstat }) => lstat(destination)), + ).rejects.toThrow(); + expect( + readers.json.mock.calls.filter(([args]) => args[1]?.endsWith('/artifacts/456')), + ).toHaveLength(2); +}); + +test('source selection may flag exact legacy metadata but never downloads or authorizes bytes', async () => { + const readers = remote(await artifact()); + await expect(selectedLegacyRelease(config, { releaseId: '123', commit }, readers)).resolves.toBe( + true, + ); + expect(readers.download).not.toHaveBeenCalled(); + await expect(selectedLegacyRelease(config, { releaseId: '124', commit }, readers)).resolves.toBe( + false, + ); +}); + +test('new format artifacts cannot use an old run allowance', async () => { + const readers = remote(await artifact()); + await expect(verifyLegacyReleaseAttestation(config, await artifact(2), readers)).resolves.toBe( + false, + ); + expect(readers.json).not.toHaveBeenCalled(); + expect(readers.download).not.toHaveBeenCalled(); +}); + +test('changed local and remotely self-consistent bytes cannot be substituted for each other', async () => { + const readers = remote(await artifact(1, 'different reviewed inventory')); + await expect(verifyLegacyReleaseAttestation(config, await artifact(), readers)).rejects.toThrow( + 'inventory', + ); +}); + +test.each([ + [ + 'artifact ID', + (r: ReturnType) => { + r.metadata.id = 999; + }, + ], + [ + 'artifact expiry', + (r: ReturnType) => { + r.metadata.expires_at = '2027-02-01T00:00:00Z'; + }, + ], + [ + 'expired metadata', + (r: ReturnType) => { + r.metadata.expired = true; + }, + ], + [ + 'source SHA', + (r: ReturnType) => { + r.run.head_sha = 'b'.repeat(40); + }, + ], + [ + 'failed run', + (r: ReturnType) => { + r.run.conclusion = 'failure'; + }, + ], + [ + 'fork run', + (r: ReturnType) => { + r.run.head_repository.full_name = 'foreign/website'; + }, + ], + [ + 'foreign artifact', + (r: ReturnType) => { + r.metadata.workflow_run.repository_id = 999; + }, + ], + [ + 'different workflow', + (r: ReturnType) => { + r.run.path = '.github/workflows/other.yml'; + }, + ], +])('rejects %s before downloading', async (_name, mutate) => { + const readers = remote(await artifact()); + mutate(readers); + await expect(verifyLegacyReleaseAttestation(config, await artifact(), readers)).rejects.toThrow(); + expect(readers.download).not.toHaveBeenCalled(); +}); + +test('expiry is bounded by the reviewed deadline even if the remote artifact still exists', async () => { + const readers = remote(await artifact()); + readers.now = () => Date.parse(record.expiresAt); + await expect(verifyLegacyReleaseAttestation(config, await artifact(), readers)).rejects.toThrow( + 'expired', + ); + expect(readers.download).not.toHaveBeenCalled(); +}); + +test('changed metadata after download and changed default branch are refused', async () => { + const readers = remote(await artifact()); + readers.download.mockImplementation(async (args) => { + const destination = args.at(-1); + if (!destination) throw new Error('Missing private download destination'); + await cp(await artifact(), destination, { recursive: true }); + readers.metadata.id = 999; + return ''; + }); + await expect(verifyLegacyReleaseAttestation(config, await artifact(), readers)).rejects.toThrow(); + readers.metadata.id = record.artifactId; + const json = readers.json.getMockImplementation(); + if (!json) throw new Error('Missing metadata reader'); + readers.json.mockImplementation(async (args) => + args[1] === `repos/${config.repository}` ? { id: 789, default_branch: 'develop' } : json(args), + ); + await expect( + selectedLegacyRelease(config, { releaseId: '123', commit }, readers), + ).rejects.toThrow(); +}); + +test('policy rejects duplicate run IDs, duplicate artifact IDs and malformed expiry', () => { + expect(legacyArtifactsSchema.safeParse([record, { ...record, artifactId: 999 }]).success).toBe( + false, + ); + expect(legacyArtifactsSchema.safeParse([record, { ...record, runId: '999' }]).success).toBe( + false, + ); + expect(legacyArtifactsSchema.safeParse([{ ...record, expiresAt: 'tomorrow' }]).success).toBe( + false, + ); +}); + +test.each([true, false])( + 'truncated or ambiguous download names cannot select a different artifact (%s)', + async (truncated) => { + const readers = remote(await artifact()); + const json = readers.json.getMockImplementation(); + if (!json) throw new Error('Missing reader'); + readers.json.mockImplementation((args) => + args[1]?.endsWith('/runs/123/artifacts?per_page=100') + ? Promise.resolve( + truncated + ? { total_count: 101, artifacts: [readers.metadata] } + : { total_count: 2, artifacts: [readers.metadata, { ...readers.metadata, id: 999 }] }, + ) + : json(args), + ); + await expect(verifyLegacyReleaseAttestation(config, await artifact(), readers)).rejects.toThrow( + 'inventory', + ); + expect(readers.download).not.toHaveBeenCalled(); + }, +); + +test('a local artifact changed during remote verification cannot gain authorization', async () => { + const local = await artifact(); + const source = await artifact(); + const readers = remote(source); + readers.download.mockImplementation(async (args) => { + const destination = args.at(-1); + if (!destination) throw new Error('Missing destination'); + await cp(source, destination, { recursive: true }); + await writeFile(path.join(local, '.wrangler/worker/index.js'), 'changed during download'); + return ''; + }); + await expect(verifyLegacyReleaseAttestation(config, local, readers)).rejects.toThrow(); +}); + +test('the ordinary signed-proof verifier independently authorizes exact old bytes through real GitHub CLI boundaries', async () => { + const local = await artifact(); + const source = await artifact(); + const readers = remote(source); + const bin = await mkdtemp(path.join(os.tmpdir(), 'legacy-gh-fixture-')); + roots.push(bin); + const fixture = path.join(bin, 'fixture.json'); + const capture = path.join(bin, 'calls.jsonl'); + await writeFile( + fixture, + JSON.stringify({ source, run: readers.run, metadata: readers.metadata }), + ); + await writeFile( + path.join(bin, 'gh'), + `#!/usr/bin/env node +const fs=require('node:fs'); +const args=process.argv.slice(2); +fs.appendFileSync(process.env.LEGACY_CAPTURE,JSON.stringify(args)+'\\n'); +const value=JSON.parse(fs.readFileSync(process.env.LEGACY_FIXTURE,'utf8')); +if(args[0]==='run'&&args[1]==='download') fs.cpSync(value.source,args.at(-1),{recursive:true}); +else if(args[0]==='api') { + const route=args[1]; + const response=route.endsWith('/runs/123/artifacts?per_page=100')?{total_count:1,artifacts:[value.metadata]}: + route.endsWith('/artifacts/456')?value.metadata:route.endsWith('/runs/123')?value.run:{id:789,default_branch:'main'}; + process.stdout.write(JSON.stringify(response)); +} else process.exit(9); +`, + ); + await chmod(path.join(bin, 'gh'), 0o755); + vi.stubEnv('PATH', `${bin}${path.delimiter}${process.env.PATH}`); + vi.stubEnv('LEGACY_FIXTURE', fixture); + vi.stubEnv('LEGACY_CAPTURE', capture); + const fullConfig = { + ...config, + attestation: { + ...config.attestation, + signerWorkflow: 'LasVegasForTransit/repository-tooling/.github/workflows/release-attest.yml', + signerCommit: 'b'.repeat(40), + }, + }; + await expect(verifyReleaseAttestation(fullConfig, local)).resolves.toBeUndefined(); + const calls = (await readFile(capture, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as string[]); + expect(calls.filter((args) => args[0] === 'run')).toHaveLength(1); + expect(calls.filter((args) => args[1]?.endsWith('/artifacts/456'))).toHaveLength(2); + await expect(verifyReleaseAttestation(fullConfig, await artifact(2))).rejects.toThrow( + 'attestation', + ); + await expect( + verifyReleaseAttestation(fullConfig, local, path.join(bin, 'missing-proof')), + ).rejects.toThrow(); + expect((await readFile(capture, 'utf8')).trim().split('\n')).toHaveLength(calls.length); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/legacy-worker-release.test.ts b/.lvbt/web-platform/packages/web-platform/tests/legacy-worker-release.test.ts new file mode 100644 index 0000000..d2c72fc --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/legacy-worker-release.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from 'vitest'; +import { packageLegacyWorkerRelease } from '../src/legacy-worker-release-artifact.js'; +import { verifyRelease } from '../src/saved-release-artifact.js'; +test('legacy Worker packaging retains reviewed migrations without rewriting the checkout config', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'legacy-worker-migrations-')); + try { + const source = path.join(root, 'apps/site'); + await mkdir(path.join(source, '.wrangler/worker'), { recursive: true }); + await mkdir(path.join(source, 'dist')); + await mkdir(path.join(source, 'migrations')); + const original = JSON.stringify({ + name: 'app', + assets: { directory: './dist', binding: 'ASSETS', run_worker_first: ['/api/*'] }, + d1_databases: [{ binding: 'DB', database_id: 'production' }], + env: { + preview: { name: 'app-preview', d1_databases: [{ binding: 'DB', database_id: 'preview' }] }, + }, + }); + await writeFile(path.join(source, 'wrangler.jsonc'), original); + await writeFile(path.join(source, 'dist/index.html'), '
Reviewed app
'); + await writeFile(path.join(source, '.wrangler/worker/index.js'), 'export default {}'); + const sql = '-- exact reviewed bytes\r\nCREATE TABLE users(id TEXT);\r\n'; + await writeFile(path.join(source, 'migrations/0001_users.sql'), sql); + const config = { + repository: 'Example/app', + appDirectory: 'apps/site', + productionWorker: 'app', + previewWorker: 'app-preview', + productionUrl: 'https://example.org', + previewUrl: 'https://preview.example.org', + artifactPrefix: 'app-release', + migrations: [{ binding: 'DB', directory: 'migrations' }], + stagingWorkflow: { + name: 'Deploy staging', + path: '.github/workflows/deploy.yml', + branch: 'main', + }, + promotionWorkflow: { file: 'promote.yml', titlePrefix: 'Promote app', branch: 'main' }, + }; + const destination = path.join(root, 'release'); + const release = await packageLegacyWorkerRelease( + source, + destination, + { commit: 'a'.repeat(40), releaseId: '123' }, + config, + ); + expect(await verifyRelease(destination)).toEqual(release); + expect( + await readFile( + path.join(destination, '.wrangler/worker/migrations/DB/0001_users.sql'), + 'utf8', + ), + ).toBe(sql); + expect(await readFile(path.join(source, 'wrangler.jsonc'), 'utf8')).toBe(original); + const saved = JSON.parse(await readFile(path.join(destination, 'wrangler.jsonc'), 'utf8')) as { + assets: { run_worker_first: string[] }; + env: { preview: { assets: { run_worker_first: string[] } } }; + }; + expect(saved.assets.run_worker_first).toEqual(['/api/*', '/lvbt-release.json']); + expect(saved.env.preview.assets.run_worker_first).toEqual(saved.assets.run_worker_first); + expect( + await readFile(path.join(destination, '.wrangler/worker/release-original.js'), 'utf8'), + ).toBe('export default {}'); + const entry = (await import( + pathToFileURL(path.join(destination, '.wrangler/worker/index.js')).href + )) as { + default: { fetch(request: Request, env: unknown, context: unknown): Promise }; + }; + const marker = await entry.default.fetch( + new Request('https://preview.example.org/lvbt-release.json'), + { LVBT_DEPLOYMENT_ENV: 'preview' }, + {}, + ); + expect(await marker.json()).toEqual({ commit: 'a'.repeat(40), releaseId: '123' }); + expect(marker.headers.get('cache-control')).toBe('private, no-store'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/links.test.ts b/.lvbt/web-platform/packages/web-platform/tests/links.test.ts new file mode 100644 index 0000000..4986a3a --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/links.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import * as links from '../src/links'; + +describe('compiled site links', () => { + it('rewrites configured canonical origins but preserves unrelated external destinations', () => { + expect(typeof links.linksFromHtml).toBe('function'); + const result = links.linksFromHtml( + 'JoinOther', + '/events/', + 'http://127.0.0.1:1234', + new Set(['https://example.test', 'https://www.example.test']), + ); + expect(result.internal).toEqual([ + 'http://127.0.0.1:1234/join/?a=1&b=2', + 'http://127.0.0.1:1234/logo.svg', + ]); + expect(result.external).toEqual(['https://other.test/a']); + }); + it('retains every failed URL and returns external redirect destinations', async () => { + expect(typeof links.checkInternalLinkResults).toBe('function'); + const origin = 'http://127.0.0.1:1234'; + const result = await links.checkInternalLinkResults( + [`${origin}/one`, `${origin}/two`, `${origin}/go`], + origin, + (url) => + Promise.resolve( + url.endsWith('/go') + ? new Response('', { status: 302, headers: { location: 'https://other.test/join' } }) + : new Response('', { status: 404 }), + ), + ); + expect( + result.results.filter((entry) => entry.status === 'fail').map((entry) => entry.url), + ).toEqual([`${origin}/one`, `${origin}/two`]); + expect(result.results[0]?.diagnostic).toMatch(/404/); + expect(result.external).toEqual(['https://other.test/join']); + expect(result.checked).toBe(3); + }); + it('classifies transport failures as errors and invalid redirects as link failures', async () => { + expect(typeof links.checkInternalLinkResults).toBe('function'); + const origin = 'http://127.0.0.1:1234'; + const result = await links.checkInternalLinkResults( + [`${origin}/error`, `${origin}/loop`], + origin, + (url) => { + if (url.endsWith('/error')) return Promise.reject(new Error('socket closed')); + return Promise.resolve(new Response('', { status: 302, headers: { location: '/loop' } })); + }, + ); + expect(result.results.map((entry) => entry.status)).toEqual(['error', 'fail']); + expect(result.results[0]?.diagnostic).toMatch(/socket closed/); + expect(result.results[1]?.diagnostic).toMatch(/redirect/i); + }); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/named-worker-release.test.ts b/.lvbt/web-platform/packages/web-platform/tests/named-worker-release.test.ts new file mode 100644 index 0000000..18c3ebb --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/named-worker-release.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { runWorkerRelease } from '../src/worker-release-command.js'; +import { packageRelease, verifyRelease } from '../src/saved-release-artifact.js'; +import { namedDeployReceipt, verifyNamedPreview } from '../src/named-worker-release.js'; +const identity = { commit: 'a'.repeat(40), releaseId: '123' }; +const version = '12345678-1234-4234-8234-123456789abc'; +const config = { + repository: 'Example/app', + appDirectory: 'app', + publicationMode: 'named-staging' as const, + productionWorker: 'app', + previewWorker: 'app-preview', + productionUrl: 'https://example.org', + previewUrl: 'https://preview.example.org', + artifactPrefix: 'app-release', + workersDevSubdomain: 'example', + stagingWorkflow: { name: 'Deploy staging', path: '.github/workflows/deploy.yml', branch: 'main' }, + promotionWorkflow: { file: 'promote.yml', titlePrefix: 'Promote app', branch: 'main' }, +}; +async function fixture(root: string) { + const source = path.join(root, 'app'); + await mkdir(path.join(source, 'dist'), { recursive: true }); + await mkdir(path.join(source, '.wrangler/worker'), { recursive: true }); + await writeFile(path.join(source, 'dist/index.html'), '
Reviewed
'); + await writeFile(path.join(source, '.wrangler/worker/index.js'), 'export default {}'); + await writeFile( + path.join(source, 'wrangler.jsonc'), + JSON.stringify({ + name: 'app', + logpush: true, + env: { preview: { name: 'app-preview', logpush: true } }, + }), + ); + const directory = path.join(root, 'release'); + const release = await packageRelease(source, directory, identity, { formatVersion: 2 }); + await mkdir(path.join(root, 'bin')); + await writeFile( + path.join(root, 'bin/pnpm'), + String.raw`#!/usr/bin/env node +const fs=require('node:fs'),args=process.argv.slice(2),file=args[args.indexOf('--config')+1]; +fs.appendFileSync(process.env.RELEASE_CAPTURE_PATH,JSON.stringify({args,config:JSON.parse(fs.readFileSync(file,'utf8'))})+'\n'); +fs.writeFileSync(process.env.WRANGLER_OUTPUT_FILE_PATH,JSON.stringify({type:'deploy',version:1,worker_name:args[args.indexOf('--name')+1],version_id:'12345678-1234-4234-8234-123456789abc'})); +fs.writeFileSync(file,'modified working copy');`, + { mode: 0o755 }, + ); + return { directory, release }; +} +test('named candidate writes only staging, production activation rechecks identity, and both deploy retained copies', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'named-release-')); + try { + const { directory, release } = await fixture(root); + const capture = path.join(root, 'calls.jsonl'); + vi.stubEnv('PATH', `${path.join(root, 'bin')}:${process.env.PATH}`); + vi.stubEnv('RELEASE_CAPTURE_PATH', capture); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'test-id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'test-secret'); + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, options: { headers: Record }) => + options.headers['CF-Access-Client-Id'] + ? Response.json(identity) + : new Response('', { status: 403 }), + ), + ); + await runWorkerRelease(config, ['upload', '--directory', directory, '--target', 'production']); + await runWorkerRelease(config, [ + 'activate', + '--directory', + directory, + '--target', + 'production', + '--version', + version, + ]); + const calls = (await readFile(capture, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { args: string[]; config: unknown }); + expect(calls).toHaveLength(2); + expect(calls[0]?.args).toEqual( + expect.arrayContaining([ + 'deploy', + '--name', + 'app-preview', + '--env', + 'preview', + '--no-bundle', + ]), + ); + expect(calls[1]?.args).toEqual( + expect.arrayContaining(['deploy', '--name', 'app', '--no-bundle']), + ); + expect(calls[1]?.config).toMatchObject({ logpush: true }); + expect(await verifyRelease(directory)).toEqual(release); + await expect( + runWorkerRelease({ ...config, previewOnly: true }, [ + 'upload', + '--directory', + directory, + '--target', + 'production', + ]), + ).rejects.toThrow('Preview-only'); + expect((await readFile(capture, 'utf8')).trim().split('\n')).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + await rm(root, { recursive: true, force: true }); + } +}); +test('named receipts reject missing, duplicate, or unrelated namespaces without inventing a version URL', () => { + const receipt = { type: 'deploy', version: 1, worker_name: 'app-preview', version_id: version }; + expect(namedDeployReceipt(JSON.stringify(receipt), 'app-preview')).toEqual({ version }); + expect(() => namedDeployReceipt(JSON.stringify(receipt), 'app')).toThrow('reconcile'); + expect(() => + namedDeployReceipt(`${JSON.stringify(receipt)}\n${JSON.stringify(receipt)}`, 'app-preview'), + ).toThrow('reconcile'); +}); +test('direct named activation verification rejects a namesake in another account before using credentials', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'named-account-')); + try { + const { release } = await fixture(root); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'test-id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'test-secret'); + const fetch = vi.fn(() => Response.json(identity)); + vi.stubGlobal('fetch', fetch); + await expect( + verifyNamedPreview( + { ...config, previewUrl: 'https://app-preview.foreign-account.workers.dev' }, + release, + ), + ).rejects.toThrow('reviewed Worker'); + expect(fetch).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/pr-preview-comment.test.ts b/.lvbt/web-platform/packages/web-platform/tests/pr-preview-comment.test.ts new file mode 100644 index 0000000..f40421e --- /dev/null +++ b/.lvbt/web-platform/packages/web-platform/tests/pr-preview-comment.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { prPreviewComment } from '../src/pr-preview-comment.mjs'; + +test('profile previews have independent sticky markers and visible profile names', () => { + const map = prPreviewComment('map', 'https://map-pr-42.example.workers.dev'); + const news = prPreviewComment('news', 'https://news-pr-42.example.workers.dev'); + expect(map.marker).toBe(''); + expect(news.marker).toBe(''); + expect(map.body).toContain('Worker candidate (map): https://map-pr-42.example.workers.dev'); + expect(map.body.includes(news.marker)).toBe(false); + expect(news.body.includes(map.marker)).toBe(false); +}); +test('a single unprofiled consumer preserves its existing sticky comment', () => { + const comment = prPreviewComment('', 'https://app-pr-42.example.workers.dev'); + expect(comment.marker).toBe(''); + expect(comment.body).toContain('Worker candidate: https://app-pr-42.example.workers.dev'); +}); +test('unreviewed profile strings cannot forge another comment marker', () => { + expect(() => prPreviewComment('map -->\n