diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dffa1f2..fa1206e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,6 +39,12 @@ jobs: node-version: 22 cache: pnpm + - name: Setup OpenTofu + uses: opentofu/setup-opentofu@v1 + with: + tofu_version: 1.11.5 + tofu_wrapper: false + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -57,6 +63,9 @@ jobs: - name: Check workspace run: npm run check:workspace + - name: Check infra formatting + run: npm run infra:fmt:check + - name: Test packages with coverage run: npm run test:coverage diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..bbb2528 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,148 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + environment: + description: "Target deployment environment" + required: true + type: choice + options: + - staging + - production + deploy_worker: + description: "Deploy the Cloudflare Worker" + required: true + type: boolean + default: true + deploy_pages: + description: "Deploy the Cloudflare Pages app" + required: true + type: boolean + default: true + run_hosted_smoke: + description: "Run hosted smoke checks after deploy" + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: deploy-${{ github.workflow }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + name: Deploy ${{ inputs.environment }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: ${{ inputs.environment }} + env: + CI: true + WRANGLER_SEND_METRICS: false + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME || 'forage-web' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Setup OpenTofu + uses: opentofu/setup-opentofu@v1 + with: + tofu_version: 1.11.5 + tofu_wrapper: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve deployment environment + id: resolve + shell: bash + run: | + set -euo pipefail + + environment_name="${{ inputs.environment }}" + case "$environment_name" in + production) + web_origin="${{ vars.FORAGE_PRODUCTION_WEB_ORIGIN }}" + worker_origin="${{ vars.FORAGE_PRODUCTION_WORKER_ORIGIN }}" + pages_branch="${{ vars.FORAGE_PRODUCTION_PAGES_BRANCH }}" + default_pages_branch="main" + ;; + staging) + web_origin="${{ vars.FORAGE_STAGING_WEB_ORIGIN }}" + worker_origin="${{ vars.FORAGE_STAGING_WORKER_ORIGIN }}" + pages_branch="${{ vars.FORAGE_STAGING_PAGES_BRANCH }}" + default_pages_branch="staging" + ;; + *) + echo "::error::Unsupported deployment environment: $environment_name" + exit 1 + ;; + esac + + if [ -z "$worker_origin" ]; then + echo "::error::Set FORAGE_${environment_name^^}_WORKER_ORIGIN as a GitHub environment variable." + exit 1 + fi + + if [ "${{ inputs.run_hosted_smoke }}" = "true" ] && [ -z "$web_origin" ]; then + echo "::error::Set FORAGE_${environment_name^^}_WEB_ORIGIN as a GitHub environment variable before hosted smoke checks." + exit 1 + fi + + if [ -z "$pages_branch" ]; then + pages_branch="$default_pages_branch" + fi + + { + echo "environment_name=$environment_name" + echo "web_origin=$web_origin" + echo "worker_origin=$worker_origin" + echo "pages_branch=$pages_branch" + } >> "$GITHUB_OUTPUT" + + - name: Check infra formatting + run: npm run infra:fmt:check + + - name: Build Worker + run: pnpm --filter @forage/worker build + + - name: Build web app + env: + PUBLIC_WORKER_ORIGIN: ${{ steps.resolve.outputs.worker_origin }} + run: pnpm --filter @forage/web build + + - name: Deploy Worker + if: ${{ inputs.deploy_worker }} + working-directory: apps/worker + run: pnpm exec wrangler deploy --env "${{ steps.resolve.outputs.environment_name }}" + + - name: Deploy Pages + if: ${{ inputs.deploy_pages }} + working-directory: apps/worker + run: | + pnpm exec wrangler pages deploy ../../apps/web/dist \ + --project-name "$CLOUDFLARE_PAGES_PROJECT_NAME" \ + --branch "${{ steps.resolve.outputs.pages_branch }}" + + - name: Hosted smoke check + if: ${{ inputs.run_hosted_smoke }} + env: + FORAGE_WEB_ORIGIN: ${{ steps.resolve.outputs.web_origin }} + FORAGE_WORKER_ORIGIN: ${{ steps.resolve.outputs.worker_origin }} + run: pnpm smoke:hosted diff --git a/docs/17-ci-and-quality-gates.md b/docs/17-ci-and-quality-gates.md index 115ebd2..7383440 100644 --- a/docs/17-ci-and-quality-gates.md +++ b/docs/17-ci-and-quality-gates.md @@ -20,6 +20,7 @@ Current `npm run check` coverage: - Analysis review fixture check - Documentation checks - Workspace structure checks +- OpenTofu formatting for `infra/opentofu` - Package tests with coverage - Worker API contract tests - Biome lint and format checks for JavaScript, TypeScript, CSS, and JSON @@ -74,5 +75,5 @@ Future gates: - Raise package source coverage from 75% to 90% after the MVP worker/import state boundaries stabilize. - Add package-level unit tests for IndexedDB migrations. - Add browser smoke tests once the app has stable flows beyond the current import slice. -- Add deployment preview checks when Cloudflare environment bindings are defined. -- Add a scheduled or manual CI job for `pnpm smoke:hosted` once Cloudflare domains are configured. +- Expand deployment preview checks after Cloudflare environment bindings are proven. +- Enable hosted smoke checks by default once Cloudflare domains are configured. diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index 7daba81..4cbfce2 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -149,10 +149,20 @@ Required repository settings: Recommended repository secrets for deployment CI: - `CLOUDFLARE_API_TOKEN` - `CLOUDFLARE_ACCOUNT_ID` -- Any project-specific deploy token required by the chosen deployment workflow Do not store GitHub App client secrets as public repository variables. +Recommended GitHub environment variables for the manual deploy workflow: +- `CLOUDFLARE_PAGES_PROJECT_NAME` +- `FORAGE_STAGING_WEB_ORIGIN` +- `FORAGE_STAGING_WORKER_ORIGIN` +- `FORAGE_STAGING_PAGES_BRANCH` +- `FORAGE_PRODUCTION_WEB_ORIGIN` +- `FORAGE_PRODUCTION_WORKER_ORIGIN` +- `FORAGE_PRODUCTION_PAGES_BRANCH` + +Use GitHub environments named `staging` and `production`. Production should require manual approval before `.github/workflows/deploy.yml` can deploy to it. + ## Post-Deploy Verification Run the automated hosted smoke check: diff --git a/docs/22-infrastructure-as-code.md b/docs/22-infrastructure-as-code.md index cbfeaaa..baa3dd4 100644 --- a/docs/22-infrastructure-as-code.md +++ b/docs/22-infrastructure-as-code.md @@ -57,6 +57,8 @@ Set `manage_worker_custom_domains = false` until the Worker service has been dep After apply, copy the `settings_kv_namespaces` output into the Worker binding configuration as `SETTINGS_KV` for each environment. +Use [Deployment Automation](./23-deployment-automation.md) for the GitHub Actions workflow, repository secrets, GitHub environment variables, and first deployment order. + ## Sources Checked - [Cloudflare Terraform overview](https://developers.cloudflare.com/terraform/) diff --git a/docs/23-deployment-automation.md b/docs/23-deployment-automation.md new file mode 100644 index 0000000..fcb6e78 --- /dev/null +++ b/docs/23-deployment-automation.md @@ -0,0 +1,91 @@ +# Deployment Automation + +Status: +Manual GitHub Actions workflow scaffolded + +Forage deploys through `.github/workflows/deploy.yml`. The workflow is manual-only for now so staging and production deploys remain operator-controlled while the Cloudflare hosting path is still being proven. + +## Workflow Inputs + +- `environment`: `staging` or `production` +- `deploy_worker`: deploy `apps/worker` with Wrangler +- `deploy_pages`: deploy `apps/web/dist` to Cloudflare Pages with Wrangler direct upload +- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment + +## GitHub Repository Secrets + +Configure these as repository or environment secrets: + +- `CLOUDFLARE_API_TOKEN` +- `CLOUDFLARE_ACCOUNT_ID` + +The Cloudflare API token should be scoped narrowly to the Forage account and resources needed for Worker and Pages deployment. Keep GitHub App client secrets in Cloudflare Worker secrets, not GitHub repository variables. + +## GitHub Environment Variables + +Configure these on the `staging` and `production` GitHub environments: + +- `CLOUDFLARE_PAGES_PROJECT_NAME` +- `FORAGE_STAGING_WEB_ORIGIN` +- `FORAGE_STAGING_WORKER_ORIGIN` +- `FORAGE_STAGING_PAGES_BRANCH` +- `FORAGE_PRODUCTION_WEB_ORIGIN` +- `FORAGE_PRODUCTION_WORKER_ORIGIN` +- `FORAGE_PRODUCTION_PAGES_BRANCH` + +Defaults: + +- Pages project defaults to `forage-web` when `CLOUDFLARE_PAGES_PROJECT_NAME` is unset. +- Staging Pages branch defaults to `staging` when `FORAGE_STAGING_PAGES_BRANCH` is unset. +- Production Pages branch defaults to `main` when `FORAGE_PRODUCTION_PAGES_BRANCH` is unset. + +`FORAGE_*_WORKER_ORIGIN` is required because the Astro build compiles `PUBLIC_WORKER_ORIGIN` into the web app and CSP. `FORAGE_*_WEB_ORIGIN` is required when hosted smoke checks are enabled. + +## First Deploy Order + +1. Apply `infra/opentofu` with `manage_worker_custom_domains = false`. +2. Configure Worker secrets in Cloudflare. +3. Deploy the Worker once with Wrangler or the manual deploy workflow. +4. Enable Worker custom domains in OpenTofu if the Worker service now exists. +5. Apply OpenTofu again. +6. Deploy Pages through the manual deploy workflow. +7. Run the hosted smoke check. + +This order avoids the Cloudflare custom-domain dependency where the Worker service must exist before OpenTofu can attach Worker domains. + +## Workflow Behavior + +Worker deploy: + +```sh +pnpm --filter @forage/worker build +cd apps/worker +pnpm exec wrangler deploy --env staging +``` + +Pages deploy: + +```sh +PUBLIC_WORKER_ORIGIN=https://api-staging.forage.example.com pnpm --filter @forage/web build +cd apps/worker +pnpm exec wrangler pages deploy ../../apps/web/dist --project-name forage-web --branch staging +``` + +Hosted smoke: + +```sh +FORAGE_WEB_ORIGIN=https://staging.forage.example.com \ +FORAGE_WORKER_ORIGIN=https://api-staging.forage.example.com \ +pnpm smoke:hosted +``` + +## Quality Gates + +The main Check workflow installs OpenTofu and runs `npm run infra:fmt:check`. The root `npm run check` also includes the same infra formatting check so local and CI gates stay aligned. + +## Sources Checked + +- [Cloudflare Pages direct upload](https://developers.cloudflare.com/pages/get-started/direct-upload/) +- [Cloudflare Workers GitHub Actions](https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/) +- [Wrangler GitHub Action](https://github.com/cloudflare/wrangler-action) +- [OpenTofu setup action](https://github.com/opentofu/setup-opentofu) diff --git a/docs/README.md b/docs/README.md index edd779b..06f1d0a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ This directory contains the planning and technical decision material for Forage. - [Hosting And Security Plan](./20-hosting-and-security.md) - [Hosting UI Setup](./21-hosting-ui-setup.md) - [Infrastructure As Code](./22-infrastructure-as-code.md) +- [Deployment Automation](./23-deployment-automation.md) ## Architecture Decisions diff --git a/package.json b/package.json index f945ef3..00e5686 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "analysis:review": "node --import tsx scripts/analysis/review-export.ts", "build": "pnpm --recursive --if-present build", - "check": "npm run check:scripts && npm run check:pre-mvp && npm run check:analysis && npm run check:docs && npm run check:workspace && npm run test:coverage && npm run check:web-tests && npm run check:worker-tests && npm run lint && npm run format:astro:check && npm run typecheck && npm run build && npm run check:web-smoke", + "check": "npm run check:scripts && npm run check:pre-mvp && npm run check:analysis && npm run check:docs && npm run check:workspace && npm run infra:fmt:check && npm run test:coverage && npm run check:web-tests && npm run check:worker-tests && npm run lint && npm run format:astro:check && npm run typecheck && npm run build && npm run check:web-smoke", "check:analysis": "pnpm analysis:review scripts/analysis/fixtures/forage-export.sample.json > /dev/null", "check:docs": "node scripts/check-docs.mjs", "check:reporting": "pnpm --filter @forage/reporting test",