diff --git a/.github/scripts/verify-firebase-release.mjs b/.github/scripts/verify-firebase-release.mjs new file mode 100644 index 0000000..3be681d --- /dev/null +++ b/.github/scripts/verify-firebase-release.mjs @@ -0,0 +1,307 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const FUNCTION_SPECS = Object.freeze({ + createMemberOnSignUp: Object.freeze({ + entryPoint: 'createMemberOnSignUp', + trigger: 'event', + eventType: 'providers/firebase.auth/eventTypes/user.create', + }), + ensureMemberProfile: Object.freeze({ + entryPoint: 'ensureMemberProfile', + trigger: 'https', + }), +}); + +const REGION = 'us-central1'; +const RUNTIME = 'nodejs20'; +const FIRESTORE_RELEASE_REQUEST = 'cloud.firestore/(default)'; +const FIRESTORE_RELEASE_CANONICAL = 'cloud.firestore'; +const PROJECT_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; +const VERSION_PATTERN = /^\d+$/; + +export function normalizeRulesSource(source) { + return `${source.replace(/\r\n/g, '\n').trimEnd()}\n`; +} + +export function digestRulesSource(source) { + return createHash('sha256').update(normalizeRulesSource(source)).digest('hex'); +} + +export function firestoreReleaseNames(project) { + return { + requestName: `projects/${project}/releases/${FIRESTORE_RELEASE_REQUEST}`, + canonicalName: `projects/${project}/releases/${FIRESTORE_RELEASE_CANONICAL}`, + }; +} + +function summarizeFunction(id, value) { + if (!value) return null; + return { + id, + name: value.name, + status: value.status, + entryPoint: value.entryPoint, + runtime: value.runtime, + updateTime: value.updateTime, + versionId: value.versionId, + buildId: value.buildId, + trigger: value.httpsTrigger ? 'https' : value.eventTrigger ? 'event' : 'unknown', + eventType: value.eventTrigger?.eventType, + }; +} + +function summarizeRules(release, ruleset) { + if (!release || !ruleset) return null; + const files = Array.isArray(ruleset.source?.files) + ? ruleset.source.files.map((file) => ({ + name: file.name, + digest: digestRulesSource(file.content ?? ''), + })) + : []; + return { + releaseName: release.name, + rulesetName: release.rulesetName, + updateTime: release.updateTime, + files, + }; +} + +export function validateBackendState(before, after, expectedRulesDigest) { + const errors = []; + const previousFunctions = before?.functions ?? {}; + const currentFunctions = after?.functions ?? {}; + const { canonicalName: expectedReleaseName } = firestoreReleaseNames(after?.project); + const expectedRulesetPrefix = `projects/${after?.project}/rulesets/`; + + const hasExpectedRulesIdentity = (rules) => { + if (rules?.releaseName !== expectedReleaseName) return false; + if (typeof rules.rulesetName !== 'string') return false; + if (!rules.rulesetName.startsWith(expectedRulesetPrefix)) return false; + const rulesetId = rules.rulesetName.slice(expectedRulesetPrefix.length); + return rulesetId.length > 0 && !rulesetId.includes('/'); + }; + + const hasExactRulesSource = (rules) => Array.isArray(rules?.files) + && rules.files.length === 1 + && rules.files[0]?.name === 'firestore.rules' + && rules.files[0]?.digest === expectedRulesDigest; + + if (typeof after?.project !== 'string' || after.project !== before?.project) { + errors.push('Backend project readback does not match the approved environment.'); + } + + if (!after?.rules) { + errors.push('Firestore Rules release is unreadable.'); + } else { + if (!hasExpectedRulesIdentity(after.rules)) { + errors.push('Firestore Rules release has the wrong project or ruleset identity.'); + } + const rulesAlreadyExact = before?.project === after.project + && hasExpectedRulesIdentity(before?.rules) + && hasExactRulesSource(before?.rules); + if ( + after.rules.rulesetName === before?.rules?.rulesetName + && !rulesAlreadyExact + ) { + errors.push('Firestore Rules release did not advance.'); + } + if (!hasExactRulesSource(after.rules)) { + errors.push('Firestore Rules source does not match the approved commit.'); + } + } + + for (const [id, spec] of Object.entries(FUNCTION_SPECS)) { + const current = currentFunctions[id]; + const previous = previousFunctions[id]; + if (!current) { + errors.push(`${id} is unreadable.`); + continue; + } + if (current.name !== `projects/${after.project}/locations/${REGION}/functions/${id}`) { + errors.push(`${id} has the wrong project, region, or generation.`); + } + if (current.status !== 'ACTIVE') { + errors.push(`${id} is not ACTIVE.`); + } + if (current.runtime !== RUNTIME || current.entryPoint !== spec.entryPoint) { + errors.push(`${id} has the wrong runtime or entry point.`); + } + if (current.trigger !== spec.trigger) { + errors.push(`${id} has the wrong trigger type.`); + } + if (spec.eventType && current.eventType !== spec.eventType) { + errors.push(`${id} has the wrong event trigger.`); + } + const hasCurrentRevision = VERSION_PATTERN.test(current.versionId ?? '') + && typeof current.updateTime === 'string' + && Number.isFinite(Date.parse(current.updateTime)) + && typeof current.buildId === 'string' + && current.buildId.length > 0; + if (!hasCurrentRevision) { + errors.push(`${id} revision metadata is unreadable.`); + continue; + } + if ( + previous + && ( + !VERSION_PATTERN.test(previous.versionId ?? '') + || typeof previous.updateTime !== 'string' + || !Number.isFinite(Date.parse(previous.updateTime)) + || typeof previous.buildId !== 'string' + || previous.buildId.length === 0 + || BigInt(current.versionId) <= BigInt(previous.versionId) + || Date.parse(current.updateTime) <= Date.parse(previous.updateTime) + || current.buildId === previous.buildId + ) + ) { + errors.push(`${id} revision did not advance.`); + } + } + + return errors; +} + +async function getJson(url, token, { allowNotFound = false } = {}) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (allowNotFound && response.status === 404) return null; + if (!response.ok) { + throw new Error(`Provider readback failed with HTTP ${response.status}.`); + } + return response.json(); +} + +async function readBackendState(project, token) { + const { requestName: releaseName } = firestoreReleaseNames(project); + const release = await getJson( + `https://firebaserules.googleapis.com/v1/${releaseName}`, + token, + { allowNotFound: true }, + ); + const ruleset = release?.rulesetName + ? await getJson( + `https://firebaserules.googleapis.com/v1/${release.rulesetName}`, + token, + ) + : null; + + const functions = {}; + for (const id of Object.keys(FUNCTION_SPECS)) { + const name = `projects/${project}/locations/${REGION}/functions/${id}`; + const value = await getJson( + `https://cloudfunctions.googleapis.com/v1/${name}`, + token, + { allowNotFound: true }, + ); + functions[id] = summarizeFunction(id, value); + } + + return { + project, + capturedAt: new Date().toISOString(), + rules: summarizeRules(release, ruleset), + functions, + }; +} + +function parseArguments(argv) { + const [command, ...rest] = argv; + const values = { command }; + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index]; + const value = rest[index + 1]; + if (!key?.startsWith('--') || value === undefined) { + throw new Error('Invalid verification arguments.'); + } + values[key.slice(2)] = value; + } + return values; +} + +function requireProject(project) { + if (!PROJECT_PATTERN.test(project ?? '')) { + throw new Error('Approved Firebase project ID is missing or invalid.'); + } +} + +function writePrivateJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +async function capture(values, token) { + requireProject(values.project); + if (!values.output) throw new Error('Private pre-state output path is required.'); + const state = await readBackendState(values.project, token); + writePrivateJson(values.output, state); + console.log('Captured the current backend state for private comparison.'); +} + +async function verify(values, token) { + requireProject(values.project); + if (!values.before || !values.rules) { + throw new Error('Pre-state and approved Rules source are required.'); + } + const before = JSON.parse(fs.readFileSync(values.before, 'utf8')); + if (before.project !== values.project) { + throw new Error('Pre-state project does not match the approved environment.'); + } + + const expectedRulesDigest = digestRulesSource(fs.readFileSync(values.rules, 'utf8')); + const attempts = Number.parseInt(process.env.FIREBASE_VERIFY_ATTEMPTS ?? '30', 10); + const intervalMs = Number.parseInt(process.env.FIREBASE_VERIFY_INTERVAL_MS ?? '10000', 10); + if (!Number.isInteger(attempts) || attempts < 1 || attempts > 60) { + throw new Error('Verification attempt limit is invalid.'); + } + if (!Number.isInteger(intervalMs) || intervalMs < 0 || intervalMs > 30000) { + throw new Error('Verification interval is invalid.'); + } + + let lastErrors = ['Backend readback did not run.']; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const after = await readBackendState(values.project, token); + lastErrors = validateBackendState(before, after, expectedRulesDigest); + if (lastErrors.length === 0) { + if (values['deploy-succeeded'] !== 'true') { + throw new Error( + 'Deployment command failed; readable backend state is not release proof.', + ); + } + console.log('Rules source and both active Function revisions are verified.'); + return; + } + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + throw new Error(`Backend verification failed: ${lastErrors.join(' ')}`); +} + +async function main() { + const values = parseArguments(process.argv.slice(2)); + const token = process.env.CLOUD_ACCESS_TOKEN; + if (!token) throw new Error('Short-lived provider readback authority is missing.'); + + if (values.command === 'capture') { + await capture(values, token); + } else if (values.command === 'verify') { + await verify(values, token); + } else { + throw new Error('Unknown verification command.'); + } +} + +if ( + process.argv[1] + && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : 'Backend verification failed.'); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a77b68e..8acbd07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: run: npm test -- --watchAll=false --runInBand - name: Run SPA callback safety tests run: npm run test:spa-navigation + - name: Validate protected release workflow + run: | + ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' + node --test tests/release-workflow.test.js tests/firebase-release-verification.test.js - run: npm run lint:fix -- --max-warnings=0 || true - name: Build env: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cc64f30..dbe336e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,72 +1,491 @@ -name: Deploy +name: Protected release + +run-name: >- + ${{ inputs.deployment_environment }} ${{ inputs.release_plan }} from + ${{ inputs.source_commit }} on: - push: - branches: [master, main] workflow_dispatch: + inputs: + source_commit: + description: Full 40-character commit at the current tip of main + required: true + type: string + deployment_environment: + description: Protected release environment + required: true + type: choice + options: + - staging + - production + release_plan: + description: Fixed reviewed backend set; add another plan by pull request + required: true + type: choice + options: + - profile-recovery + +permissions: {} + +concurrency: + group: release-${{ inputs.deployment_environment }} + cancel-in-progress: false jobs: - deploy-frontend: - name: Deploy frontend to GitHub Pages + preflight: + name: Validate exact current-main release runs-on: ubuntu-latest + timeout-minutes: 5 permissions: - contents: write + actions: read + contents: read + outputs: + ci_run_id: ${{ steps.validate.outputs.ci_run_id }} + source_commit: ${{ steps.validate.outputs.source_commit }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Check out canonical main history + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: main + - id: validate + name: Require current main and its successful push CI + env: + GH_TOKEN: ${{ github.token }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + + if [[ "$WORKFLOW_REF" != "refs/heads/main" ]]; then + echo "Release workflow must run from main." >&2 + exit 1 + fi + if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "Source commit must be a full lowercase Git SHA." >&2 + exit 1 + fi + + git fetch --no-tags origin refs/heads/main + current_main="$(git rev-parse origin/main)" + if [[ "$SOURCE_COMMIT" != "$current_main" ]]; then + echo "Source commit must equal the current tip of main." >&2 + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "$SOURCE_COMMIT" ]]; then + echo "Checked-out source does not match the approved commit." >&2 + exit 1 + fi + + runs_file="$(mktemp)" + jobs_file="$(mktemp)" + trap 'rm -f "$runs_file" "$jobs_file"' EXIT + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs" \ + -f head_sha="$SOURCE_COMMIT" \ + -f branch=main \ + -f event=push \ + -f per_page=100 > "$runs_file" + + ci_run_id="$(jq -er --arg sha "$SOURCE_COMMIT" ' + .workflow_runs + | map(select( + .head_sha == $sha + and .head_branch == "main" + and .event == "push" + )) + | sort_by([.run_number, (.run_attempt // 0)]) + | last + | select(.status == "completed" and .conclusion == "success") + | .id + ' "$runs_file")" || { + echo "The latest exact main-push CI run is not successful." >&2 + exit 1 + } + + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ci_run_id}/jobs" \ + -f filter=latest \ + -f per_page=100 > "$jobs_file" + + for required_job in \ + "Frontend lint + build" \ + "Cloud Functions lint + test" \ + "Firestore security-rules tests" + do + if ! jq -e --arg name "$required_job" ' + [.jobs[] | select( + .name == $name + and .status == "completed" + and .conclusion == "success" + )] | length == 1 + ' "$jobs_file" >/dev/null + then + echo "Required CI job is not exactly one successful result: $required_job" >&2 + exit 1 + fi + done + + echo "ci_run_id=$ci_run_id" >> "$GITHUB_OUTPUT" + echo "source_commit=$SOURCE_COMMIT" >> "$GITHUB_OUTPUT" + echo "Preflight passed for current main and its exact push CI run." + + prepare-pages: + name: Build immutable website artifact without cloud authority + if: ${{ inputs.deployment_environment == 'production' }} + needs: preflight + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Validate required public build configuration + env: + PUBLIC_RECAPTCHA_SITE_KEY: ${{ vars.REACT_APP_RECAPTCHA_SITE_KEY }} + run: | + set -euo pipefail + if [[ -z "$PUBLIC_RECAPTCHA_SITE_KEY" ]]; then + echo "Required public App Check site key is missing." >&2 + exit 1 + fi + echo "Required public website configuration is present." + - name: Check out the approved current-main commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.source_commit }} + persist-credentials: false + - name: Confirm website source commit + env: + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] || { + echo "Website source does not match the approved commit." >&2 + exit 1 + } + - name: Set up locked Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '20' - cache: 'npm' - - run: npm ci --legacy-peer-deps - - name: Build + cache: npm + - name: Install committed website dependencies + run: npm ci --legacy-peer-deps + - name: Build public website artifact env: - CI: 'false' + CI: 'true' DISABLE_ESLINT_PLUGIN: 'true' SITE_ORIGIN: 'https://runmprc.com' - GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/firebase-sa.json - FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} - REACT_APP_RECAPTCHA_SITE_KEY: ${{ secrets.REACT_APP_RECAPTCHA_SITE_KEY }} - REACT_APP_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} + REACT_APP_RECAPTCHA_SITE_KEY: ${{ vars.REACT_APP_RECAPTCHA_SITE_KEY }} + REACT_APP_SENTRY_DSN: ${{ vars.REACT_APP_SENTRY_DSN }} + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} run: | - if [ -n "$FIREBASE_SERVICE_ACCOUNT" ]; then - echo "$FIREBASE_SERVICE_ACCOUNT" > "$GOOGLE_APPLICATION_CREDENTIALS" - fi + set -euo pipefail npm run build - - name: Deploy to gh-pages - uses: peaceiris/actions-gh-pages@v4 + printf '%s\n' "$SOURCE_COMMIT" > build/release-commit.txt + - name: Reject server-credential material in the website artifact + run: | + set -euo pipefail + if grep -R -q -E \ + 'FIREBASE_SERVICE_ACCOUNT|FIREBASE_TOKEN|GOOGLE_APPLICATION_CREDENTIALS|BEGIN (RSA )?PRIVATE KEY' \ + build + then + echo "Server credential material was found in the website artifact." >&2 + exit 1 + fi + - name: Retain the exact prebuilt Pages artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./build - cname: runmprc.com + name: pages-${{ needs.preflight.outputs.source_commit }} + path: build + if-no-files-found: error + retention-days: 30 - deploy-functions: - name: Deploy Cloud Functions + Firestore rules + deploy-backend: + name: Deploy and verify fixed Firebase backend + if: ${{ always() && needs.preflight.result == 'success' && (inputs.deployment_environment == 'staging' || needs.prepare-pages.result == 'success') }} + needs: + - preflight + - prepare-pages runs-on: ubuntu-latest - needs: deploy-frontend + timeout-minutes: 40 + environment: + name: ${{ inputs.deployment_environment }} + permissions: + actions: read + contents: read + id-token: write + env: + DEPLOYMENT_ENVIRONMENT: ${{ inputs.deployment_environment }} + RELEASE_PLAN: ${{ inputs.release_plan }} + TARGET_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} + outputs: + backend_verified: ${{ steps.verify.outputs.backend_verified }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Validate protected environment mapping + env: + DEPLOY_SERVICE_ACCOUNT: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + run: | + set -euo pipefail + + if [[ "$DEPLOYMENT_ENVIRONMENT" == "staging" ]]; then + echo "Staging is unavailable until #113/#133 add one exact approved project." >&2 + exit 1 + fi + if [[ "$DEPLOYMENT_ENVIRONMENT" != "production" ]]; then + echo "Unsupported release environment." >&2 + exit 1 + fi + if [[ "$RELEASE_PLAN" != "profile-recovery" ]]; then + echo "Unsupported release plan." >&2 + exit 1 + fi + if [[ "$TARGET_PROJECT_ID" != "mid-peninsula-running-club" ]]; then + echo "Production project does not match the reviewed target." >&2 + exit 1 + fi + if [[ -z "$WORKLOAD_IDENTITY_PROVIDER" || -z "$DEPLOY_SERVICE_ACCOUNT" ]]; then + echo "Required protected cloud authority is missing." >&2 + exit 1 + fi + if [[ ! "$WORKLOAD_IDENTITY_PROVIDER" =~ ^projects/[0-9]+/locations/global/workloadIdentityPools/[A-Za-z0-9._-]+/providers/[A-Za-z0-9._-]+$ ]]; then + echo "Workload Identity provider configuration is invalid." >&2 + exit 1 + fi + if [[ ! "$DEPLOY_SERVICE_ACCOUNT" =~ ^[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.iam\.gserviceaccount\.com$ ]]; then + echo "Deploy service account configuration is invalid." >&2 + exit 1 + fi + echo "Protected production mapping and authority are present." + - name: Check out the approved current-main commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.source_commit }} + persist-credentials: false + - name: Confirm fixed source and target set + env: + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] || { + echo "Backend source does not match the approved commit." >&2 + exit 1 + } + grep -q 'exports.createMemberOnSignUp' functions/index.js + grep -q 'exports.ensureMemberProfile' functions/index.js + test -s firestore.rules + echo "Exact source commit and fixed release resources are present." + - name: Set up locked Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '20' - - run: npm ci - working-directory: functions - - name: Deploy to Firebase - uses: FirebaseExtended/action-hosting-deploy@v0 - if: ${{ false }} # Swap to a firebase-tools deploy step when ready; this placeholder keeps the job declarative. + cache: npm + cache-dependency-path: | + package-lock.json + functions/package-lock.json + - name: Install committed deploy dependencies without lifecycle scripts + run: | + set -euo pipefail + npm ci --legacy-peer-deps --ignore-scripts + npm --prefix functions ci --ignore-scripts + - name: Verify the lockfile Firebase CLI before cloud authentication + run: | + set -euo pipefail + expected_version="$(node -p "require('./package-lock.json').packages['node_modules/firebase-tools'].version")" + actual_version="$(npx --no-install firebase --version)" + if [[ "$actual_version" != "$expected_version" ]]; then + echo "Firebase CLI does not match package-lock.json." >&2 + exit 1 + fi + echo "Using the Firebase CLI version committed in package-lock.json." + - name: Confirm the prebuilt Pages artifact is still available after approval + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} - projectId: mid-peninsula-running-club - - name: Deploy via firebase-tools + name: pages-${{ needs.preflight.outputs.source_commit }} + path: ${{ runner.temp }}/prebuilt-pages + - name: Recheck the prebuilt artifact before backend mutation env: - FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} - GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/firebase-sa.json - FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} run: | - npm install -g firebase-tools@latest - if [ -n "$FIREBASE_SERVICE_ACCOUNT" ]; then - echo "$FIREBASE_SERVICE_ACCOUNT" > "$GOOGLE_APPLICATION_CREDENTIALS" - firebase deploy --only firestore:rules,firestore:indexes,functions --project mid-peninsula-running-club --non-interactive - else - echo "No FIREBASE_SERVICE_ACCOUNT secret; skipping Firebase deploy" + set -euo pipefail + artifact="$RUNNER_TEMP/prebuilt-pages" + [[ "$(cat "$artifact/release-commit.txt")" == "$SOURCE_COMMIT" ]] || { + echo "Prebuilt website artifact no longer matches the approved commit." >&2 + exit 1 + } + if grep -R -q -E \ + 'FIREBASE_SERVICE_ACCOUNT|FIREBASE_TOKEN|GOOGLE_APPLICATION_CREDENTIALS|BEGIN (RSA )?PRIVATE KEY' \ + "$artifact" + then + echo "Server credential material was found in the website artifact." >&2 + exit 1 fi + echo "The exact credential-free website artifact remains available." + - name: Revalidate current main and exact CI after protected approval + env: + CI_RUN_ID: ${{ needs.preflight.outputs.ci_run_id }} + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ github.run_id }} + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} + run: | + set -euo pipefail + + git fetch --no-tags origin refs/heads/main + [[ "$(git rev-parse origin/main)" == "$SOURCE_COMMIT" ]] || { + echo "Main advanced after this release was requested; request a new release." >&2 + exit 1 + } + [[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] || { + echo "Backend source no longer matches the approved commit." >&2 + exit 1 + } + + runs_file="$(mktemp)" + jobs_file="$(mktemp)" + release_file="$(mktemp)" + trap 'rm -f "$runs_file" "$jobs_file" "$release_file"' EXIT + + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_RUN_ID}" \ + > "$release_file" + release_created_at="$(jq -er '.created_at' "$release_file")" + release_created_epoch="$(date -u -d "$release_created_at" +%s)" + current_epoch="$(date -u +%s)" + if (( current_epoch - release_created_epoch > 86400 )); then + echo "Release request is older than 24 hours; request a new release." >&2 + exit 1 + fi + + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs" \ + -f head_sha="$SOURCE_COMMIT" \ + -f branch=main \ + -f event=push \ + -f per_page=100 > "$runs_file" + + validated_ci_run_id="$(jq -er --arg sha "$SOURCE_COMMIT" ' + .workflow_runs + | map(select( + .head_sha == $sha + and .head_branch == "main" + and .event == "push" + )) + | sort_by([.run_number, (.run_attempt // 0)]) + | last + | select(.status == "completed" and .conclusion == "success") + | .id + ' "$runs_file")" || { + echo "The newest exact main-push CI run is no longer successful." >&2 + exit 1 + } + if [[ "$validated_ci_run_id" != "$CI_RUN_ID" ]]; then + echo "The approved CI run is no longer the newest exact run." >&2 + exit 1 + fi + + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${validated_ci_run_id}/jobs" \ + -f filter=latest \ + -f per_page=100 > "$jobs_file" + for required_job in \ + "Frontend lint + build" \ + "Cloud Functions lint + test" \ + "Firestore security-rules tests" + do + if ! jq -e --arg name "$required_job" ' + [.jobs[] | select( + .name == $name + and .status == "completed" + and .conclusion == "success" + )] | length == 1 + ' "$jobs_file" >/dev/null + then + echo "Required CI job is no longer successful: $required_job" >&2 + exit 1 + fi + done + echo "Current main and its exact CI remain approved after the wait." + - id: auth + name: Obtain short-lived Google Cloud credentials + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + project_id: ${{ vars.FIREBASE_PROJECT_ID }} + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + create_credentials_file: true + cleanup_credentials: true + export_environment_variables: true + token_format: access_token + access_token_lifetime: 3300s + request_reason: mprc-release-${{ needs.preflight.outputs.source_commit }} + - id: capture + name: Capture private provider state before deployment + env: + CLOUD_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + run: >- + node .github/scripts/verify-firebase-release.mjs capture + --project "$TARGET_PROJECT_ID" + --output "$RUNNER_TEMP/firebase-before.json" + - id: deploy + name: Deploy the one fixed backend set + run: >- + timeout --signal=TERM --kill-after=30s 20m + npx --no-install firebase deploy + --only firestore:rules,functions:createMemberOnSignUp,functions:ensureMemberProfile + --project "$TARGET_PROJECT_ID" + --non-interactive + - id: verify + name: Always read back Rules and both Function revisions + if: ${{ always() && steps.auth.outcome == 'success' && steps.capture.outcome == 'success' }} + env: + CLOUD_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + DEPLOY_SUCCEEDED: ${{ steps.deploy.outcome == 'success' }} + run: | + set -euo pipefail + node .github/scripts/verify-firebase-release.mjs verify \ + --project "$TARGET_PROJECT_ID" \ + --before "$RUNNER_TEMP/firebase-before.json" \ + --rules firestore.rules \ + --deploy-succeeded "$DEPLOY_SUCCEEDED" + echo "backend_verified=true" >> "$GITHUB_OUTPUT" + + deploy-pages: + name: Publish the prebuilt GitHub Pages copy + if: ${{ inputs.deployment_environment == 'production' && needs.deploy-backend.result == 'success' && needs.deploy-backend.outputs.backend_verified == 'true' }} + needs: + - preflight + - prepare-pages + - deploy-backend + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download the exact prebuilt website artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: pages-${{ needs.preflight.outputs.source_commit }} + path: build + - name: Confirm artifact commit and credential separation + env: + SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} + run: | + set -euo pipefail + [[ "$(cat build/release-commit.txt)" == "$SOURCE_COMMIT" ]] || { + echo "Website artifact does not match the approved commit." >&2 + exit 1 + } + if grep -R -q -E \ + 'FIREBASE_SERVICE_ACCOUNT|FIREBASE_TOKEN|GOOGLE_APPLICATION_CREDENTIALS|BEGIN (RSA )?PRIVATE KEY' \ + build + then + echo "Server credential material was found in the website artifact." >&2 + exit 1 + fi + echo "The prebuilt website artifact matches the verified backend commit." + - name: Publish GitHub Pages copy + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 + with: + github_token: ${{ github.token }} + publish_dir: ./build diff --git a/.gitignore b/.gitignore index 3f4af8a..2317304 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,8 @@ dist # production /build +# Short-lived credentials generated by google-github-actions/auth. +gha-creds-*.json + # misc .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 71bbc8f..1a42d0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ CI=true DISABLE_ESLINT_PLUGIN=true npx --no-install react-scripts build The Rules suite needs Java 17. The direct `react-scripts build` command is preferred for a diagnostic build because normal `npm run build` regenerates the sitemap. Run dependency audits for security/dependency issues, but do not apply forced automatic upgrades. -The deterministic frontend Jest baseline is available through the command above. Hosted CI runs that command as the blocking `Run frontend Jest tests` step under #124. This proves the suite ran for that commit; it does not prove branch protection, website publication, Firebase deployment, provider configuration, or production behavior. The remaining fail-closed lint and deployment work stays open under #105. +The deterministic frontend Jest baseline is available through the command above. Hosted CI runs it as the blocking `Run frontend Jest tests` step under #124 and runs the standalone SPA suite under #126. #135 adds a tested manual, exact-commit, backend-first GitHub release gate and pauses Git-triggered Netlify production builds. This proves source shape only: protected environments/OIDC (#133), staged/live deployment (#136), fail-closed lint, required checks, Netlify publication, provider configuration, and production behavior remain separate work under #105 and WEB-001. For Firebase-backed local development, run `npm run emulators` first and wait until Auth, Firestore, and Functions are ready under `demo-mprc-local`. In a second terminal, run `npm start` and use `http://localhost:3000`. Stop if any browser Firebase request uses a non-loopback host. The emulator suite does not isolate Stripe, Strava, email, or other provider calls made by Functions; those flows remain forbidden until their test configuration and safe sink are separately proven. diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 2c6abb1..de5f655 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -137,12 +137,12 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. Optimized p ## CI-001 — Repair test gates and secure the deployment pipeline **Labels:** `priority:P0`, `type:security`, `type:testing`, `area:ci`, `size:L`, `needs-external-config` -**Status:** Published as tracker [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). [#103](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/103) merged the deterministic local frontend Jest baseline, and [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124) added its named blocking hosted-CI step. [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) owns the separate standalone SPA test gate. Fail-closed lint, required branch protection, dependency/secret checks, and protected backend-first deployment remain open, so the delivery pipeline is still non-compliant overall. +**Status:** Published as tracker [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). [#103](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/103), [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), and [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) provide the local/hosted frontend and SPA baselines. [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135) owns the manual exact-commit, backend-first source gate and Git-triggered Netlify production containment. [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) owns protected OIDC/environment configuration, and [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual profile-recovery release. Fail-closed lint, required checks, scanning, staging/live evidence, and protected Netlify publication remain open, so the pipeline is still non-compliant overall. **Depends on:** SAFETY-001 ### Problem -Before #103, frontend tests failed because the Jest environment lacked `TextEncoder`. That child repaired the local baseline, and #124 adds it to hosted CI. CI still runs a mutating `lint:fix` command and ignores its failure with `|| true`. Deployment still runs independently of CI, gives a full Firebase service-account JSON to the frontend build, installs `firebase-tools@latest`, deploys frontend before backend, and can skip or fail before proving Firebase deployment. +Before #103, frontend tests failed because the Jest environment lacked `TextEncoder`. That child repaired the local baseline, and #124 adds it to hosted CI. CI still runs a mutating `lint:fix` command and ignores its failure with `|| true`. #135 removes the automatic/fail-open release source defects: merge and release are separate, exact CI checks are re-read, release targets are fixed, OIDC replaces JSON-key wiring, lockfile tooling is used, backend precedes Pages, and Git-triggered Netlify production builds stop. The protected identity/environments and actual staged/live release remain outside that source slice. ### Scope @@ -179,7 +179,7 @@ Before #103, frontend tests failed because the Jest environment lacked `TextEnco ### Agent handoff -Repository changes can implement tests/workflow shape, but OIDC/IAM/environment protection requires an authorized owner. CI-001A/#103 owns test reliability, CI-001B1/#124 owns the hosted frontend-Jest step, CI-001B1A/#126 owns the standalone SPA step, and the remaining CI-001B2 deployment-identity/protection work must stay in separately claimed children. +Repository changes can implement tests/workflow shape, but OIDC/IAM/environment protection requires an authorized owner. CI-001A/#103 owns test reliability, CI-001B1/#124 owns hosted frontend Jest, CI-001B1A/#126 owns hosted SPA safety, CI-001B2/#135 owns the protected release source gate, #133 owns cloud/environment authority, and #136 owns staged/target release evidence. Keep fail-closed lint, scanning, required checks, and Netlify hosting work in separately claimed children. --- diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 8584509..c8ae543 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -18,8 +18,8 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | --- | --- | --- | --- | | CI-001A | merged/closed as #103 | Repair Jest browser globals and replace the stale starter test with a deterministic, no-provider-network MPRC app smoke test. | Node 20 focused/full frontend tests, changed-file lint, clean diff, and diagnostic build pass. | | CI-001B1 | merged/closed as #124; CI-001A | Run the complete committed frontend Jest suite as a named blocking step in the hosted frontend CI job; update exact officer/engineering truth. | Exact pull-request and post-merge hosted steps passed; Firebase skipped and Netlify exact production commit remained unverified. | -| CI-001B1A | published as #126; #99 and CI-001B1 | Run the standalone same-origin SPA callback suite as a separate blocking hosted frontend step. | Local deliberate-failure proof plus the named hosted step passing on the exact pull-request and post-merge commits; no provider callback claim. | -| CI-001B2 | owner_action; CI-001B1 | Protect backend-first staging/production deployment; make lint/remaining checks fail closed; pin tools/Actions; remove frontend service-account JSON; configure OIDC/WIF, approvals, missing-config failure, smoke and roll-forward. | Required PR gate and negative credential run; private two-owner IAM review; staging deployment evidence. | +| CI-001B1A | merged/closed as #126; #99 and CI-001B1 | Run the standalone same-origin SPA callback suite as a separate blocking hosted frontend step. | Local deliberate-failure proof plus the named hosted step passing on the exact pull-request and post-merge commits; no provider callback claim. | +| CI-001B2 | published as #135; CI-001B1 | Provide the tested manual exact-commit source gate: fixed Rules/Functions, lockfile CLI, pinned Actions, short-lived-auth wiring, backend-first Pages dependency, missing-config failure, no frontend credential, and Netlify Git-production containment. | Source/static negative tests and exact PR evidence. #133 separately owns protected OIDC/environment configuration; #136 owns staged/target release; WEB-001 owns protected Netlify publication. | | SUPPLY-001A | published as [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127); CI-001A | Prove `gpxparser` unused and remove its obsolete transitive chain. | `rg`/tree proof, before/after audits, root lockfile diff, full frontend checks. | | SUPPLY-001B | ready; CI-001A | Patch React Router within compatible 6.x and resolve/test future flags without route redesign. | Navigation/callback tests, audit delta, build. | | SUPPLY-001C | proposed; CI-001A | Upgrade Firebase web SDK within one compatibility boundary. | Auth/Firestore/Functions/App Check/emulator tests and bundle/audit diff. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 96a2900..7c365e2 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -28,7 +28,7 @@ MPRC can open a race or merchandise item for sale only when the platform can: 3. Schema changes are additive first. Backfills are idempotent, dry-runnable, and report counts/anomalies. 4. Backend/rules/indexes deploy before dependent clients. Legacy reads remain until migration evidence is complete. 5. No issue requires production secrets for development or CI. -6. Live-mode enabling must become a separate protected operation. The current `main` workflow automatically publishes Pages and attempts Firebase deployment on merge, so it is non-compliant until CI-001 is complete. +6. Live-mode enabling is a separate protected operation. #135 supplies a manual, exact-commit, backend-first source gate and pauses Git-triggered Netlify production builds. Protected environment/OIDC configuration, staged/live proof, fail-closed lint, required checks, and a protected live-Netlify path remain open under #105/#133/#136/WEB-001. 7. A provider Console setting is not complete until its non-secret configuration and verification evidence are recorded privately. 8. Security controls fail closed in hosted environments and remain developer-friendly only in explicit local/CI environments. 9. Do not trade payment integrity for UI responsiveness. Confirmation may say “processing”; it must not guess “paid.” diff --git a/OFFICER_HANDBOOK.md b/OFFICER_HANDBOOK.md index f25776c..c2b1445 100644 --- a/OFFICER_HANDBOOK.md +++ b/OFFICER_HANDBOOK.md @@ -9,18 +9,25 @@ The private Google Doc entry card titled **MPRC Website — Officer Start Here** ## The whole process ```mermaid -flowchart LR +flowchart TD Ask["Describe the result"] --> Track["One issue and pull request"] Track --> Preview["Preview and checks"] - Preview --> Approve{"Approve merge and its automatic deployment attempt?"} + Preview --> Approve{"Approve merge?"} Approve -- "No" --> Track - Approve -- "Yes" --> Merged["Merged to main"] - Merged --> Auto["Automatic Pages publication and Firebase attempt"] - Auto --> Verify["Check runmprc.com, Firebase, and providers separately"] + Approve -- "Yes" --> Merged["Merged to main — not released"] + Merged --> Request["Request one exact-commit release"] + Request --> Preflight["Check commit and tests; prepare public artifact"] + Preflight --> Release{"Approve protected environment?"} + Release -- "No" --> Merged + Release -- "Yes" --> Gate["Check project, scope, and authority"] + Gate --> Backend{"Firebase deployed and verified?"} + Backend -- "No" --> Stop["Stop — website is not published"] + Backend -- "Yes" --> Pages["Publish Pages branch without Netlify's domain name"] + Pages --> Verify["Check Pages, Netlify, runmprc.com, and providers separately"] Verify --> Record["Record proof and undo plan"] ``` -In words: ask and preview first; approving a merge also authorizes today's automatic deployment attempt; then verify each real service separately. +In words: approve the merge first; request one exact release; approve its protected environment after the source checks; Firebase must finish before the Pages copy; then check every real service separately. ## One-line request for AI @@ -41,12 +48,17 @@ In words: ask and preview first; approving a merge also authorizes today's autom ## Current facts that prevent false confidence -- Use the canonical [`main` branch](https://github.com/Run-MPRC/Run-MPRC.github.io/tree/main). The repository currently opens stale `dev` by default. +- Use the canonical [`main` branch](https://github.com/Run-MPRC/Run-MPRC.github.io/tree/main). - `runmprc.com` is currently served by Netlify. -- GitHub also publishes a separate Pages copy. -- A `main` merge automatically publishes the Pages copy; there is no separate Pages approval today. +- GitHub Pages currently still claims `runmprc.com`, so its normal address redirects to the Netlify-served name. It is not an independently reachable copy today. +- The new source stops writing that domain claim. #136/WEB-001 must publish and verify the provider setting before officers call the conflict cleared. +- A `main` merge runs checks but does not start the protected GitHub release. +- The protected release is **NOT AVAILABLE YET** until #133 configures its short-lived cloud identity and named approvers. +- The release fails when authority or required configuration is missing. It cannot report a green backend skip. +- Firebase must be verified before the Pages publication job can start. +- Git-triggered Netlify production builds are paused by repository configuration. - Independent officer publishing to the live Netlify host is **NOT AVAILABLE YET**. -- A green workflow can include “skipping Firebase deploy.” +- Netlify build hooks and provider settings remain unverified. The GitHub release does not claim to publish `runmprc.com`. - Live race registration, merchandise payments, and refunds are not approved. - There is no proven no-code switch that safely stops every new Stripe payment. @@ -76,12 +88,13 @@ Record separate answers: 1. What source changed? 2. What tests passed? 3. What pull request merged? -4. Was a website copy published? -5. Did Netlify identify the intended commit, or is that still unknown? -6. Was the exact result seen on `runmprc.com`? -7. Did Firebase actually deploy, without a skip message? -8. Was each affected outside provider configured and checked directly? -9. Who approved and checked the result? -10. How can the change be undone safely? +4. Which exact commit and environment were approved for release? +5. Did Firebase deploy and pass verification first? +6. Was the GitHub Pages branch published afterward, and was its old custom-domain claim cleared and verified? +7. Did Netlify identify the intended commit, or is that still unknown? +8. Was the exact result seen on `runmprc.com`? +9. Was each affected outside provider configured and checked directly? +10. Who approved and checked the result? +11. How can the change be undone safely through the same gate? The expanded handbook and task index is [docs/officers/README.md](./docs/officers/README.md). diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index b6b4c79..e7dc6b1 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -38,10 +38,12 @@ Use the club's approved password manager for access. Share only a public link or - **Hosted frontend tests passed:** Did the `Frontend lint + build` job show a green `Run frontend Jest tests` step? Jest is the automated frontend behavior test. - **Other tests passed:** What else was checked for this change? - **Code merged:** Which pull request was approved? -- **Website live:** Was the exact change seen on [runmprc.com](https://runmprc.com)? -- **Backend live:** If Firebase changed, did the log show a real Firebase deployment rather than “skipping” it? +- **Release approved:** Which environment, exact commit, and named approver were recorded? +- **Backend live:** If Firebase changed, did the fixed backend deployment and verification finish before website publication? +- **Pages published:** Did GitHub Pages receive the same exact commit, and was its old `runmprc.com` claim cleared and verified? +- **Website live:** Did Netlify identify that commit, and was the exact change then seen on [runmprc.com](https://runmprc.com)? - **Outside service verified:** If Stripe, Netlify, DNS, Google, or email changed, was that service checked separately? -As of **2026-07-13**, hosted CI runs the frontend Jest suite, but GitHub Pages and the live Netlify site are still separate. A green test or workflow does **not** by itself prove that `runmprc.com` or Firebase changed. +As of **2026-07-13**, a merge runs checks but does not start the GitHub release. The protected release is **NOT AVAILABLE YET** until its short-lived cloud identity and named environment approvers are configured under issue #133. Git-triggered Netlify production builds are paused, and a protected way to publish the live Netlify site is also **NOT AVAILABLE YET**. GitHub Pages still reports `runmprc.com` as its custom domain even though Netlify serves that name; source removal is not provider proof. A green test or workflow does **not** by itself prove that GitHub Pages, `runmprc.com`, Firebase, or that domain setting changed. For the concise handbook, see [OFFICER_HANDBOOK.md](./OFFICER_HANDBOOK.md). The expanded task index is [docs/officers/README.md](./docs/officers/README.md). diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 94eae34..8eedd6e 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -206,7 +206,7 @@ CI=true npm test -- --watchAll=false --runInBand npm run test:spa-navigation ``` -The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as the blocking `Run SPA callback safety tests` step under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126). Required branch protection, fail-closed lint, and protected deployment remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). +The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as `Run SPA callback safety tests` under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126), plus the manual-release source invariants under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). Protected environment/OIDC configuration, actual staged/live deployment, fail-closed lint, required checks, and controlled Netlify publication remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105), [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133), [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136), and WEB-001. ### Firestore Rules @@ -269,7 +269,7 @@ For expand-and-contract changes: 7. Run smoke and reconciliation checks. 8. Observe for the defined period before removing legacy fields/endpoints. -The current workflow deploys the frontend before Functions and can silently skip Firebase deployment when credentials are absent. Treat it as non-compliant until CI-001 is complete. +The GitHub release workflow is now manual and exact-current-commit. After protected approval it rechecks `main`, the newest exact CI run, and the retained credential-free artifact before cloud authentication. It fails on missing protected configuration, uses a lockfile Firebase CLI, verifies reviewed Rules and two named profile Functions before publishing the Pages branch, and never gives cloud authority to website preparation or publication. Future Pages artifacts omit the `runmprc.com` CNAME, but the existing Pages provider setting still claims that Netlify-served name until #136/WEB-001 publish and read it back. The source gate is not usable until #133 configures protected environments/OIDC. It does not publish the live Netlify host. Treat the full pipeline as incomplete until #105 closes. ### Production approvals diff --git a/README.md b/README.md index 8c9b2ce..ac69413 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Historical developer/content/LLM guides remain under [`docs/`](./docs/README.md) - `.github/workflows/`: frontend, Functions, Rules CI and deployment automation. - `public/404.html`, `public/index.html`, and `public/spa-navigation.js`: current tested GitHub Pages callback handoff. It preserves safe same-origin path, query, and fragment state. -**Deployment reality checked 2026-07-12:** the repository publishes a GitHub Pages copy, while `runmprc.com` is currently served by a separate Netlify deployment. The Firebase workflow can remain green while skipping backend deployment when its service-account secret is absent. Treat website, Firebase, and provider deployment as separate states until CI-001 and hosting consolidation are complete. +**Deployment reality checked 2026-07-13:** merges run CI but do not start the manual release workflow. The protected gate accepts one exact current merged commit, rechecks its newest CI run after approval, uses one fixed Firebase target set, fails when protected authority/configuration is missing, verifies Firebase before publishing GitHub Pages, and gives no server credential to website preparation or publication. Git-triggered Netlify production builds are paused. The source stops adding a Pages `CNAME`, but GitHub Pages currently still claims `runmprc.com` and its default URL redirects there; only a controlled #136/WEB-001 publication and provider readback can clear that conflict. Protected publication to the live Netlify-served `runmprc.com` is not configured yet. Treat GitHub Pages, Netlify, `runmprc.com`, Firebase, and outside providers as separate states. ## Local setup status @@ -79,7 +79,7 @@ npm run test:rules CI=true DISABLE_ESLINT_PLUGIN=true npx --no-install react-scripts build ``` -Rules tests require Java 17. The direct `react-scripts build` command is useful for a diagnostic compile because the normal `npm run build` runs the sitemap generator and may intentionally update `public/sitemap.xml`. The frontend Jest command runs as the blocking `Run frontend Jest tests` step in hosted CI under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). The separate SPA callback command is locally available; [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) owns adding it to hosted CI. [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105) still owns non-mutating lint, branch/deployment protection, credentials, staging, and the other incomplete delivery gates. +Rules tests require Java 17. The direct `react-scripts build` command is useful for a diagnostic compile because the normal `npm run build` runs the sitemap generator and may intentionally update `public/sitemap.xml`. Hosted CI runs the frontend Jest suite under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), the SPA callback suite under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126), and the release-gate source tests under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) still owns protected environment/OIDC configuration; [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual staged profile-recovery release. Non-mutating lint, required branch checks, scanning, staging, and hosting consolidation remain open under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105) and their atomic children. These safety changes do not repair a missing member profile or prove deployed Firebase Rules/Functions. The reported profile-save failure remains [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118). @@ -95,4 +95,4 @@ Business owners—not coding agents—must approve legal text, waiver/insurance ## Deployment warning -Pushing to `main` currently triggers GitHub Pages deployment and may deploy Firebase resources when configured. The existing workflow is itself scheduled for hardening in CI-001; do not assume a merge is a safe commerce release. Production changes require protected approval, passing checks, compatible backend-first rollout, a named observer, and the runbook's post-deploy/reconciliation steps. +Pushing to `main` runs CI only. It does not start `.github/workflows/deploy.yml`. The manual workflow is fixed to an exact merged commit and reviewed target set, but it is **NOT AVAILABLE YET** until #133 configures protected environments and short-lived cloud authority. Git-triggered Netlify production builds are paused; a protected live-Netlify publication path is also **NOT AVAILABLE YET**. Production changes still require protected approval, a compatible backend-first rollout, separate live-host proof, a named observer, and the runbook's post-deploy/reconciliation steps. diff --git a/SECURITY.md b/SECURITY.md index a7ceeb3..78a3969 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,7 +68,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-023 | The legacy `functions.config().api.key` endpoint uses a static shared key with no replay control, source restriction, App Check, authenticated operator, or request audit. The API is also scheduled for Firebase decommissioning in March 2027. | Key theft permits bulk role changes and future deployment failure. | Retire it in favor of authenticated admin workflow or narrowly authenticated scheduled import; migrate any remaining config to Secret Manager. | | RISK-024 | OAuth tokens are stored plaintext and browser admins can read them under current rules; refresh writes have no concurrency control. | Token theft exposes member activity data; concurrent refresh can lose a rotated refresh token. | Make secrets server/IAM-only, encrypt where risk analysis requires, transact refresh versions, minimize scopes, and audit access. | | RISK-025 | Admin authentication relies on password Auth plus a long-lived role token; no repository evidence of MFA, recent-auth checks, re-auth for refunds/role grants, or rapid revocation workflow. | A stolen admin session has broad durable impact. | Require MFA for privileged accounts, recent-auth for sensitive actions, capability roles, short sessions/forced refresh, and break-glass procedures. | -| RISK-026 | Firestore Admin SDK bypasses rules and the Functions service identity's IAM scope is not documented. GitHub deploy uses a broad service-account secret when present. | Server or CI compromise may expose the entire project. | Inventory IAM, create least-privilege service/deployer identities, use workload identity/OIDC where practical, and remove long-lived keys. | +| RISK-026 | Firestore Admin SDK bypasses rules and the Functions runtime IAM scope is not documented. #135 removes the long-lived service-account JSON path from release source, but the least-privilege short-lived deploy identity is not configured yet. | Server or CI compromise may expose the entire project; missing provider configuration also blocks release. | Complete the private IAM inventory and #133 OIDC/WIF configuration; keep runtime and deploy identities separate; never restore a JSON key shortcut. | | RISK-027 | Sentry optionally records user email and enables 100% replay on error; analytics/privacy consent, redaction, retention, and field-deny policies are not evidenced. | Forms and account flows contain PII that may be sent to monitoring vendors unexpectedly. | Disable replay on sensitive routes or configure strict masking, avoid email user context, set scrubbing/retention, consent policy, and vendor agreements. | | RISK-028 | No payment reconciliation job, dead-letter/quarantine workflow, or alert proves paid Stripe objects match Firestore. | Missed webhooks and partial failures can persist unnoticed. | Add scheduled reconciliation, alert thresholds, operator repair tooling, and a daily finance report. | | RISK-029 | Hosting on GitHub Pages limits controlled response headers and relies on custom JavaScript SPA fallback. | Weaker CSP/clickjacking/referrer controls and fragile OAuth/payment routes. | Migrate to Firebase Hosting or another owned edge with SPA rewrites, CSP, HSTS, frame protection, referrer and permissions policy. | @@ -83,7 +83,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-033 | CSV export mitigates formula injection, but roster export has no explicit re-auth, download audit, row limit/streaming limit, or data-minimization profiles. | Add scoped exports, reason/re-auth, audit, minimum columns, safe filename IDs, and large-export controls. | | RISK-034 | Webhook error response includes the Stripe library's signature error detail. | Return generic client errors; keep sanitized structured diagnostics server-side. | | RISK-035 | The deterministic frontend Jest suite and standalone SPA callback suite run as separate blocking hosted CI steps, but CI's frontend lint step still mutates files and is allowed to fail with `|| true`; branch protection and broader domain/integration coverage remain incomplete. | Make lint non-mutating and mandatory, prove required branch checks, and add domain/integration coverage. | -| RISK-036 | Production and staging deployment topology is inconsistent in docs/workflows; backend deploy follows frontend and can silently skip when a secret is absent. | Define environment-protected deployments, deploy compatible backend first, fail required production stages closed, and add rollback evidence. | +| RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and Git-triggered Netlify production containment. Protected environments/OIDC, isolated staging, live-Netlify publication, and rollback evidence are still absent. | Complete #133 and #136, provision isolated staging, protect the Netlify release path under WEB-001, and rehearse rollback/safe roll-forward before production. | | RISK-037 | Account/registration deletion, export, retention, backup, and restore procedures are incomplete. | Approve retention matrix, automate minimization, support access/deletion requests, and test backup restoration. | | RISK-038 | Source-controlled secret scan is ad hoc; no continuous secret scanner, dependency update bot, SBOM, provenance, or branch protection is documented. | Add secret/dependency/code scanning, reviewed lockfile updates, protected environments/branches, and artifact provenance appropriate to project scale. | @@ -103,6 +103,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 keeps App Check, Firebase Analytics, and Sentry from initializing in local/test even when public provider configuration is present. Initial capability callback routes also fail closed before App Check, Firebase Analytics, or Sentry startup. | #111 still owns general hosted URL/event/log redaction, already-initialized SPA transitions, replay/consent/retention policy, payload inspection, and provider evidence. ABUSE-001A must defer the three callback callables; DATA-001A and OAUTH-001C within canonical tracker [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) own their safe protected handoffs. | | Part of RISK-035 | The Jest environment is repaired, the stale frontend smoke test is replaced, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite as its own named blocking step. | Keep exact hosted evidence on #124/#126; make lint non-mutating/fail-closed, prove required branch checks, and add the broader TEST-001 suite. | +| RISK-026, RISK-036 | #135 replaces automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. It rechecks newest CI and the retained artifact after approval, pins release Actions, uses the lockfile Firebase CLI, validates protected configuration before backend install/authentication, idempotently verifies fixed Rules/Function revisions before Pages publication, removes cloud credentials from website preparation/publication, stops Git-triggered Netlify production builds, and removes the Pages CNAME from future artifacts. | Before #135 closes, its merged source must pass the safe missing-authority dispatch with zero Firebase/Pages/Netlify publication. #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | ## 5. Threat model diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index ce6c03c..c1f3f0f 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,7 +1,7 @@ # MPRC Platform System Design **Status:** Target architecture and current-state assessment -**Last reviewed:** 2026-07-12 +**Last reviewed:** 2026-07-13 **Owners:** MPRC product, engineering, operations, and finance leads This document is the system-level map for the Mid-Peninsula Running Club platform. It describes what exists in this repository, which parts are safe to rely on today, and the architecture required before accepting live race-registration or merchandise payments. @@ -50,7 +50,7 @@ The repository already contains a substantial prototype: a public React site, Fi flowchart LR Visitor["Visitor or member browser"] Admin["Club administrator browser"] - Web["React single-page app\nNetlify live; separate GitHub Pages copy"] + Web["React single-page app\nNetlify live; Pages domain conflict unresolved"] Auth["Firebase Authentication"] FS["Cloud Firestore"] Fn["Firebase Cloud Functions\nNode.js 20"] @@ -85,12 +85,30 @@ flowchart LR | Operational data | Cloud Firestore | `src/services`, `firestore.rules`, `firestore.indexes.json` | Appropriate for current scale; counters and state transitions require transactional design. | | Server API | First-generation Firebase callable/HTTP/trigger functions | `functions/` | Prototype covers most workflows; validation, idempotency, and isolation are incomplete. | | Payments | Stripe Checkout Sessions, Payment Links, refunds, signed webhook | `functions/createCheckoutSession.js`, `createMerchCheckout.js`, `stripeWebhook.js` | Not ready for live payments until P0 issues are complete. | -| Hosting | Netlify currently answers `runmprc.com`; GitHub Actions also publishes a separate Pages copy | `netlify.toml`, `.github/workflows/deploy.yml`, `public/404.html` | Split and drifting as verified 2026-07-12. A Pages success does not prove the live Netlify site changed. Hosting authority, preview, DNS, headers, and rollback remain open CI/WEB work. | +| Hosting and release | Netlify currently answers `runmprc.com`. GitHub Pages still reports the same custom domain, so its default URL redirects to the Netlify-served name instead of providing an independent copy. #135 source stops automatic releases, pauses Git-triggered Netlify production builds, and removes the Pages CNAME from future protected artifacts. | `netlify.toml`, `.github/workflows/deploy.yml`, `public/404.html` | Split and conflicting as verified 2026-07-13. The existing Pages provider setting is not cleared until a controlled #136/WEB-001 release and readback prove it. #133 authority, #136 release evidence, Netlify provider triggers, DNS, headers, and rollback remain open. | | Email | Firestore `mail` outbox designed for the Firebase Trigger Email extension | `functions/sendConfirmationEmail.js` | Extension/provider deployment is unverified; outbox creation is not transactionally idempotent and HTML needs escaping. | | Observability | Optional Sentry and Firebase Analytics | `src/services/monitoring`, `src/services/analytics` | Local/test initialization is disabled by #99 source. Hosted redaction, replay, consent, retention, and provider configuration remain unverified under #111. | | Third-party fitness | Strava OAuth tokens and statistics | `functions/strava.js`, `src/services/strava` | Functional prototype. The #100 source Rules deny browser token access, but Firebase deployment is unproven and transactional refresh, scopes/revocation, IAM/encryption decision, and audit remain OAUTH-001. | -The repository workflow presents GitHub Pages plus Firebase as a release path, but public DNS and response headers show that the live custom domain is served by Netlify. The two copies currently contain different bundles. The Firebase job can also finish green while explicitly skipping deployment when `FIREBASE_SERVICE_ACCOUNT` is absent. Treat Pages publication, Netlify production, and Firebase deployment as separate evidence until one hosting/release authority is chosen. The App Engine synchronization script is another surface that must be documented as active or retired. +The former workflow automatically published Pages before attempting Firebase and could finish green after skipping Firebase. #135 replaces that source path with a manual exact-current-commit request, exact latest CI checks, one fixed backend plan, protected short-lived identity wiring, provider readback, and Firebase-before-Pages publication. Missing authority or failed/partial verification is red. Git-triggered Netlify production builds stop, while build hooks and protected Netlify publication remain unverified. No source test clears the current Pages custom-domain claim, configures #133, deploys #136, or proves `runmprc.com`; those remain separate provider states. The App Engine synchronization script is another surface that must be documented as active or retired. + +```mermaid +flowchart TD + Merge["Merge to main"] --> CI["Exact main-push CI"] + CI --> Request["Manual exact-commit release request"] + Request --> Prepare["Credential-free artifact preparation"] + Prepare --> Approval{"Protected environment approved?"} + Approval -- "No" --> Stop["Stop — nothing published"] + Approval -- "Yes" --> Recheck{"Main, latest CI, project, scope, authority, and artifact still valid?"} + Recheck -- "No" --> Stop + Recheck -- "Yes" --> Backend["Deploy fixed Firebase set once"] + Backend --> Verify{"Exact Rules and Function revisions verified?"} + Verify -- "No" --> Stop + Verify -- "Yes" --> Pages["Publish Pages branch without a CNAME"] + Merge -. "Git production build paused" .-> Netlify["Netlify / runmprc.com\nprotected publication not available"] +``` + +Text alternative: a merge only runs CI; a separate request prepares a public artifact, waits for protected approval, rechecks current source and authority, verifies Firebase, and only then publishes a Pages branch that no longer claims the Netlify domain. ### GitHub Pages callback handoff diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index 4c6030d..609c0a7 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -38,6 +38,24 @@ Until those six steps are recorded, this guide does not claim incapacitation cov | Club email provider | Notices and password recovery | Communications owner plus backup | | Social/community accounts | Public communication and member groups | Named owner plus backup | +## Release access to record privately + +Record these facts without copying a credential or private provider identifier: + +1. Dave Liu is the current primary production release approver. +2. Name the backup/security reviewer who can approve when Dave is unavailable. +3. Name two reviewers for the protected `staging` environment. +4. Name two reviewers for the protected `production` environment. +5. Record that `Protected release` is the approved GitHub workflow. +6. Record the fixed environment-to-Firebase-project map. +7. Record the fixed release plans and named resources. +8. Record the owner of the short-lived cloud identity and its revocation procedure. +9. Record the latest missing-authority failure drill. +10. Record the latest backend-first rollback or safe roll-forward drill. +11. Record the Netlify team owner, live site, protected trigger, and rollback location. + +The repository stores none of the cloud identity value, provider path, access token, password, or recovery code. The protected release is **NOT AVAILABLE YET** until #133 completes these records and tests the environment approvals. + ## Private service record For each system, record only: @@ -63,6 +81,11 @@ For each system, record only: 7. Have two owners approve any removal. 8. Test the service-specific recovery or rollback procedure before removing access. 9. Confirm GitHub, Netlify, DNS, and Firebase agree on the intended production path. +10. Confirm a normal merge does not start the GitHub release. +11. Confirm missing release authority becomes a red failure before backend installation, cloud authentication, deployment, or website publication. A public website artifact may be prepared without cloud authority. +12. Confirm Firebase verification must finish before the GitHub Pages publication job can start. +13. Confirm Netlify Git-triggered production builds remain paused until a protected live-host path exists. +14. Confirm reviewers reject release requests older than 24 hours and request the current `main` commit again. ## Expected result and success proof @@ -73,7 +96,7 @@ For each system, record only: ## Stop conditions -Stop if a service has only one owner, uses Dave's personal recovery channel, has no tested rollback, or asks for a password/code in a shared document. Do not remove an account, token, integration, fork, or billing method until two owners complete a dependency check. +Stop if a service has only one owner, uses Dave's personal recovery channel, has no tested rollback, or asks for a password/code in a shared document. Also stop if a merge can publish unexpectedly, a server credential reaches a website job, a project/scope can be typed freely, a green backend skip is possible, or a website can publish before backend verification. Do not remove an account, token, integration, fork, or billing method until two owners complete a dependency check. ## Undo and recovery @@ -88,7 +111,7 @@ If an access change blocks a valid officer, stop further removals. Use the servi ## Special GitHub note -`Run-MPRC` is the club organization. `runmprc` is a separate personal user and stale fork owner. Do not retire it from this guide alone. First open a claimed cleanup issue, inventory outside dependencies, prepare rollback, and obtain two-owner approval. +`Run-MPRC` is the club organization, and `main` is now the canonical default branch. `runmprc` is a separate personal user and stale fork owner. Do not retire it from this guide alone. First open a claimed cleanup issue, inventory outside dependencies, prepare rollback, and obtain two-owner approval. ## Annual tabletop exercise diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index f220cf3..61d15b5 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -48,7 +48,7 @@ There is currently no proven no-code switch that safely stops all new Stripe pay - Tests used fake people and test-mode payments. - The exact commit and pull request are named. - The website deployment is verified separately. -- Firebase logs prove Functions/rules/indexes deployed; no step says “skipping Firebase deploy.” +- The protected release proves the exact Rules and named Functions deployed first. Missing authority or a skipped/partial backend is a red stop, and the website is not published. - Stripe or another provider is verified directly when involved. - Counts and money reconcile after the change. - The named officer signs off. diff --git a/docs/officers/GLOSSARY.md b/docs/officers/GLOSSARY.md index d37f991..0bfdf8f 100644 --- a/docs/officers/GLOSSARY.md +++ b/docs/officers/GLOSSARY.md @@ -19,7 +19,7 @@ | Firestore | The database inside Firebase. Saving an Admin form can change its records immediately. | | Function | A protected backend action, such as checking access or handling a payment event. | | Netlify | The service currently answering requests for `runmprc.com`. | -| GitHub Pages | A second website copy built from GitHub. It is not currently the custom-domain production copy. | +| GitHub Pages | A GitHub-hosted website branch. It currently still claims `runmprc.com`, so it is not an independently reachable second copy; source removal and provider verification remain open. | | Stripe | The outside payment service. MPRC live commerce is not approved yet. | | Secret | A password-like value, key, token, or recovery code that must not be put in source, issues, AI, email, or screenshots. | | Token | A password-like value that lets a person or service act. Treat it as a secret. | diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 29a9c8a..fadd232 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -1,116 +1,195 @@ -# Review, Merge, and Check a Change +# Review, Merge, Release, and Check a Change -**Purpose:** Review one approved change, merge it safely, and record what did or did not become live. -**Approver:** content owner for ordinary content; specialist owners listed in [Events, shop, members, and money](./EVENTS_SHOP_MEMBERS.md) for higher-risk work. -**Prerequisites:** an approved pull request aimed at `main`, its rollback note, access to its GitHub job details, and a platform maintainer available for merge and live verification. +**Purpose:** review one change, merge it, approve one exact release, and record what did or did not become live. -**Independent officer publication status:** **NOT AVAILABLE YET.** A platform maintainer must own the merge and live verification until the Netlify repository, branch, owner, build, preview, and rollback path are recorded and tested. +**Merge approver:** the content or business owner named in the change request. -## Current deployment truth +**Production release approver:** Dave Liu as platform owner until the board records a replacement, plus the required service/security reviewer for a high-risk change. Two named backup officers are still required under #133 before continuity is complete. + +**Prerequisites:** one approved pull request aimed at `main`; green required checks; an exact 40-character merged commit; a rollback or safe roll-forward note; and a named observer. + +**Protected release status:** **NOT AVAILABLE YET.** Issue #135 provides the fail-closed source gate. Issue #133 must still configure protected `staging` and `production` environments, their named reviewers, and a short-lived cloud identity. Public browser build values must be named repository or organization variables because artifact preparation has no protected-environment access; #133/#136 must record and verify them separately. Do not add a long-lived Firebase key as a shortcut. + +**Live Netlify publication status:** **NOT AVAILABLE YET.** Git-triggered production builds are paused by repository configuration. The Netlify owner, build hooks, manual trigger, exact-commit proof, and rollback path remain unverified provider work. GitHub Pages currently still claims the same custom domain; future source omits that claim, but #136/WEB-001 must publish and verify its removal. + +## The release gate + +```mermaid +flowchart TD + Review["Preview and checks"] --> Merge{"Approve merge?"} + Merge -- "No" --> Review + Merge -- "Yes" --> Merged["Merged to main — not released"] + Merged --> Request["Request one exact-commit release"] + Request --> Preflight{"Commit and required checks valid?"} + Preflight -- "No" --> Stop["Red failure — publish nothing"] + Preflight -- "Yes" --> Prepare["Prepare credential-free website artifact"] + Prepare --> Release{"Approve protected environment?"} + Release -- "No" --> Stop + Release -- "Yes" --> Gate{"Project, scope, and authority valid?"} + Gate -- "No" --> Stop + Gate -- "Yes" --> Rules["Deploy reviewed Firestore Rules"] + Rules --> Functions["Deploy two named Functions"] + Functions --> VerifyBackend{"Both Functions verified?"} + VerifyBackend -- "No" --> Stop + VerifyBackend -- "Yes" --> Pages["Publish prebuilt Pages branch without Netlify's domain"] + Pages --> Verify["Check Pages, Netlify, runmprc.com, and providers separately"] +``` + +In words: merging does not release; a request checks one exact commit and may prepare a credential-free artifact; protected approval unlocks Firebase; only verified Firebase permits Pages publication. + +## Current facts As of **2026-07-13**: -- Merging to `main` starts GitHub workflows. -- The `Frontend lint + build` job includes a job-failing step named `Run frontend Jest tests`. A failed step fails that job. -- Required branch protection is not proven. The Jest step does not by itself prevent someone with merge access from merging a failed change. -- The same job still has a lint command that can rewrite files and ignore failure. A green job is not proof that lint passed. -- Merging therefore also authorizes an automatic GitHub Pages publication; there is no separate Pages approval today. -- The GitHub repository currently opens stale, unprotected `dev` by default; use the explicit `main` branch and do not merge `dev` into `main` as a shortcut. -- GitHub Pages builds a copy of the website. -- `runmprc.com` is currently served by Netlify, not that Pages copy. -- Current previews are optimized production-mode builds and can point at production Firebase. They are safe only for public, read-only page review. -- The Firebase step can say success while skipping deployment when its service-account secret is absent. -- Therefore, a green workflow does not prove the public site or backend changed. +- `main` is the canonical branch. +- A merge starts CI checks. It does not start `.github/workflows/deploy.yml`. +- The release workflow accepts only a full commit already merged into `main`. +- It requires successful frontend, Functions, and Firestore Rules checks for that commit. +- Its only current release plan is the reviewed profile-recovery set: Firestore Rules, `createMemberOnSignUp`, and `ensureMemberProfile`. +- A caller cannot type a Firebase project or deployment target into the release form. +- Missing environment configuration or cloud authority makes the release red before backend dependencies, cloud authentication, or deployment. A public website artifact may be prepared without cloud authority, but it cannot be published. +- The backend uses a short-lived cloud identity when #133 configures it. The website job receives public browser values only. +- The Firebase CLI comes from the committed lockfile. The release does not install `latest`. +- A production Pages publication job cannot start until Firebase deployment and Function verification succeed. +- The `staging` option deliberately stops before deployment until #113/#133 name one exact approved staging Firebase project. A future staging release remains backend-only until a separate staging browser configuration and host exist. +- `runmprc.com` is served by Netlify, not GitHub Pages. +- GitHub Pages currently reports `runmprc.com` as its custom domain and redirects its normal address there. It is not an independently reachable copy today. +- Future source stops writing that Pages domain claim. Only provider readback after #136/WEB-001 can prove it cleared. +- Git-triggered Netlify production builds are paused. Netlify build hooks are not controlled by that repository rule and remain unverified. +- Live race signup, merchandise payments, and refunds remain unavailable. ## Before merge -1. Open the pull request and confirm its destination says `main`. -2. Confirm it names one issue and one outcome. -3. Confirm another person or review agent approved it. -4. Open the `Frontend lint + build` job. -5. Confirm `Run frontend Jest tests` is present and green. Stop if it is missing, skipped, or failed. -6. Confirm the other required test steps are green. -7. If you open a preview, review only public pages. Do not sign in, open a private/admin page, submit a form, or test a signup, checkout, refund, email, or Strava action. -8. Ask the platform maintainer for separate non-mutating lint evidence when lint applies. Do not use the green job as lint proof. -9. Confirm the officer guide was updated when needed. -10. Read the rollback note. -11. Confirm you understand that a merge automatically publishes the GitHub Pages copy. -12. Ask the platform maintainer whether any outside automation may also publish from the merge. -13. Do not merge if either automatic publication is unacceptable or unknown. - -## Netlify publication — NOT AVAILABLE YET - -Before a backup officer can publish independently, a claimed hosting issue must record and test: - -1. The exact Netlify team and site name. -2. The two officer accounts that own it. -3. The connected GitHub repository and branch. -4. The event that starts a production deploy. -5. The build command and public configuration names. -6. A safe preview URL for the intended commit, plus proof that private/Firebase actions use staging before anyone signs in. -7. Where Netlify displays the deployed commit. -8. How to restore the previous known-good deploy. -9. Which DNS records point `runmprc.com` to Netlify. -10. A dated drill proving the procedure without changing member or payment data. +1. Open the pull request. +2. Confirm its destination is `main`. +3. Confirm it names one issue and one outcome. +4. Confirm another person or review agent approved it. +5. Open the `Frontend lint + build` job. +6. Confirm `Run frontend Jest tests` is present and green. +7. Confirm `Run SPA callback safety tests` is present and green. +8. Confirm the Functions and Firestore Rules jobs are green. +9. Use a preview only for public, read-only pages. +10. Do not sign in, open private/admin pages, submit forms, or test signup, checkout, refund, email, or Strava in a preview. +11. Confirm the officer guide and undo note are present. +12. Approve or reject the merge. Do not describe merge approval as release approval. ## After merge -1. Record the pull request number and merged commit. -2. Wait for the GitHub workflow to finish. -3. Read the job details, not only the green summary. -4. Confirm the merged commit's `Run frontend Jest tests` step is green. Stop and escalate if it is missing, skipped, or failed. -5. Look for “skipping Firebase deploy.” If present, mark the backend **not deployed**. -6. Ask the platform maintainer which commit Netlify says it deployed. -7. Open [runmprc.com](https://runmprc.com) in a private/incognito window. -8. Visit the exact changed page directly. -9. Check the requested text, link, image, or behavior. -10. Check one phone-sized view. -11. Check one normal computer view. -12. If Firebase or an outside service changed, ask its owner for separate dated proof. -13. Record the result using the checklist below. +1. Record the pull request number. +2. Record the full merged commit. +3. Wait for that commit's CI jobs. +4. Confirm the required jobs are green again. +5. Mark the result **merged — not released**. +6. Do not expect GitHub Pages, Firebase, Netlify, or `runmprc.com` to change from the merge. +7. If Netlify unexpectedly publishes the merge, stop and treat it as a hosting incident. + +## Before a protected release — NOT AVAILABLE YET + +Do not use this section until #133 records that both GitHub environments are protected and tested. + +1. Choose `staging` or `production`. +2. Copy the exact 40-character merged commit. Do not use a branch name. +3. Choose the fixed release plan. Do not type a project or Function name. +4. Confirm the environment's Firebase project is the approved one. +5. Confirm the required checks belong to the same exact commit. +6. Confirm the rollback or safe roll-forward commit. +7. Confirm the named observer is available. +8. Ask the platform maintainer to request the manual release. +9. Record the release-run link. +10. Wait for the exact-commit checks and credential-free artifact preparation. +11. Have the named reviewer confirm the environment, commit, fixed plan, and undo note before approving the protected environment. +12. Do not approve a request older than 24 hours. Start a new request from the current `main` commit. + +## Watch the release — NOT AVAILABLE YET + +1. Confirm preflight says the exact commit is merged and its checks passed. +2. For production, confirm the credential-free website artifact was prepared from that commit. +3. Confirm the named protected-environment approval is recorded before the backend job. +4. Confirm protected configuration is present and environment-matched. +5. Confirm short-lived cloud authentication succeeds. +6. Confirm Firestore Rules deploy first. +7. Confirm only `createMemberOnSignUp` and `ensureMemberProfile` deploy next. +8. Confirm both Functions are found by the verification step. +9. Stop if any backend step is missing, skipped, failed, partial, or mismatched. +10. Confirm the GitHub Pages publication job starts only after backend success. +11. Confirm the Pages artifact uses the same exact commit. +12. Never call an overall green run proof that `runmprc.com` changed. + +## Verify every affected surface + +1. Record whether the GitHub Pages branch published and whether provider readback shows its old `runmprc.com` claim is gone. +2. Ask the Netlify owner which commit, if any, Netlify published. +3. Open [runmprc.com](https://runmprc.com) in a private window. +4. Visit the exact changed public page. +5. Check one phone-sized view. +6. Check one normal computer view. +7. If Firebase changed, obtain dated proof for the exact project, Rules release, and named Functions. +8. If an outside provider changed, obtain separate dated proof from its owner. +9. Use made-up data only. Do not inspect or change a real member record. +10. Complete the delivery record. ## Expected result -The named Jest step is green for both the pull-request commit and the merged commit. The delivery record separately says whether GitHub Pages, Netlify, `runmprc.com`, Firebase, and any outside provider were verified. +Merge, release approval, Firebase deployment, backend verification, Pages publication, Netlify publication, `runmprc.com`, and provider verification are recorded as separate states. A backend failure or missing authority leaves the website unpublished. ## Stop conditions -Stop and ask the platform maintainer if the pull request does not target `main`; approval or the rollback note is missing; the named Jest step is missing, skipped, or failed; a preview asks you to sign in or use private/Firebase behavior; publication is unexpected; the deployed commit is unknown; or any live surface disagrees with the merged change. +Stop and contact the platform owner if: + +- The pull request does not target `main`. +- Approval, required checks, or the undo note is missing. +- The release uses a branch name or short commit. +- The commit is not merged into `main`. +- The release request is more than 24 hours old. +- The environment, project, or fixed scope is missing or wrong. +- Anyone asks for a service-account key, token, password, or recovery code. +- Firebase is skipped, partial, failed, or unverified. +- A website publication job starts before backend verification. +- A project or deployment target can be typed freely. +- Netlify publishes unexpectedly or its live commit is unknown. +- A test needs real member, payment, or private data. ## Success proof -Keep the completed delivery record with links to the pull request, merged commit, and exact GitHub job details. Add dated, separately obtained proof for each live website, Firebase, or outside-provider surface that the change affected. +Keep the completed record with links to the issue, pull request, merged commit, exact CI jobs, release run, and each affected live service. Keep provider identifiers, private links, logs, credentials, and member data out of public evidence. ## Delivery record ```text Issue: Pull request: -Merged commit: -Hosted frontend Jest step: pass / fail / missing -Other tests passed: -GitHub Pages published: yes / no / not relevant +Merged commit (40 characters): +Required checks passed: +Release requested by: +Release approved by: +Environment: staging / production / not released +Release plan: +Preflight: pass / fail / not run +Firebase Rules deployed: yes / no / not run +Named Functions deployed and verified: yes / no / not run +GitHub Pages published: yes / no / not run Netlify intended commit verified: yes / no / unknown runmprc.com verified: yes / no -Firebase deployed: yes / no / not relevant Outside provider configured: yes / no / not relevant Outside provider verified: yes / no / not relevant Production behavior verified: yes / no Checked by: Checked at (date and time): +Undo or safe roll-forward commit: Known remaining problem: ``` -## If the change is not on the live site +## If anything fails -1. Do not repeatedly merge or redeploy. -2. Save the live URL, time, and a redacted screenshot. -3. Ask AI to compare the merged version, GitHub Pages copy, and Netlify copy. -4. Treat the problem as a hosting/deployment issue. -5. Do not change DNS until the Netlify site, owner, branch, environment, and rollback are confirmed. +1. Do not rerun blindly. +2. Do not approve the Pages job after a backend failure. +3. Save the run link, exact commit, time, and a redacted screenshot. +4. If Rules changed but Functions failed, treat the backend as partial. +5. Ask the platform owner to restore the reviewed compatible backend set or safely roll forward. +6. Do not force-push, reset branches, delete data, edit Firestore by hand, or change DNS. ## Undo -Ask for a revert pull request. Do not force-push, reset shared branches, delete data, or make a second unrelated change while the first problem is being investigated. +Use one reviewed rollback or safe roll-forward commit through the same protected, backend-first gate. Restore a compatible Firebase set before publishing a dependent website. A Netlify rollback remains a provider-owner procedure and is **NOT AVAILABLE YET** for backup officers. -**Escalation:** platform owner and backup for website hosting; Firebase owner for backend changes; treasurer plus platform owner for commerce. +**Escalation:** platform owner plus backup for release/hosting; Firebase owner for backend; treasurer plus platform owner for commerce; privacy owner for member data. diff --git a/docs/officers/README.md b/docs/officers/README.md index 7af855a..19f52d3 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,24 +1,31 @@ # Officer Website Handbook **Audience:** MPRC officers and backup maintainers with little or no coding experience -**Last checked:** 2026-07-12 +**Last checked:** 2026-07-13 **Start page:** [OFFICER_START_HERE.md](../../OFFICER_START_HERE.md) The safe pattern is always the same: ```mermaid -flowchart LR +flowchart TD A["Officer describes the result"] --> B["AI opens one tracked change"] B --> C["AI shows preview and checks"] - C --> D{"Approve merge and its automatic deployment attempt?"} + C --> D{"Approve merge?"} D -- "No" --> B - D -- "Yes" --> E["Merged to main"] - E --> P["Automatic Pages publication and Firebase attempt"] - P --> F["Check the real site and services"] + D -- "Yes" --> E["Merged to main — not released"] + E --> Q["Request one exact-commit release"] + Q --> P["Check source; prepare public artifact"] + P --> R{"Approve protected environment?"} + R -- "No" --> E + R -- "Yes" --> V["Check project, scope, and authority"] + V --> H{"Firebase deployed and verified?"} + H -- "No" --> S["Stop — website is not published"] + H -- "Yes" --> W["Publish Pages branch without Netlify's domain name"] + W --> F["Check Pages, Netlify, runmprc.com, and providers"] F --> G["Record proof and close"] ``` -In words: ask and preview first; approving a merge also authorizes today's automatic deployment attempt; then check the real result on every affected service. +In words: approve the merge, request one exact release, and approve its protected environment separately; Firebase must finish before the Pages copy; then check every affected service. ## Short guides @@ -35,7 +42,7 @@ In words: ask and preview first; approving a merge also authorizes today's autom 1. Describe the result. Do not guess which file or service to edit. 2. Ask for a preview. Do not approve a change you cannot see. -3. Approve one step at a time. A `main` merge automatically publishes GitHub Pages today; live Netlify and Firebase still need separate proof. +3. Approve one step at a time. Merge is separate from release. Firebase, Pages, Netlify, and `runmprc.com` each need separate proof. 4. Check the real website or service. A green check is not the same as “live.” 5. Stop when money, private data, account access, legal wording, or security is involved. @@ -45,8 +52,10 @@ In words: ask and preview first; approving a merge also authorizes today's autom | --- | --- | | Drafted | A change exists only in a working copy. | | Tests passed | Automated checks passed for the named change. | -| Merged | GitHub accepted the code into `main`. | -| Pages published | GitHub automatically built its website copy after merge. | +| Merged | GitHub accepted the code into `main`; it is not released. | +| Release approved | A named approver accepted one exact commit and environment. | +| Backend verified | The fixed Firebase deployment and checks finished successfully. | +| Pages published | GitHub published the approved commit after backend verification, without claiming Netlify's domain. | | Netlify commit verified | The live host identifies the intended merged commit. | | Website verified | The exact result was seen on `runmprc.com`. | | Firebase deployed | The backend deployment actually ran; it did not skip. | @@ -56,6 +65,10 @@ Never shorten several of these states to “done.” Independent officer publishing to the live Netlify host is **NOT AVAILABLE YET**. Use a platform maintainer until the Netlify connection and rollback path are documented and tested. +The protected GitHub release is also **NOT AVAILABLE YET** until #133 configures the environment approvers and short-lived cloud identity. Missing authority stops the release with a red failure before Firebase or website publication. + +GitHub Pages currently still claims `runmprc.com`, so its normal address redirects to the Netlify-served name. The source stops adding that claim, but #136/WEB-001 must publish and verify the provider setting before officers call Pages an independent copy. + ## Current safety warning The repository contains race, shop, registration, payment, refund, and admin work that is **not approved for live commerce**. Do not enter live Stripe keys or accept real payments until the launch blockers in the root security documents are closed and officers approve a controlled launch. diff --git a/docs/officers/REQUEST_A_CHANGE.md b/docs/officers/REQUEST_A_CHANGE.md index ef2b4d5..aa169bb 100644 --- a/docs/officers/REQUEST_A_CHANGE.md +++ b/docs/officers/REQUEST_A_CHANGE.md @@ -27,13 +27,14 @@ Have one of these ready: 6. Require one GitHub issue and one small pull request based on `main`. 7. Ask for the affected page link and a preview or clear before/after view. 8. Review spelling, dates, links, mobile layout, and unintended changes. -9. If the preview is correct, ask a platform maintainer to explain which automatic builds a merge will start. -10. Approve the merge only if those automatic builds are also acceptable. -11. Use [Publish and check](./PUBLISH_AND_CHECK.md) before calling the change live. +9. If the preview is correct, approve or reject the merge. +10. Record the merged commit as **not released**. +11. If publication is needed, use the separate protected-release checklist in [Publish and check](./PUBLISH_AND_CHECK.md). +12. Use [Publish and check](./PUBLISH_AND_CHECK.md) before calling the change live. -If you open GitHub yourself, use the [canonical repository on `main`](https://github.com/Run-MPRC/Run-MPRC.github.io/tree/main), then select **Issues**. The normal repository landing page currently opens stale `dev`; do not use `dev` as the source for a new request. +If you open GitHub yourself, use the [canonical repository on `main`](https://github.com/Run-MPRC/Run-MPRC.github.io/tree/main), then select **Issues**. `main` is now the repository default. Do not use the legacy `dev` branch as the source for a new request. -GitHub automatically publishes a Pages copy after a `main` merge. Other outside automation may also react to a merge. There is no safe “merge but guarantee nothing publishes” option in the current workflow. +The GitHub release is manual. A `main` merge runs checks but does not publish the GitHub Pages copy or deploy Firebase. Git-triggered Netlify production builds are paused by repository configuration, but Netlify build hooks/provider settings remain unverified. Stop and escalate any unexpected publication. ## If you cannot open an AI assistant diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index d71690b..b1e36a8 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -41,23 +41,29 @@ flowchart LR In words: public content comes from GitHub; private accounts and operational records use Firebase; Google Forms are separate; Stripe must remain test-only until approved. -## How a change reaches people today +## How a change reaches people through the protected gate ```mermaid flowchart TD - PR["Approved pull request"] --> Main["Merge to main"] - Main --> Workflow["GitHub workflow"] - Workflow --> Pages["GitHub Pages copy"] - Workflow --> Firebase{"Firebase credential present?"} - Firebase -- "No" --> Skip["Backend deploy skipped"] - Firebase -- "Yes and job succeeds" --> Backend["Firebase deployed"] - Main -. "connection and trigger not verified" .-> Netlify - Netlify["Netlify deployment — current live host"] --> Live["runmprc.com"] - Pages -. "currently not the live custom-domain copy" .-> Live - Dev["dev — stale default branch"] -. "do not use for new release work" .-> PR + PR["Approved pull request"] --> Main["Merge to main — checks only"] + Main --> Request["Request one exact-commit release"] + Request --> Preflight{"Commit and required checks valid?"} + Preflight -- "No" --> Stop["Red failure — publish nothing"] + Preflight -- "Yes" --> Prepare["Prepare credential-free artifact"] + Prepare --> Approval{"Protected environment approved?"} + Approval -- "No" --> Stop + Approval -- "Yes" --> Gate{"Project, scope, and authority valid?"} + Gate -- "No" --> Stop + Gate -- "Yes" --> Rules["Deploy reviewed Firestore Rules"] + Rules --> Functions["Deploy and verify named Functions"] + Functions --> Pages["Pages branch without Netlify's domain claim"] + Main -. "Git-triggered production build paused" .-> Netlify + Netlify["Netlify — current live host; protected publication unavailable"] --> Live["runmprc.com"] + Pages -. "existing provider claim still conflicts until verified clear" .-> Live + Dev["dev — legacy branch"] -. "do not use for new release work" .-> PR ``` -In words: GitHub can report success while the live Netlify site stays old or Firebase is skipped, so each surface needs separate proof. +In words: merge, release request, and protected approval are separate; a missing or failed Firebase gate publishes nothing; the future Pages branch must stop claiming the Netlify domain, and both hosts still need separate proof. ## Account and permission ownership diff --git a/netlify.toml b/netlify.toml index a725bb2..e39ab53 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,9 @@ [build] command = "npm run build" publish = "build" + # Git-triggered production publication is paused until WEB-001 records the + # Netlify owners, protected trigger, exact commit, and rollback path. + ignore = "node ./scripts/netlify-ignore-build.js" [build.environment] NODE_VERSION = "20" diff --git a/public/CNAME b/public/CNAME deleted file mode 100644 index a99aab3..0000000 --- a/public/CNAME +++ /dev/null @@ -1 +0,0 @@ -runmprc.com \ No newline at end of file diff --git a/scripts/netlify-ignore-build.js b/scripts/netlify-ignore-build.js new file mode 100644 index 0000000..11c5893 --- /dev/null +++ b/scripts/netlify-ignore-build.js @@ -0,0 +1,14 @@ +'use strict'; + +// Netlify's ignore command uses 0 to stop a build and 1 to continue it. +// Keep public, read-only Deploy Previews available, but do not let a main +// branch merge silently publish runmprc.com before the protected host gate. +const { CONTEXT: context } = process.env; + +if (context === 'deploy-preview' || context === 'branch-deploy') { + console.log('Non-production Netlify build may continue for public review.'); + process.exitCode = 1; +} else { + console.log('Git-triggered Netlify production builds are paused.'); + process.exitCode = 0; +} diff --git a/tests/firebase-release-verification.test.js b/tests/firebase-release-verification.test.js new file mode 100644 index 0000000..95c61b4 --- /dev/null +++ b/tests/firebase-release-verification.test.js @@ -0,0 +1,254 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const test = require('node:test'); + +const SCRIPT_URL = pathToFileURL(path.resolve( + __dirname, + '../.github/scripts/verify-firebase-release.mjs', +)).href; + +function functionState(project, id, overrides = {}) { + const isAuthTrigger = id === 'createMemberOnSignUp'; + return { + id, + name: `projects/${project}/locations/us-central1/functions/${id}`, + status: 'ACTIVE', + entryPoint: id, + runtime: 'nodejs20', + updateTime: '2026-07-13T09:00:00Z', + versionId: '2', + buildId: 'build-2', + trigger: isAuthTrigger ? 'event' : 'https', + eventType: isAuthTrigger + ? 'providers/firebase.auth/eventTypes/user.create' + : undefined, + ...overrides, + }; +} + +function rulesState(project, rulesetId, files, overrides = {}) { + return { + releaseName: `projects/${project}/releases/cloud.firestore`, + rulesetName: `projects/${project}/rulesets/${rulesetId}`, + files, + ...overrides, + }; +} + +test('requests the default database path but expects its canonical release name', async () => { + const { firestoreReleaseNames } = await import(SCRIPT_URL); + assert.deepEqual(firestoreReleaseNames('demo-approved-project'), { + requestName: 'projects/demo-approved-project/releases/cloud.firestore/(default)', + canonicalName: 'projects/demo-approved-project/releases/cloud.firestore', + }); +}); + +test('accepts an advanced exact Rules source and both expected Functions', async () => { + const { digestRulesSource, validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const rules = 'rules_version = \'2\';\nservice cloud.firestore { match /databases/{database}/documents {} }\n'; + const digest = digestRulesSource(rules); + const before = { + project, + rules: rulesState(project, 'old', [ + { name: 'firestore.rules', digest: 'old' }, + ]), + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp', { + updateTime: '2026-07-13T08:00:00Z', versionId: '1', buildId: 'build-1', + }), + ensureMemberProfile: null, + }, + }; + const after = { + project, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest }]), + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }, + }; + + assert.deepEqual(validateBackendState(before, after, digest), []); +}); + +test('rejects stale Rules, stale Function revisions, and wrong trigger/runtime state', async () => { + const { digestRulesSource, validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = digestRulesSource('approved rules\n'); + const staleSignup = functionState(project, 'createMemberOnSignUp', { + versionId: '1', buildId: 'build-1', updateTime: '2026-07-13T08:00:00Z', + }); + const before = { + project, + rules: rulesState(project, 'same', [ + { name: 'firestore.rules', digest: 'previous-wrong' }, + ]), + functions: { + createMemberOnSignUp: staleSignup, + ensureMemberProfile: functionState(project, 'ensureMemberProfile', { + versionId: '1', buildId: 'build-1', updateTime: '2026-07-13T08:00:00Z', + }), + }, + }; + const after = { + project, + rules: rulesState( + project, + 'same', + [ + { name: 'firestore.rules', digest: 'wrong' }, + { name: 'backup-firestore.rules', digest }, + ], + ), + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp', { + versionId: '1', buildId: 'build-1', updateTime: '2026-07-13T09:00:00Z', + }), + ensureMemberProfile: functionState(project, 'ensureMemberProfile', { + runtime: 'nodejs18', + trigger: 'event', + versionId: '1', + buildId: 'build-1', + updateTime: '2026-07-13T08:00:00Z', + }), + }, + }; + + const errors = validateBackendState(before, after, digest); + assert.ok(errors.includes('Firestore Rules release did not advance.')); + assert.ok(errors.includes('Firestore Rules source does not match the approved commit.')); + assert.ok(errors.includes('createMemberOnSignUp revision did not advance.')); + assert.ok(errors.includes('ensureMemberProfile has the wrong runtime or entry point.')); + assert.ok(errors.includes('ensureMemberProfile has the wrong trigger type.')); + assert.ok(errors.includes('ensureMemberProfile revision did not advance.')); +}); + +test('accepts an idempotent Rules retry when exact source is already live', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = 'expected'; + const rules = rulesState(project, 'already-live', [ + { name: 'firestore.rules', digest }, + ]); + const before = { + project, + rules, + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp', { + versionId: '1', buildId: 'build-1', updateTime: '2026-07-13T08:00:00Z', + }), + ensureMemberProfile: functionState(project, 'ensureMemberProfile', { + versionId: '1', buildId: 'build-1', updateTime: '2026-07-13T08:00:00Z', + }), + }, + }; + const after = { + project, + rules, + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }, + }; + + assert.deepEqual(validateBackendState(before, after, digest), []); +}); + +test('rejects missing Function revision evidence', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = 'expected'; + const after = { + project, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest }]), + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp', { + buildId: undefined, + }), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }, + }; + + const errors = validateBackendState({ project, functions: {} }, after, digest); + assert.ok(errors.includes('createMemberOnSignUp revision metadata is unreadable.')); +}); + +test('fails closed when provider readback is incomplete or malformed', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const errors = validateBackendState( + { project: 'demo-approved-project' }, + { + project: 'demo-approved-project', + rules: { + releaseName: 'projects/wrong-project/releases/cloud.firestore', + rulesetName: 'projects/demo-approved-project/rulesets/new', + files: [{ digest: 'unexpected' }], + }, + }, + 'expected', + ); + + assert.ok(errors.includes( + 'Firestore Rules release has the wrong project or ruleset identity.', + )); + assert.ok(errors.includes('Firestore Rules source does not match the approved commit.')); + assert.ok(errors.includes('createMemberOnSignUp is unreadable.')); + assert.ok(errors.includes('ensureMemberProfile is unreadable.')); +}); + +test('rejects readback from a different backend project', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-other-project'; + const digest = 'expected'; + const after = { + project, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest }]), + functions: { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }, + }; + + const errors = validateBackendState( + { project: 'demo-approved-project', functions: {} }, + after, + digest, + ); + assert.ok(errors.includes( + 'Backend project readback does not match the approved environment.', + )); +}); + +test('rejects noncanonical and non-default Firestore response identities', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = 'expected'; + const functions = { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }; + + for (const releaseName of [ + `projects/${project}/releases/cloud.firestore/(default)`, + `projects/${project}/releases/cloud.firestore/other`, + ]) { + const after = { + project, + rules: rulesState( + project, + 'new', + [{ name: 'firestore.rules', digest }], + { releaseName }, + ), + functions, + }; + const errors = validateBackendState({ project, functions: {} }, after, digest); + assert.ok(errors.includes( + 'Firestore Rules release has the wrong project or ruleset identity.', + )); + } +}); diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js new file mode 100644 index 0000000..9e2d4c9 --- /dev/null +++ b/tests/release-workflow.test.js @@ -0,0 +1,171 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const ROOT = path.resolve(__dirname, '..'); +const WORKFLOW_PATH = path.join(ROOT, '.github/workflows/deploy.yml'); +const NETLIFY_CONFIG_PATH = path.join(ROOT, 'netlify.toml'); +const NETLIFY_GATE_PATH = path.join(ROOT, 'scripts/netlify-ignore-build.js'); +const GITIGNORE_PATH = path.join(ROOT, '.gitignore'); +const PUBLIC_CNAME_PATH = path.join(ROOT, 'public/CNAME'); +const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); +const netlifyConfig = fs.readFileSync(NETLIFY_CONFIG_PATH, 'utf8'); +const gitignore = fs.readFileSync(GITIGNORE_PATH, 'utf8'); + +function runNetlifyGate(context) { + const env = { ...process.env }; + if (context === undefined) { + delete env.CONTEXT; + } else { + env.CONTEXT = context; + } + return spawnSync(process.execPath, [NETLIFY_GATE_PATH], { + cwd: ROOT, + env, + encoding: 'utf8', + }); +} + +test('release workflow is manual, exact-commit, and fixed-scope', () => { + assert.match(workflow, /\bon:\n workflow_dispatch:/); + assert.doesNotMatch(workflow, /\n push:/); + assert.match(workflow, /source_commit:/); + assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/); + assert.match(workflow, /Source commit must equal the current tip of main/); + assert.match(workflow, /actions\/workflows\/ci\.yml\/runs/); + assert.doesNotMatch(workflow, /-f status=completed/); + assert.match(workflow, /\.head_branch == "main"/); + assert.match(workflow, /\.event == "push"/); + assert.match(workflow, /sort_by\(\[\.run_number, \(\.run_attempt \/\/ 0\)\]\)/); + assert.match(workflow, /\.status == "completed" and \.conclusion == "success"/); + assert.match(workflow, /\.conclusion == "success"/); + assert.match(workflow, /Frontend lint \+ build/); + assert.match(workflow, /Cloud Functions lint \+ test/); + assert.match(workflow, /Firestore security-rules tests/); + assert.match(workflow, /options:\n - profile-recovery/); + assert.match(workflow, /RELEASE_PLAN: \$\{\{ inputs\.release_plan \}\}/); + assert.match(workflow, /Unsupported release plan/); + assert.match( + workflow, + /--only firestore:rules,functions:createMemberOnSignUp,functions:ensureMemberProfile/, + ); + assert.doesNotMatch(workflow, /--only[= ]+functions(?:\s|$)/m); + assert.equal((workflow.match(/npx --no-install firebase deploy/g) ?? []).length, 1); + assert.match(workflow, /Staging is unavailable until #113\/#133/); +}); + +test('website is prebuilt, then backend is read back before publication', () => { + const preparePosition = workflow.indexOf(' prepare-pages:'); + const backendPosition = workflow.indexOf(' deploy-backend:'); + const pagesPosition = workflow.indexOf(' deploy-pages:'); + + assert.ok(preparePosition > 0); + assert.ok(backendPosition > preparePosition); + assert.ok(pagesPosition > backendPosition); + assert.match(workflow.slice(preparePosition, backendPosition), /upload-artifact@[0-9a-f]{40}/); + assert.match(workflow.slice(backendPosition, pagesPosition), /Always read back Rules and both Function revisions/); + assert.match(workflow.slice(backendPosition, pagesPosition), /if: \$\{\{ always\(\)/); + assert.match( + workflow.slice(pagesPosition), + /needs:\n - preflight\n - prepare-pages\n - deploy-backend/, + ); + assert.match( + workflow.slice(pagesPosition), + /needs\.deploy-backend\.outputs\.backend_verified == 'true'/, + ); + assert.match(workflow.slice(pagesPosition), /download-artifact@[0-9a-f]{40}/); + assert.match(workflow.slice(pagesPosition), /release-commit\.txt/); + assert.match(workflow.slice(preparePosition, backendPosition), /retention-days: 30/); +}); + +test('release uses protected short-lived authority and committed tooling', () => { + const backendHeader = workflow.slice( + workflow.indexOf(' deploy-backend:'), + workflow.indexOf(' steps:', workflow.indexOf(' deploy-backend:')), + ); + + assert.match(workflow, /permissions: \{\}/); + assert.match(workflow, /id-token: write/); + assert.match(workflow, /google-github-actions\/auth@[0-9a-f]{40}/); + assert.match(workflow, /access_token_lifetime: 3300s/); + assert.match(workflow, /npx --no-install firebase --version/); + assert.match(workflow, /npm ci --legacy-peer-deps --ignore-scripts/); + assert.doesNotMatch(workflow, /firebase-tools@latest/); + assert.doesNotMatch(workflow, /npm install -g/); + assert.doesNotMatch(workflow, /secrets\.FIREBASE_SERVICE_ACCOUNT/); + assert.doesNotMatch(workflow, /secrets\.FIREBASE_TOKEN/); + assert.doesNotMatch(workflow, /^\s+FIREBASE_SERVICE_ACCOUNT:/m); + assert.doesNotMatch(workflow, /^\s+FIREBASE_TOKEN:/m); + assert.doesNotMatch(workflow, /No .*skipping Firebase deploy/i); + assert.match(gitignore, /^gha-creds-\*\.json$/m); + assert.doesNotMatch(backendHeader, /secrets\.GCP_/); + + const installPosition = workflow.indexOf('Install committed deploy dependencies'); + const authPosition = workflow.indexOf('Obtain short-lived Google Cloud credentials'); + const capturePosition = workflow.indexOf('Capture private provider state'); + const postApprovalArtifactPosition = workflow.indexOf( + 'Confirm the prebuilt Pages artifact is still available after approval', + ); + const postApprovalCiPosition = workflow.indexOf( + 'Revalidate current main and exact CI after protected approval', + ); + assert.ok(installPosition > 0 && authPosition > installPosition); + assert.ok(postApprovalArtifactPosition > installPosition); + assert.ok(postApprovalCiPosition > postApprovalArtifactPosition); + assert.ok(authPosition > postApprovalCiPosition); + assert.ok(capturePosition > authPosition); + assert.match(workflow, /Main advanced after this release was requested/); + assert.match(workflow, /approved CI run is no longer the newest exact run/); + assert.match(workflow, /Release request is older than 24 hours/); + + for (const actionLine of workflow.matchAll(/^\s*uses:\s*([^\s#]+)/gm)) { + assert.match(actionLine[1], /@[0-9a-f]{40}$/); + } +}); + +test('frontend preparation and publication receive no cloud identity', () => { + const prepareJob = workflow.slice( + workflow.indexOf(' prepare-pages:'), + workflow.indexOf(' deploy-backend:'), + ); + const pagesJob = workflow.slice(workflow.indexOf(' deploy-pages:')); + + assert.doesNotMatch(prepareJob, /id-token:/); + assert.doesNotMatch(prepareJob, /secrets\.GCP_/); + assert.doesNotMatch(pagesJob, /id-token:/); + assert.doesNotMatch(pagesJob, /secrets\.GCP_/); + assert.doesNotMatch(pagesJob, /^\s+DEPLOY_SERVICE_ACCOUNT:/m); + assert.doesNotMatch(pagesJob, /^\s+WORKLOAD_IDENTITY_PROVIDER:/m); + assert.match(prepareJob, /Reject server-credential material/); + assert.match(pagesJob, /credential separation/); + assert.equal((workflow.match(/grep -R -q -E/g) ?? []).length, 3); + assert.doesNotMatch(workflow, /grep -R -E/); + assert.match(workflow, /secrets\.GCP_WORKLOAD_IDENTITY_PROVIDER/); + assert.match(workflow, /secrets\.GCP_DEPLOY_SERVICE_ACCOUNT/); + assert.doesNotMatch(pagesJob, /^ environment:/m); + assert.doesNotMatch(pagesJob, /^\s+cname:/m); + assert.equal(fs.existsSync(PUBLIC_CNAME_PATH), false); +}); + +test('Netlify production Git builds stop while previews remain available', () => { + assert.match( + netlifyConfig, + /ignore = "node \.\/scripts\/netlify-ignore-build\.js"/, + ); + + for (const context of [undefined, 'production', 'future-unknown-context']) { + const result = runNetlifyGate(context); + assert.equal(result.status, 0); + assert.match(result.stdout, /production builds are paused/); + } + + for (const context of ['deploy-preview', 'branch-deploy']) { + const result = runNetlifyGate(context); + assert.equal(result.status, 1); + assert.match(result.stdout, /may continue/); + } +});