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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/blue-green-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ on:
default: ''
type: string

freeze_override_reason:
description: 'Emergency override of a deployment freeze (devops/deploy-freeze-calendar.json). Only authorized overriders are honored; the person and reason are logged.'
required: false
default: ''
type: string

permissions:
contents: read

Expand All @@ -53,7 +59,7 @@ jobs:
determine-slots:
name: Determine Active & Idle Slots
runs-on: ubuntu-latest
needs: validate-override
needs: [validate-override, deploy-freeze-gate]
outputs:
active_slot: ${{ steps.identify.outputs.active_slot }}
idle_slot: ${{ steps.identify.outputs.idle_slot }}
Expand Down Expand Up @@ -229,6 +235,34 @@ jobs:
echo "::error::disable_auto_rollback requires override_reason so the override is auditable."
exit 1

# Blocks deploys inside a freeze window (month-end, incident hold, ...) from
# devops/deploy-freeze-calendar.json unless an authorized person supplies an
# override reason. Status is shown in the run summary; overrides are logged
# with the person and reason (artifact + team channel). See
# docs/DEPLOYMENT_FREEZE.md.
deploy-freeze-gate:
name: Deploy Freeze Gate
runs-on: ubuntu-latest
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
DEPLOY_ACTOR: ${{ github.actor }}
FREEZE_OVERRIDE_REASON: ${{ github.event.inputs.freeze_override_reason }}
DEVOPS_ALERT_WEBHOOK_URL: ${{ secrets.DEVOPS_ALERT_WEBHOOK_URL }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Check deployment freeze calendar
run: node scripts/deploy-freeze-gate.mjs check
- name: Upload freeze override log
if: hashFiles('freeze-override-log.jsonl') != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: freeze-override-log-${{ github.run_id }}
path: freeze-override-log.jsonl
retention-days: 400

post-deploy-health-gate:
name: Post-Deploy Health Gate
runs-on: ubuntu-latest
Expand Down
141 changes: 141 additions & 0 deletions .github/workflows/config-parity-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Environment Config Parity Drift

# Compares non-secret configuration, versions and feature-flag states between
# staging and production on a schedule, ignoring differences listed in
# devops/config-parity-allowlist.json. New unexpected drift alerts the team and
# opens an issue; first-seen/resolved times are kept on the
# `config-drift-history` branch. Issue #931. See docs/CONFIG_PARITY_DRIFT.md.

on:
schedule:
- cron: "30 */6 * * *"
workflow_dispatch:
pull_request:
paths:
- "scripts/config-parity-drift.mjs"
- "scripts/config-parity-drift.test.mjs"
- "scripts/fixtures/config-parity/**"
- "devops/config-parity-allowlist.json"
- ".github/workflows/config-parity-drift.yml"

permissions:
contents: read

jobs:
unit-tests:
name: Parity logic tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- run: node --test scripts/config-parity-drift.test.mjs

collect:
name: Collect ${{ matrix.environment }}
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
strategy:
matrix:
environment: [staging, production]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Collect snapshot
run: node scripts/config-parity-drift.mjs collect --environment "${{ matrix.environment }}" --out "${{ matrix.environment }}.json"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: parity-snapshot-${{ matrix.environment }}
path: ${{ matrix.environment }}.json
retention-days: 7

compare:
name: Compare and alert
needs: collect
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: parity-snapshot-*
merge-multiple: true

# History lives on its own branch so it survives cache eviction.
- name: Fetch drift history
run: |
mkdir -p history
if git ls-remote --exit-code --heads origin config-drift-history >/dev/null 2>&1; then
git fetch --depth=1 origin config-drift-history
git show FETCH_HEAD:history.json > history/history.json 2>/dev/null || echo '{"drift":{}}' > history/history.json
else
echo '{"drift":{}}' > history/history.json
fi

- name: Compare environments
id: compare
run: |
set +e
node scripts/config-parity-drift.mjs compare \
--staging staging.json --production production.json \
--history history/history.json --report parity-report.md
code=$?
[ "$code" -eq 0 ] || [ "$code" -eq 2 ] || exit "$code"

- name: Save drift history
run: |
cd history
git init -q
git checkout -q -b config-drift-history
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add history.json
git commit -q -m "chore: update config drift history" || exit 0
git push -f "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" config-drift-history

- name: Alert on new drift
if: steps.compare.outputs.new_drift == 'true'
env:
WEBHOOK_URL: ${{ secrets.DEVOPS_ALERT_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$WEBHOOK_URL" ]; then echo "DEVOPS_ALERT_WEBHOOK_URL not set; skipping chat notification."; exit 0; fi
PAYLOAD=$(jq -n --arg run "$RUN_URL" --arg report "$(cat parity-report.md)" \
'{text: ("⚠️ *Staging/production config drift detected*\n" + $run + "\n\n" + $report)}')
curl -sf -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$WEBHOOK_URL"

- name: Open GitHub issue on new drift
if: steps.compare.outputs.new_drift == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('parity-report.md', 'utf8');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '[Drift] Unexpected staging/production config difference',
body: report + '\nIf a difference is intentional, add it to `devops/config-parity-allowlist.json`; otherwise align the environments.\n\nRun: ' + `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
labels: ['drift', 'needs-review'],
});

- name: Fail the run while unexpected drift exists
if: steps.compare.outputs.drift == 'true'
run: |
echo "::error::Unexpected configuration drift between staging and production. See the run summary."
exit 1
50 changes: 50 additions & 0 deletions .github/workflows/deploy-freeze.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Deployment Freeze Windows

# Validates the version-controlled freeze calendar, tests the gate logic, and
# announces upcoming freezes in the team channel (issue #930). The gate itself
# runs inside blue-green-deploy.yml.

on:
pull_request:
paths:
- "devops/deploy-freeze-calendar.json"
- "scripts/deploy-freeze-gate.mjs"
- "scripts/deploy-freeze-gate.test.mjs"
- ".github/workflows/deploy-freeze.yml"
- ".github/workflows/blue-green-deploy.yml"
schedule:
# Weekdays 09:00 UTC
- cron: "0 9 * * 1-5"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Calendar validation and gate tests
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Validate calendar
run: node scripts/deploy-freeze-gate.mjs validate
- name: Run unit tests
run: node --test scripts/deploy-freeze-gate.test.mjs

announce:
name: Announce upcoming freezes
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
env:
FREEZE_ANNOUNCE_HOURS: "72"
DEVOPS_ALERT_WEBHOOK_URL: ${{ secrets.DEVOPS_ALERT_WEBHOOK_URL }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- run: node scripts/deploy-freeze-gate.mjs announce
107 changes: 107 additions & 0 deletions .github/workflows/sbom-diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: SBOM Diff Comment

# Posts a single, updating comment on pull requests that change dependencies:
# added / removed / upgraded packages with licenses, flagging disallowed
# licenses (scripts/license-allowlist.json) and known vulnerabilities (OSV).
# Issue #929.

on:
pull_request:
paths:
- "backend/package.json"
- "backend/package-lock.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "contracts/Cargo.toml"
- "contracts/Cargo.lock"
- "scripts/sbom-diff.mjs"
- "scripts/license-allowlist.json"
- ".github/workflows/sbom-diff.yml"

permissions:
contents: read

concurrency:
group: sbom-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
unit-tests:
name: SBOM diff logic tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- run: node --test scripts/sbom-diff.test.mjs

sbom-diff:
name: Compare PR SBOM with base
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout PR head
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
path: head
- name: Checkout base branch
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"

- name: SBOM (base) backend
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./base/backend, format: spdx-json, output-file: base-backend.spdx.json, upload-artifact: false }
- name: SBOM (head) backend
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./head/backend, format: spdx-json, output-file: head-backend.spdx.json, upload-artifact: false }
- name: SBOM (base) frontend
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./base/frontend, format: spdx-json, output-file: base-frontend.spdx.json, upload-artifact: false }
- name: SBOM (head) frontend
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./head/frontend, format: spdx-json, output-file: head-frontend.spdx.json, upload-artifact: false }
- name: SBOM (base) contracts
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./base/contracts, format: spdx-json, output-file: base-contracts.spdx.json, upload-artifact: false }
- name: SBOM (head) contracts
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with: { path: ./head/contracts, format: spdx-json, output-file: head-contracts.spdx.json, upload-artifact: false }

- name: Compute SBOM diff
id: diff
# Run the script from the PR head so changes to it are exercised.
run: |
node head/scripts/sbom-diff.mjs --out sbom-diff-comment.md \
backend=base-backend.spdx.json:head-backend.spdx.json \
frontend=base-frontend.spdx.json:head-frontend.spdx.json \
contracts=base-contracts.spdx.json:head-contracts.spdx.json
cat sbom-diff-comment.md >> "$GITHUB_STEP_SUMMARY"

# One comment per PR: found by its hidden marker and edited in place.
# Fork PRs get a read-only token, so there the summary above is the output.
- name: Create or update PR comment
if: github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('sbom-diff-comment.md', 'utf8');
const marker = '<!-- sbom-diff-comment -->';
const changed = '${{ steps.diff.outputs.changed }}' === 'true';
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else if (changed) {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
16 changes: 16 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,19 @@ LOG_SAMPLE_EXEMPT_SOURCES=
# LogVolumeBudgetExceeded Prometheus alert (devops/prometheus/log-volume-alerts.yml).
LOG_VOLUME_BUDGET_BYTES=1073741824
LOG_VOLUME_BUDGET_WINDOW_MS=3600000

# Adaptive CAPTCHA (issue #934)
# Challenges are only demanded when risk signals fire (repeated failures,
# bad reputation, unusual request rate, credential-stuffing lockout).
# Set CAPTCHA_SECRET on every instance so tokens verify across a fleet.
CAPTCHA_SECRET=
CAPTCHA_FAILURE_THRESHOLD=3
CAPTCHA_REQUEST_RATE_THRESHOLD=30
CAPTCHA_WINDOW_MS=600000
CAPTCHA_TOKEN_TTL_MS=300000
CAPTCHA_MAX_SOLVE_ATTEMPTS=3
# Delay before the accessible "assisted" fallback issues a token.
CAPTCHA_ASSISTED_DELAY_MS=60000
CAPTCHA_SUPPORT_CONTACT=support@remitmortgage.com
# Comma-separated sources (IPs) with a known-bad reputation.
CAPTCHA_BAD_SOURCES=
Loading
Loading