diff --git a/.dockerignore b/.dockerignore index fa73c85..fb5f7d1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -72,6 +72,9 @@ core.* /.tools/ /.playwright-cli/ /output/ +/.runner +/act_runner +/act_runner-data/ # Local configuration can contain deployment credentials and must not be sent # to Docker when Compose builds from the repository root. diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..3f6d4a1 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,29 @@ +name: Quality + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + full-local-gates: + runs-on: who-need-help-ci + steps: + - name: Check out the repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Run isolated quality and security gates + run: ./scripts/quality.sh + + - name: Run isolated browser end-to-end tests + run: ./scripts/e2e-run.sh + + - name: Run isolated OAuth, SMTP, and push boundary drill + run: ./scripts/external-boundaries-run.sh ci-boundaries + + - name: Build, lint, and unit-test the debug Android app + env: + WNH_ENV_FILE: .env.example + run: ./scripts/android-build.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..6dc292c --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - who-need-help-ci diff --git a/.gitignore b/.gitignore index be3987f..cedad00 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,11 @@ npm-debug.log __pycache__/ *.py[cod] +# Gitea act_runner registration state and local binaries must stay outside Git. +/.runner +/act_runner +/act_runner-data/ + # Local environment files can contain deployment credentials. Keep only the # documented template in Git. /.env diff --git a/README.md b/README.md index d52e52c..6bb82ab 100644 --- a/README.md +++ b/README.md @@ -148,14 +148,14 @@ boundary entirely inside an isolated Docker network: ``` The command publishes no host ports, generates independent one-run credentials -in an ignored mode-`0600` file, verifies success, rejection, retry, replay, and -timeout paths, retains only non-secret evidence, and removes its exact Compose -project, database volume, and images. It also runs two Oban worker replicas and -verifies that request acceptance and new-chat events are delivered from their -real domain transactions, including an Oban retry and pre-transport replay -deduplication. The adapter is not presented as an FCM or APNs implementation; -an external provider must resolve the stable user recipient to registered -devices. +and a separate metrics bearer token in an ignored mode-`0600` file, verifies +success, rejection, retry, replay, and timeout paths, retains only non-secret +evidence, and removes its exact Compose project, database volume, and images. +It also runs two Oban worker replicas and verifies that request acceptance and +new-chat events are delivered from their real domain transactions, including +an Oban retry and pre-transport replay deduplication. The adapter is not +presented as an FCM or APNs implementation; an external provider must resolve +the stable user recipient to registered devices. The commands, boundaries, and unclaimed production properties are documented in [the operations runbook](docs/operations.md). diff --git a/compose.external-boundaries.yaml b/compose.external-boundaries.yaml index cdfef33..e74a555 100644 --- a/compose.external-boundaries.yaml +++ b/compose.external-boundaries.yaml @@ -4,6 +4,7 @@ x-boundary-app-environment: &boundary-app-environment DATABASE_URL: ${EXTERNAL_DATABASE_URL:?Set EXTERNAL_DATABASE_URL} SECRET_KEY_BASE: ${EXTERNAL_SECRET_KEY_BASE:?Set EXTERNAL_SECRET_KEY_BASE} HANDOVER_SECRET: ${EXTERNAL_HANDOVER_SECRET:?Set EXTERNAL_HANDOVER_SECRET} + METRICS_TOKEN: ${EXTERNAL_METRICS_TOKEN:?Set EXTERNAL_METRICS_TOKEN} PHX_HOST: boundary.local PHX_SCHEME: http PHX_URL_PORT: "80" diff --git a/docs/operations.md b/docs/operations.md index b39c3f9..19cd071 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,6 +6,34 @@ time objective, retention period, storage capacity, or high-availability model; those values require product policy and measurements from the eventual production environment. +## Private Gitea continuous integration + +The observed private Git remote is hosted by Gitea 1.22.0. Gitea 1.22 reads +repository workflows from `.gitea/workflows/`, so the repository keeps a +Gitea-specific quality workflow in addition to the GitHub-compatible workflow. +Both files are checked by the pinned local actionlint gate. + +The Gitea workflow deliberately targets only the custom +`who-need-help-ci` runner label. Its jobs build and run many Docker images and +must not be scheduled onto an unrelated shared runner or onto the production +application host. Its gates run sequentially in one job so a single push cannot +start four heavy Docker suites concurrently. Before enabling it: + +1. enable Actions in the private repository settings; +2. register a trusted runner controlled by this project; +3. assign `who-need-help-ci:host` only to a Linux host where the exact Docker + Engine and Compose commands used by the repository have been verified; +4. install the runtime required by the pinned checkout action on that host; +5. keep the runner registration state and any Docker socket access outside the + repository and away from untrusted projects. + +The runner's availability, labels, software versions, free resources and +repository Actions setting cannot be established from the unauthenticated +repository clone. Do not treat the presence of the workflow file as evidence +that remote CI has run. After provisioning the runner, trigger the workflow +manually, require the full job and all four gate steps to pass, and record the +run URL and commit before protecting `main`. + ## First production Compose environment Run the initializer on the target Docker host after its final public hostname @@ -140,8 +168,9 @@ credentials, a server, or host-published ports: The script creates a uniquely named Compose project on an internal-only Docker network. It generates independent one-run OAuth and push credentials in an -ignored mode-`0600` environment file, builds the production release plus a -non-root standard-library Python protocol mock, and then verifies: +ignored mode-`0600` environment file, including a separate bearer token for the +protected metrics endpoint, builds the production release plus a non-root +standard-library Python protocol mock, and then verifies: `compose.external-boundaries.yaml` is the only deployment file that sets `ALLOW_INSECURE_EXTERNAL_HTTP=true`. Ordinary runtime configuration requires diff --git a/docs/verification.md b/docs/verification.md index e87886b..0b264c1 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -982,6 +982,43 @@ messages. One PostgreSQL error in the audit window came from an incorrect column name in a read-only operator query; PostgreSQL aborted that transaction. The subsequent log window contained no matched error/warning entry. +## Private-Git and final boundary replay on 2026-07-20 + +The newly configured private `origin` and its `main` branch both resolved to +commit `378768b` before this replay. The observed server API identified Gitea +1.22.0. Because that release reads repository workflows from +`.gitea/workflows/`, a separate Gitea workflow now runs the same four local +gates sequentially on the dedicated `who-need-help-ci` label. Both the GitHub +and Gitea workflows passed pinned actionlint locally. Remote Actions enablement, +runner registration and an actual Gitea run remain unverified until a trusted +runner is provisioned. + +This replay found and corrected two integration regressions that the ordinary +Phoenix suite did not cover: the external-boundary workers were missing the +new required metrics credential, and the drill's product request was missing +the new required safety acknowledgement. The repaired drill then passed OAuth, +SMTP, provider-neutral push, two Oban worker replicas, retry, replay +deduplication and product-event delivery. Its non-secret mode-`0600` evidence +is `output/external-boundaries/preprod-gitea-pass`. + +- The final isolated quality/security gate passed with 244/244 ExUnit tests and + zero configured-threshold source/image findings. The Gitea runner state, + binary and local data directory are now excluded from Git and Docker build + contexts and covered by the quality script. +- The isolated Playwright suite passed all 30 scenarios across Chromium, + Firefox and WebKit, including medicine matching, chat, tracking, handover, + blind reviews, privacy, moderation, account changes and replica restart + recovery. Evidence is `output/e2e/20260720190130-1182`. +- The debug Android build passed unit tests, lint, debug APK and test APK + assembly using `.env.example`; no generated APK is tracked. +- The temporary HTTPS origin returned HTTP 200 from both `/healthz/live` and + `/healthz/ready`. The observed local Compose project had two healthy web and + two healthy worker replicas with zero restarts. +- One idle `docker stats --no-stream` snapshot measured approximately + 1,040 MiB combined across the database, Docker API proxy, Mailpit, edge + proxy, two web replicas and two worker replicas. This workstation snapshot is + not a production minimum, limit or capacity claim. + ## Known work before a public production launch - Replace the temporary staging origin with the production-owned domain and diff --git a/lib/who_need_help/external_boundary_drill.ex b/lib/who_need_help/external_boundary_drill.ex index 278c24b..ef71f2a 100644 --- a/lib/who_need_help/external_boundary_drill.ex +++ b/lib/who_need_help/external_boundary_drill.ex @@ -390,7 +390,8 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do "location_visibility" => "approximate_public", "structured_data" => %{"pickup_status" => "reserved"}, "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), - "category_id" => category.id + "category_id" => category.id, + "safety_confirmed" => true }) {:ok, assignment} = Help.accept_request(helper_scope, request.id) diff --git a/scripts/external-boundaries-run.sh b/scripts/external-boundaries-run.sh index d06cab7..563dbdc 100755 --- a/scripts/external-boundaries-run.sh +++ b/scripts/external-boundaries-run.sh @@ -35,6 +35,7 @@ chmod 700 "$ROOT/tmp" "$ROOT/tmp/external-boundaries" "$runtime_dir" \ oauth_client_id="local-$(openssl rand -hex 12)" oauth_client_secret=$(openssl rand -hex 32) push_bearer_token=$(openssl rand -hex 32) +metrics_token=$(openssl rand -hex 32) database_password=$(openssl rand -hex 24) secret_key_base=$(openssl rand -hex 64) handover_secret=$(openssl rand -hex 32) @@ -51,6 +52,7 @@ external_boundary_mock_image="who-need-help:boundary-mock-$run_id" printf 'EXTERNAL_OAUTH_CLIENT_ID=%s\n' "$oauth_client_id" printf 'EXTERNAL_OAUTH_CLIENT_SECRET=%s\n' "$oauth_client_secret" printf 'EXTERNAL_PUSH_BEARER_TOKEN=%s\n' "$push_bearer_token" + printf 'EXTERNAL_METRICS_TOKEN=%s\n' "$metrics_token" printf 'EXTERNAL_POSTGRES_PASSWORD=%s\n' "$database_password" printf 'EXTERNAL_DATABASE_URL=ecto://boundary:%s@boundary-db/boundary\n' "$database_password" printf 'EXTERNAL_SECRET_KEY_BASE=%s\n' "$secret_key_base" @@ -167,8 +169,8 @@ jq -e ' .push_product_integration.database_scope == "isolated_ephemeral_volume" ' "$output_dir/summary.json" >/dev/null -for secret in "$oauth_client_secret" "$push_bearer_token" "$database_password" \ - "$secret_key_base" "$handover_secret"; do +for secret in "$oauth_client_secret" "$push_bearer_token" "$metrics_token" \ + "$database_password" "$secret_key_base" "$handover_secret"; do if grep -R -F -q -- "$secret" "$output_dir"; then echo "Generated secret appeared in retained boundary evidence." >&2 exit 1 @@ -177,7 +179,7 @@ done test "$(stat -c '%a' "$output_dir/summary.json")" = "600" -unset oauth_client_id oauth_client_secret push_bearer_token database_password \ - secret_key_base handover_secret +unset oauth_client_id oauth_client_secret push_bearer_token metrics_token \ + database_password secret_key_base handover_secret printf 'External boundary evidence: %s\n' "$output_dir" diff --git a/scripts/quality.sh b/scripts/quality.sh index 3bb33eb..cc07334 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -68,16 +68,27 @@ for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \ hadolint --failure-threshold warning - <"$dockerfile" done -echo "Checking the GitHub Actions workflow with actionlint 1.7.12" +echo "Checking the GitHub and Gitea Actions workflows with actionlint 1.7.12" docker run --rm \ --volume "$ROOT:/repo:ro" \ --workdir /repo \ - "$ACTIONLINT_IMAGE" + "$ACTIONLINT_IMAGE" \ + -config-file .github/actionlint.yaml \ + .github/workflows/quality.yml \ + .gitea/workflows/quality.yml echo "Checking crash dumps are excluded from the Docker build context" grep -Fx 'core' .dockerignore >/dev/null grep -Fx 'core.*' .dockerignore >/dev/null +echo "Checking local Gitea runner state is excluded from Git and Docker contexts" +grep -Fx '/.runner' .gitignore >/dev/null +grep -Fx '/act_runner' .gitignore >/dev/null +grep -Fx '/act_runner-data/' .gitignore >/dev/null +grep -Fx '/.runner' .dockerignore >/dev/null +grep -Fx '/act_runner' .dockerignore >/dev/null +grep -Fx '/act_runner-data/' .dockerignore >/dev/null + echo "Checking production environment initialization and validation" production_env="$scan_dir/.env.production" PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ @@ -202,6 +213,7 @@ EXTERNAL_BOUNDARY_HOST_GID="$(id -g)" \ EXTERNAL_OAUTH_CLIENT_ID=render-client \ EXTERNAL_OAUTH_CLIENT_SECRET=render-secret \ EXTERNAL_PUSH_BEARER_TOKEN=render-push-token \ +EXTERNAL_METRICS_TOKEN=render-metrics-token \ EXTERNAL_POSTGRES_PASSWORD=render-database-secret \ EXTERNAL_DATABASE_URL=ecto://boundary:render-database-secret@boundary-db/boundary \ EXTERNAL_SECRET_KEY_BASE=render-secret-key-base \