From 908110b40d93727b5c432036ce0fef45876326d0 Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:21 -0500 Subject: [PATCH 1/4] Update README.md --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c032c9..9efc2de 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ MathTrail Canvas is a math tutor for children in grades 1–6. A child solves a The picture is a concept from the design: it shows where the product is heading, further than the MVP goes. +## The MathTrail core + +[mathtrail-standalone](https://github.com/MathTrail/mathtrail-standalone) is the core of MathTrail: a free, open-source app for Claude and ChatGPT that turns an adult's own chat into an olympiad maths trainer for grades 1–6, with checked tasks in any language, a diagnosis of the child's mistake and a memory of how they are progressing. Canvas takes its service skeleton, development environment, CI/CD and Terraform, but runs on its own: it shares neither tasks nor data with the core. + ## Documents The documents are in Russian. @@ -34,7 +38,6 @@ The documents are in Russian. ## Design -Clickable prototypes from the design, each a single page that runs in a browser with no server. GitHub shows only their source, so open them from a checkout. They show where the product is heading; where they differ from the documents, the documents win. +It shows where the product is heading; where it differs from the documents, the documents win. -- [MathTrail Screens.html](docs/design/MathTrail%20Screens.html) — eighteen screens of the prototype. -- [MathTrail Widget.html](https://mathtrail.app/en/coach/) — the olympiad coach, as one page. +- [The olympiad coach](https://mathtrail.app/en/coach/) — live on mathtrail.app. From d6ac56e9507e043a16ef056ad7523e66e79a10e8 Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:38:50 -0500 Subject: [PATCH 2/4] A green `main` now reaches prod without hands. `release.yml` ("Release and deploy") calls the new `deploy.yml` for a release that is still the newest commit of `main`. What goes out is exactly what the release signed: the server binary and the SPA archive, checked against `SHA256SUMS`. Its cosign signature must come from `release.yml` on `main` for the tag's commit. --- .devcontainer/Dockerfile | 13 +- .dockerignore | 4 + .github/actions/deployment-target/action.yml | 25 ++ .github/actions/firebase/action.yml | 34 ++ .github/actions/gcloud/action.yml | 25 ++ .github/workflows/ci.yml | 6 +- .github/workflows/deploy.yml | 303 +++++++++++++++ .github/workflows/release.yml | 24 +- .gitignore | 10 + CLAUDE.md | 6 +- Dockerfile | 22 +- README.md | 2 + RUN.md | 101 ++++- SECURITY.md | 4 +- docs/decisions.md | 103 +++++- docs/self-hosting.md | 99 ++++- docs/spec.md | 21 +- docs/stack.html | 15 +- firebase.json | 19 + infra/bootstrap.sh | 119 +++++- infra/terraform/iam.tf | 49 ++- infra/terraform/outputs.tf | 5 + infra/terraform/prod.auto.tfvars | 4 + .../terraform/tests/configuration.tftest.hcl | 50 +++ infra/terraform/variables.tf | 12 +- justfile | 345 +++++++++++++++++- 26 files changed, 1328 insertions(+), 92 deletions(-) create mode 100644 .github/actions/deployment-target/action.yml create mode 100644 .github/actions/firebase/action.yml create mode 100644 .github/actions/gcloud/action.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 firebase.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b551197..518ff57 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -102,6 +102,10 @@ ARG CLAUDE_CODE_VERSION=2.1.280 # The cloud is described in Terraform and signed into with gcloud, both from here. ARG TERRAFORM_VERSION=1.16.3 ARG GCLOUD_VERSION=585.0.0 +# The Firebase CLI, which publishes the SPA to Hosting and rolls it back. Its +# standalone build carries the Node.js it runs on, so the version pinned here is +# the whole of what runs, whatever Node.js the SPA is built with. +ARG FIREBASE_TOOLS_VERSION=15.33.0 # The tunnel, the GitHub CLI, the compose plugin and the infrastructure tools, in one # layer and behind one architecture check. Cloudflared, gh and Terraform name @@ -111,7 +115,9 @@ ARG GCLOUD_VERSION=585.0.0 # The x86_64 archive of gcloud carries a Python interpreter of its own and runs # straight out of the tarball; the arm one expects to find a system Python, which # this image deliberately does not have, so there gcloud is left out and the same -# commands are run from Google's own container image instead. +# commands are run from Google's own container image instead. The Firebase CLI +# publishes its standalone build for x86_64 alone, so it is left out on arm as +# well; there its commands run in Cloud Shell (docs/self-hosting.md). RUN arch="$(dpkg --print-architecture)"; \ case "$arch" in \ amd64) gnu_arch=x86_64 ;; \ @@ -159,8 +165,13 @@ RUN arch="$(dpkg --print-architecture)"; \ fetch "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-${GCLOUD_VERSION}-linux-x86_64.tar.gz" \ "$workdir/gcloud-${GCLOUD_VERSION}-x86_64.tar.gz"; \ tar -C /usr/local -xzf "$workdir/gcloud-${GCLOUD_VERSION}-x86_64.tar.gz"; \ + echo "firebase ${FIREBASE_TOOLS_VERSION}"; \ + fetch "https://github.com/firebase/firebase-tools/releases/download/v${FIREBASE_TOOLS_VERSION}/firebase-tools-linux" \ + "$workdir/firebase-${FIREBASE_TOOLS_VERSION}"; \ + install -m 0755 "$workdir/firebase-${FIREBASE_TOOLS_VERSION}" /usr/local/bin/firebase; \ else \ echo "gcloud ${GCLOUD_VERSION}: skipped, no interpreter in the ${arch} archive"; \ + echo "firebase ${FIREBASE_TOOLS_VERSION}: skipped, no standalone build for ${arch}"; \ fi; \ \ rm -rf "$workdir" diff --git a/.dockerignore b/.dockerignore index 35b3b36..0f73cb0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,3 +31,7 @@ go.work go.work.sum *.md **/node_modules +firebase.json +.firebase +firebase-debug*.log +gha-creds-*.json diff --git a/.github/actions/deployment-target/action.yml b/.github/actions/deployment-target/action.yml new file mode 100644 index 0000000..46de647 --- /dev/null +++ b/.github/actions/deployment-target/action.yml @@ -0,0 +1,25 @@ +name: deployment target +description: Puts what a workflow needs before it can sign in to Google into the environment, read from the file that records it. + +runs: + using: composite + steps: + - shell: bash + run: | + set -euo pipefail + + # The pool and the identity a workflow signs in through, which have to + # be known before there is any state to read them from: the two lines + # infra/bootstrap.sh prints. A line that is missing would leave the + # sign-in with an empty value, and its error would say nothing of why. + target="$GITHUB_WORKSPACE/infra/ci.env" + for name in GCP_WORKLOAD_IDENTITY_PROVIDER GCP_TERRAFORM_SERVICE_ACCOUNT; do + if ! grep -qE "^${name}=.+" "$target"; then + echo "deployment-target: $target names no $name; put there the two lines infra/bootstrap.sh prints" >&2 + exit 1 + fi + done + + # Comments and blank lines are dropped: the environment file accepts + # neither. + grep -E '^[A-Z][A-Z0-9_]*=' "$target" >> "$GITHUB_ENV" diff --git a/.github/actions/firebase/action.yml b/.github/actions/firebase/action.yml new file mode 100644 index 0000000..72d465e --- /dev/null +++ b/.github/actions/firebase/action.yml @@ -0,0 +1,34 @@ +name: firebase +description: Installs the Firebase CLI at the version this repository pins for it. + +runs: + using: composite + steps: + - shell: bash + run: | + set -euo pipefail + + # The version lives where the development image installs it, as the + # default of an ARG, so it is written down once and read from there. + dockerfile="$GITHUB_WORKSPACE/.devcontainer/Dockerfile" + version="$(sed -n 's/^ARG FIREBASE_TOOLS_VERSION=//p' "$dockerfile")" + if [ -z "$version" ]; then + echo "firebase: $dockerfile pins no FIREBASE_TOOLS_VERSION" >&2 + exit 1 + fi + + # The standalone build, which carries the Node.js it runs on, so that + # the version above is the whole of what runs. It is published for + # x86_64 alone. + if [ "$(uname -m)" != x86_64 ]; then + echo "firebase: the standalone build is made for x86_64, and this runner is $(uname -m)" >&2 + exit 1 + fi + + echo "firebase $version" + mkdir -p "$RUNNER_TEMP/bin" + curl -fsSL --retry 3 --proto "=https" --proto-redir "=https" \ + -o "$RUNNER_TEMP/bin/firebase" \ + "https://github.com/firebase/firebase-tools/releases/download/v${version}/firebase-tools-linux" + chmod 0755 "$RUNNER_TEMP/bin/firebase" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" diff --git a/.github/actions/gcloud/action.yml b/.github/actions/gcloud/action.yml new file mode 100644 index 0000000..b155a3e --- /dev/null +++ b/.github/actions/gcloud/action.yml @@ -0,0 +1,25 @@ +name: gcloud +description: Installs the Google Cloud CLI at the version this repository pins for it. + +runs: + using: composite + steps: + - id: version + shell: bash + run: | + set -euo pipefail + + # The version lives where the development image installs it, as the + # default of an ARG, so it is written down once and read from there. + dockerfile="$GITHUB_WORKSPACE/.devcontainer/Dockerfile" + version="$(sed -n 's/^ARG GCLOUD_VERSION=//p' "$dockerfile")" + if [ -z "$version" ]; then + echo "gcloud: $dockerfile pins no GCLOUD_VERSION" >&2 + exit 1 + fi + echo "gcloud $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + with: + version: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1af2fc5..9c89528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,9 +298,9 @@ jobs: # lock file names, and true to its own tests, which run against mock # providers. On the runner, because the toolchain image has no Terraform: # the development image adds it in the stage on top. Nothing here signs in - # anywhere, so the job's token reads the code and holds no identity token — - # Dependabot's pull requests run it too, and the federated pool admits any - # workflow of this repository. + # anywhere, so the job's token reads the code and holds no identity token: + # Dependabot's pull requests run it too. What an apply would change is shown + # by deploy.yml, the one workflow the federated pool admits (R58). terraform: name: Terraform runs-on: ubuntu-24.04 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0ab2497 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,303 @@ +# Continuous delivery: brings the cloud up to what a release describes, rolls a +# revision of the service onto the server that release signed, and publishes +# its SPA to Firebase Hosting (R32, R45, R58). +# +# Every step is a pinned action or a recipe of the justfile, so the same +# commands work from a laptop. No key exists anywhere: each job borrows a +# federated identity, and each identity is lent to the jobs of one GitHub +# environment alone, by the subject GitHub gives them. The jobs that change +# prod run in prod and the one that publishes the SPA in prod-hosting, and only +# main may run in either (infra/terraform/iam.tf). +name: Deploy + +on: + # The release just published, from release.yml, which calls this only while + # main still ends at that release's commit. + workflow_call: + inputs: + version: + description: "The release to deliver, as its tag names it." + type: string + required: true + secrets: + TF_VAR_billing_account: + required: true + TF_VAR_operator_email: + required: false + # By hand, on main alone: delivers the release at the tip of main, and + # nothing when there is none there. + workflow_dispatch: + # A pull request is shown what an apply would change, and nothing is applied. + pull_request: + paths: + - "infra/**" + - ".github/workflows/deploy.yml" + - ".github/actions/**" + - "justfile" + +permissions: + contents: read + +# One delivery at a time, and none cancelled halfway: an apply or a roll-out +# cut short leaves a state nobody decided on. A delivery that waits gives way +# to a newer one, which delivers a newer release. A pull request's plan reads +# and changes nothing, so it waits for no delivery and keeps no delivery +# waiting; a new push to the pull request replaces its plan still running. The +# button pressed on another branch delivers nothing, and waits in a group of +# its own, so that it cannot push out a delivery of main waiting in this one. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('cd-plan-{0}', github.event.pull_request.number) || github.ref == 'refs/heads/main' && 'cd' || format('cd-{0}', github.ref) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + +jobs: + # The plan reads the state, and the state holds what the deployment signs in + # with (R34), so it is shown only to a pull request of a branch of this + # repository, which only its owner can push: never to a fork, and never to + # Dependabot, whose pull requests change the versions of the actions this job + # would run with the Terraform identity before anybody has read them. + plan: + name: What an apply would change + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/just + - uses: ./.github/actions/deployment-target + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_TERRAFORM_SERVICE_ACCOUNT }} + - uses: ./.github/actions/terraform + # Into the run's summary as well as its log, where a reviewer of the + # pull request finds it. The step fails as the plan did, once the plan + # is shown. + - name: Plan + env: + TF_VAR_billing_account: ${{ secrets.TF_VAR_billing_account }} + TF_VAR_operator_email: ${{ secrets.TF_VAR_operator_email }} + run: | + plan="$RUNNER_TEMP/plan.txt" + status=0 + just ci-tf-plan > "$plan" || status=$? + cat "$plan" + { + echo "### What an apply would change" + echo + echo '```' + cat "$plan" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" + + # Which release goes out, read before anything signs in: a run that delivers + # nothing is green, and says why. + target: + name: Which release + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} + deliver: ${{ steps.release.outputs.deliver }} + steps: + # Asked before anything of the repository is fetched. GitHub would keep + # a run on another branch out of both environments anyway; this says so + # in a word, before a job is even started. + - name: Whether this run may deliver + if: github.event_name == 'workflow_dispatch' + run: | + if [ "$GITHUB_REF" != refs/heads/main ]; then + echo "::error::A delivery runs from main alone, and this run is on ${GITHUB_REF}." + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/just + - id: release + env: + VERSION: ${{ inputs.version }} + run: just ci-release-target "$VERSION" >> "$GITHUB_OUTPUT" + - name: Why nothing is delivered + if: steps.release.outputs.deliver != 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if [ -n "$VERSION" ]; then + echo "::notice::main has moved on past ${VERSION}: the release of its tip delivers itself, and this one is not delivered." + else + echo "::notice::No release is at the tip of main, so nothing is delivered." + fi + + # Each job below checks out the commit the release's tag names, so that the + # configuration — Terraform, firebase.json, the Dockerfile — is the + # release's own. "Re-run failed jobs" runs a job again with what the first + # attempt decided, however long ago that was, so on a later attempt each job + # first asks whether main still ends at that commit. + infra: + name: The cloud, as the release describes it + needs: target + if: needs.target.outputs.deliver == 'true' + environment: prod + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + outputs: + project_id: ${{ steps.tf.outputs.project_id }} + deploy_service_account: ${{ steps.tf.outputs.deploy_service_account }} + hosting_service_account: ${{ steps.tf.outputs.hosting_service_account }} + image_repository: ${{ steps.tf.outputs.image_repository }} + service_name: ${{ steps.tf.outputs.service_name }} + region: ${{ steps.tf.outputs.region }} + service_url: ${{ steps.tf.outputs.service_url }} + hosting_url: ${{ steps.tf.outputs.hosting_url }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.commit }} + persist-credentials: false + - uses: ./.github/actions/just + - name: Whether main still ends at the release + if: github.run_attempt != '1' + env: + COMMIT: ${{ needs.target.outputs.commit }} + run: just ci-release-current "$COMMIT" + - uses: ./.github/actions/deployment-target + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_TERRAFORM_SERVICE_ACCOUNT }} + - uses: ./.github/actions/terraform + - name: Apply + env: + # The two facts about the deployment that are not written in the + # repository: the account that pays, and where the alert is mailed. + TF_VAR_billing_account: ${{ secrets.TF_VAR_billing_account }} + TF_VAR_operator_email: ${{ secrets.TF_VAR_operator_email }} + run: just ci-tf-apply + - id: tf + name: What the jobs after this one need + run: just ci-tf-outputs >> "$GITHUB_OUTPUT" + + # The server goes out before the SPA, and is checked before the SPA goes out: + # a new page in front of a service that does not answer would break what + # works now (R58). + deploy: + name: The server of the release + needs: [target, infra] + environment: + name: prod + url: ${{ needs.infra.outputs.service_url }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + id-token: write + env: + VERSION: ${{ needs.target.outputs.version }} + COMMIT: ${{ needs.target.outputs.commit }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.commit }} + persist-credentials: false + - uses: ./.github/actions/just + - name: Whether main still ends at the release + if: github.run_attempt != '1' + run: just ci-release-current "$COMMIT" + # Fetched and checked before anything signs in: nothing the release did + # not sign gets as far as the identity. + - name: The server the release signed + env: + GH_TOKEN: ${{ github.token }} + run: just ci-release-fetch "$VERSION" "$COMMIT" "canvas-api-server_${VERSION}_linux_amd64" "$RUNNER_TEMP/release" + - uses: ./.github/actions/deployment-target + # A narrower identity than the one that applied the configuration: it + # may push an image and roll a revision, and nothing else. + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ needs.infra.outputs.deploy_service_account }} + - uses: ./.github/actions/gcloud + # Read into a variable first: inside the echo, a push that failed would + # leave the step green, and the next one would roll out an empty + # reference. + - id: image + name: The image of the release + env: + REPOSITORY: ${{ needs.infra.outputs.image_repository }} + run: | + reference="$(just ci-image-push "$RUNNER_TEMP/release/canvas-api-server_${VERSION}_linux_amd64" "$VERSION" "$COMMIT" "$REPOSITORY/server")" + echo "reference=$reference" >> "$GITHUB_OUTPUT" + - name: Roll out the revision + env: + SERVICE: ${{ needs.infra.outputs.service_name }} + REGION: ${{ needs.infra.outputs.region }} + IMAGE: ${{ steps.image.outputs.reference }} + run: just ci-deploy "$SERVICE" "$REGION" "$IMAGE" + - name: What answers is the release + env: + SERVICE_URL: ${{ needs.infra.outputs.service_url }} + run: just ci-smoke "$SERVICE_URL" "$VERSION" "$COMMIT" + + # An environment of its own, because this job runs a large program of + # somebody else's, the Firebase CLI, with an identity: the identity it can + # borrow publishes the SPA, and reaches neither the service nor the + # configuration. + hosting: + name: The SPA of the release + needs: [target, infra, deploy] + environment: + name: prod-hosting + url: ${{ needs.infra.outputs.hosting_url }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + env: + VERSION: ${{ needs.target.outputs.version }} + COMMIT: ${{ needs.target.outputs.commit }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.commit }} + persist-credentials: false + - uses: ./.github/actions/just + - name: Whether main still ends at the release + if: github.run_attempt != '1' + run: just ci-release-current "$COMMIT" + - name: The SPA the release signed + env: + GH_TOKEN: ${{ github.token }} + run: just ci-release-fetch "$VERSION" "$COMMIT" "canvas-web_${VERSION}.tar.gz" "$RUNNER_TEMP/release" + - uses: ./.github/actions/deployment-target + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ needs.infra.outputs.hosting_service_account }} + - uses: ./.github/actions/firebase + - name: Publish the SPA + env: + PROJECT: ${{ needs.infra.outputs.project_id }} + run: just ci-hosting-deploy "$RUNNER_TEMP/release/canvas-web_${VERSION}.tar.gz" "$PROJECT" "$VERSION" + - name: What the site serves is the release + env: + HOSTING_URL: ${{ needs.infra.outputs.hosting_url }} + run: just ci-hosting-check "$HOSTING_URL" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8a9c06..fd871cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,11 @@ # that failed, say — and only then is a version asked for on it used; it works # on main alone, and only once the checks have passed on that commit (R33, R45, # R52). -name: Release +# +# A release that is still the newest of main is then delivered to prod, by +# deploy.yml, within this same run: its status, here, is the status of the +# whole way from a green main to prod (R45, R58). +name: Release and deploy on: workflow_run: @@ -186,3 +190,21 @@ jobs: if [ "$newest" != true ]; then echo "main ends at $tip now: $TARGET is released, and is not the newest of main" fi + + # Only while main still ends at the commit just released: a release of an + # older commit — one whose checks finished late, or were run again — would + # roll prod back (R45). The delivery is given the two secrets it needs, by + # name, and none of the others this repository keeps (R58). + deploy: + name: Deploy + needs: release + if: needs.release.outputs.newest == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/deploy.yml + with: + version: ${{ needs.release.outputs.version }} + secrets: + TF_VAR_billing_account: ${{ secrets.TF_VAR_billing_account }} + TF_VAR_operator_email: ${{ secrets.TF_VAR_operator_email }} diff --git a/.gitignore b/.gitignore index e8c16d0..ac9bd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,16 @@ crash.*.log # Working directory of the static analysis scanner. .scannerwork/ +# The credentials file Google's sign-in action writes into a workflow's +# checkout: short-lived, and never anything to commit. +gha-creds-*.json + +# What the Firebase CLI leaves where it runs: the sums of the files it last +# published, and the log of a command that failed. +.firebase/ +firebase-debug.log* +firebase-debug.*.log* + # Claude Code's settings of one person on one machine, such as the narrow # permission of a live run, which is deleted once the run is over (CLAUDE.md). .claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 864f99a..191fcee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,7 @@ deploy/ infra/terraform/ the Google Cloud project as code — not to be confused with internal/infra/ infra/bootstrap.sh the one-time step that makes what the pipeline signs in with: project, state bucket, WIF web/ the SPA: React and Konva (R13); src/gen/ holds the generated TypeScript +firebase.json what Firebase Hosting serves, and with which headers (spec 15.5) docs/ the documents; live/ holds the reports of spikes and live runs draft/ the screen mockup: a proposal, not a spec .devcontainer/ justfile .github/workflows/ @@ -298,7 +299,7 @@ The host needs Docker and VS Code and nothing else: no builder plugin, no daemon Inside the container: - **Pinned versions.** No separate versions file: each tool's exact version is an `ARG` default in `.devcontainer/Dockerfile`, repeated as a literal in `.devcontainer/devcontainer.json` where needed. - - The Dockerfile has two stages. `toolchain` installs Go, Node.js, just, golangci-lint and mockery — what it takes to build and check the code; `devcontainer` adds gopls, dlv, cloudflared, gh, the Docker Compose plugin, Terraform, gcloud and Claude Code on top, and is the stage the container is built from. cloudflared is the tunnel that opens the SPA served from here on a real tablet. gcloud is installed on x86_64 only, because its archive for arm carries no Python interpreter; there the same commands come from Google's own container image (R28 standalone). + - The Dockerfile has two stages. `toolchain` installs Go, Node.js, just, golangci-lint and mockery — what it takes to build and check the code; `devcontainer` adds gopls, dlv, cloudflared, gh, the Docker Compose plugin, Terraform, gcloud, the Firebase CLI and Claude Code on top, and is the stage the container is built from. cloudflared is the tunnel that opens the SPA served from here on a real tablet. gcloud is installed on x86_64 only, because its archive for arm carries no Python interpreter; there the same commands come from Google's own container image (R28 standalone). The Firebase CLI is installed on x86_64 only as well, as the standalone build that carries its own Node.js, which is made for x86_64 alone; on arm its commands run in Cloud Shell (R58). - CI runs the full checks in the `toolchain` stage, which it publishes to GHCR under a hash of that stage and runs by digest, so a verdict there and one in here come from the same tools at the same versions (`just ci-toolchain-image`; R20 standalone, R49). On `main` the checks run only in an image that `main` itself signed with cosign; any other image under the tag is built again and signed (`just ci-toolchain-sign`; R53). - Everything is installed straight from the Dockerfile, and downloads refuse a protocol downgrade (R35 standalone). What is verified is verified by the source it comes from: the Go checksum database for `go install`, the registry hash for the npm package; the tarballs fetched with `curl` carry no checksum of their own. Of the npm packages only the Claude Code CLI may run its own install step, named explicitly, because that step is what puts its native binary in place. - Docker (docker-in-docker, Moby engine and buildx) comes from a devcontainer feature pinned in `devcontainer.json` and `devcontainer-lock.json`. @@ -314,4 +315,5 @@ Inside the container: - gofmt never walks into `reference/`: the copy is the sample's, read-only. - **Git hooks.** `.githooks/pre-commit` is enabled by `post-start` through `core.hooksPath`. It runs formatting, a build and the tests — enough to catch what is embarrassing, while the linter, the race detector, `govulncheck` and `gitleaks` wait for CI. Where no Go toolchain is reachable — a Git client outside the container, for instance — it says so and skips those checks instead of blocking the commit: CI runs them again and is what gates a merge. - **The runtime image.** `just docker-build` builds it and `just docker-run` starts it on port 8080. Both images in the root `Dockerfile` are pinned: a Go builder by tag and digest, at the version `go.mod` names, and a distroless static runtime by digest, since distroless names no versions in its tags; the runtime has no shell and runs as a non-root user. The SPA is no part of it: Firebase Hosting serves the build (R10). -- **Build context.** Both images are built with the repository root as context: the devcontainer copies nothing from it, the runtime image the Go module. `.dockerignore` keeps out of it the history, every `.env` and its variants such as `.env.local`, `.github/`, `.vscode/`, `.sonarlint/`, `.claude/`, `.githooks/`, `.devcontainer/`, `reference/`, `docs/`, `draft/`, `web/`, `infra/`, every `testdata/`, `tools/`, `bin/`, `dist/`, test and profile output, `.scannerwork/`, a local `go.work`, `node_modules` and the Markdown files at the root. +- **Build context.** Both images are built with the repository root as context: the devcontainer copies nothing from it, the runtime image the Go module. `.dockerignore` keeps out of it the history, every `.env` and its variants such as `.env.local`, `.github/`, `.vscode/`, `.sonarlint/`, `.claude/`, `.githooks/`, `.devcontainer/`, `reference/`, `docs/`, `draft/`, `web/`, `infra/`, every `testdata/`, `tools/`, `bin/`, `dist/`, test and profile output, `.scannerwork/`, a local `go.work`, `node_modules`, the Markdown files at the root, `firebase.json` and what the Firebase CLI leaves behind, and the credentials file Google's sign-in action writes into a workflow's checkout. +- **The release image.** A delivery does not build the server again: the `release` stage of the same `Dockerfile` puts the server a release signed into the same runtime, from a build context of its own (`just ci-image-push`, R58). diff --git a/Dockerfile b/Dockerfile index 299a2f6..f7cfeba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,11 +33,25 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /server ./cmd/server # The runtime holds the binary and a certificate bundle, and nothing else: no -# shell, no package manager, and a non-root user (uid 65532). -FROM gcr.io/distroless/static-debian13@sha256:58133991db06659feaabe0f4e97a35cebf15ef4ea08f8a4c6d2ee5f75e4aa6a0 - -COPY --from=build /server /server +# shell, no package manager, and a non-root user (uid 65532). It is written +# once, for both images below. +FROM gcr.io/distroless/static-debian13@sha256:58133991db06659feaabe0f4e97a35cebf15ef4ea08f8a4c6d2ee5f75e4aa6a0 AS runtime USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/server"] + +# The image a delivery rolls out: the runtime around the server a release +# signed, which the delivery has checked before it gets here (R58). The binary +# arrives in a build context of its own, named binary and holding it alone as +# server; downloaded, it has no execute bit, so it is given one here. Built +# only when asked for by name, --target release. +FROM runtime AS release + +COPY --from=binary --chmod=0555 server /server + +# The image built from the source, which the checks build, start and scan. It +# is the last stage, so a build that names no target gets it. +FROM runtime + +COPY --from=build /server /server diff --git a/README.md b/README.md index 9efc2de..f56f8a0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![CI](https://github.com/MathTrail/canvas-api/actions/workflows/ci.yml/badge.svg)](https://github.com/MathTrail/canvas-api/actions/workflows/ci.yml) [![CodeQL](https://github.com/MathTrail/canvas-api/actions/workflows/codeql.yml/badge.svg)](https://github.com/MathTrail/canvas-api/actions/workflows/codeql.yml) +[![Release and deploy](https://github.com/MathTrail/canvas-api/actions/workflows/release.yml/badge.svg)](https://github.com/MathTrail/canvas-api/actions/workflows/release.yml) [![Release](https://img.shields.io/github/v/release/MathTrail/canvas-api)](https://github.com/MathTrail/canvas-api/releases/latest) [![Go Version](https://img.shields.io/github/go-mod/go-version/MathTrail/canvas-api)](https://github.com/MathTrail/canvas-api/blob/main/go.mod) [![codecov](https://codecov.io/gh/MathTrail/canvas-api/branch/main/graph/badge.svg)](https://codecov.io/gh/MathTrail/canvas-api) @@ -33,6 +34,7 @@ The documents are in Russian. - [docs/spec.md](docs/spec.md) — how it is built: architecture, the sheet and the check images, the answer check, the draft analysis, the AI tutor, the API, data, security and deployment. - [docs/privacy.md](docs/privacy.md) — children's data: what is collected and kept, what the external AI service receives, parental consent, deletion and retention. - [docs/decisions.md](docs/decisions.md) — the decision log: what was decided, why, and what was rejected. +- [docs/self-hosting.md](docs/self-hosting.md) — how it is deployed: the one-time bootstrap, the delivery of every release, rollback and return. - [docs/stack.html](docs/stack.html) — the technology stack, layer by layer, with versions and what is still to be chosen. Open the file from a checkout in a browser: GitHub shows only its source. - [RUN.md](RUN.md) — the implementation plan, in epics: the current epic broken into small tasks, run and reviewed one at a time, and the later epics. diff --git a/RUN.md b/RUN.md index 6b93e9e..fb7fe68 100644 --- a/RUN.md +++ b/RUN.md @@ -137,7 +137,7 @@ | [x] | T08 | Релиз | Claude | — | T06 | | [x] | T09 | Решения для поставки | я + Claude | — | T01 | | [x] | T10 | Terraform | Claude | да | T04, T09 | -| [ ] | T11 | CD | Claude | да | T06, T07, T08, T10 | +| [x] | T11 | CD | Claude | да | T06, T07, T08, T10 | | [ ] | T12 | Первый деплой | я + Claude | — | T11 | **Почему такой порядок.** Начало задал я. Сначала правила (T01), простейший сервер (T02) и сразу вся DevX-часть (T03–T12). К первой строчке продукта уже работают проверки, релиз и выкладка, и каждая следующая задача проходит через тот же конвейер. @@ -1216,6 +1216,102 @@ - перезапуск CI старого коммита ничего не выкатывает; - откат по записанным командам возвращает прошлую ревизию и прошлую SPA, и `/health` называет её релиз; возврат на latest работает. - **Разделы:** spec 15.5; R31, R32, R33, R34, R45; standalone: R29, R30, R32, R33, R154. +- **Сделано (2026-10-14)** — по утверждённому плану. Решения автора в планировании (R58): + - окружения `prod` и `prod-hosting`, только для `main`, и WIF по их subject; + - выкатываются подписанные файлы релиза, а не сборка из тега; + - у выкладки SPA свой аккаунт; + - firebase-tools — автономный бинарник. + + Что сделано: + - **`deploy.yml`:** + - `target` выбирает релиз без учётных данных: переданный номер или, по кнопке, старший релиз на вершине `main`. Устаревший релиз — зелёный прогон с notice, кнопка вне `main` падает; + - `infra` и `deploy` — в окружении `prod`, `hosting` — в `prod-hosting`. На повторной попытке каждое из них сначала проверяет, что `main` стоит на коммите релиза; + - `plan` — на PR веток репозитория, кроме PR Dependabot; план — в summary; + - concurrency: `cd` без отмены, у PR — своя группа с отменой. `id-token: write` есть только у четырёх заданий. + - **`release.yml`** — «Release and deploy». Задание `deploy` вызывает `deploy.yml`, если `newest`; секреты передаются по имени. + - **Actions:** `deployment-target` грузит только `infra/ci.env`; `gcloud` и `firebase` читают версии из `ARG`. + - **Рецепты:** + - `ci-release-target` и `ci-release-current` — какой релиз и свежесть; + - `ci-release-fetch` — подпись `SHA256SUMS` с коммитом тега, затем строка файла; + - `ci-image-push` — стадия `release`, health до push, digest из вывода push; + - `ci-deploy` — только digest, затем `update-traffic --to-latest`; + - `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`; + - общий `_image-health`: его зовёт и `ci-image-smoke`; + - `ci-tf-outputs` — ещё `project_id` и `hosting_service_account`. + - **Dockerfile** — стадии `runtime` и `release`. Образ CI остаётся последней стадией, сборки CI и `docker-build` не меняются. В `.dockerignore` и `.gitignore` — `firebase.json`, следы Firebase CLI и файл учётных данных action `auth`. + - **`firebase.json`** — `web/dist`; `no-cache` на странице, год и `immutable` на `/assets/**`; rewrites нет. + - **Terraform и bootstrap** (правило 13): + - переменная `github_oidc_subject_prefix` с валидацией; + - аккаунт деплоя — по subject `prod`; + - новый аккаунт `canvas-api-hosting`: две роли проекта и subject `prod-hosting`; выход `hosting_service_account`; + - четыре новых теста, всего их 16: последний сверяет роли проекта со списком условия в bootstrap; + - bootstrap: аккаунт Terraform — по subject `prod` и `pull_request`, привязка «весь репозиторий» снимается, `projectIamAdmin` выдаётся с условием на две роли. Под другим условием или без условия роль сначала снимается, а политику скрипт читает с повторами. + - **Devcontainer:** `ARG FIREBASE_TOOLS_VERSION=15.33.0`, бинарник на x86_64. Хеш toolchain не изменился, образ CI не пересобирается. Нужна пересборка контейнера автором. + - **Документы:** + - R58; поправки R31, R45, R46, R56; spec 15.2, 15.5, 18; + - `docs/self-hosting.md` — выкладка, окружения, откат и возврат, «Когда меняется bootstrap»; + - `SECURITY.md`; README — плашка «Release and deploy» и ссылка на `self-hosting.md`; `docs/stack.html`; `CLAUDE.md`; + - комментарий задания «Terraform» в `ci.yml`; + - T12 — проверка отката на второй выкладке. + - **Сверх плана:** + - подпись `SHA256SUMS` привязана к коммиту тега (`--certificate-github-workflow-sha`): подписанные файлы другого релиза не пройдут; + - `firebase.json` без `ignore`: сайт — весь архив; + - `deployment-target` требует обе строки `ci.env` вместо проверки на `REPLACE`: заглушек в этом файле не бывает. + - **Проверки без облака:** + - `ci-release-target` — на настоящем репозитории и на подставном remote: лёгкие и аннотированные теги, несколько тегов на вершине, `v1.0.0-rc1`, устаревший релиз, нет релиза; + - `ci-release-fetch` — на настоящем v0.1.2; подставной `gh` отдаёт испорченный бинарник, переписанные суммы, бандл другого файла, суммы без бандла и подписанный релиз образца — всё отвергнуто, и в папку ничего не попадает; + - `ci-image-push` собрал образ из подписанного бинарника v0.1.2 и отправил его в локальный registry (`gcloud` подставной). Образ называет v0.1.2 и 701cb47, в registry лежит один манифест, а не индекс; + - `ci-deploy` с подставным `gcloud` и `ci-smoke` против контейнера из этого образа; + - `firebase.json` и `ci-hosting-check` — на эмуляторе Hosting из того же бинарника: заголовки как задуманы, `/__/` свободен, схема v15.33.0 принимает файл и отвергает испорченный (check-jsonschema 0.34.1); + - bootstrap — с подставным `gcloud`, который хранит политики в файлах. Сценарии: свежий проект; повтор, который ничего не трогает; роль под старым условием и без условия; два неудачных чтения; чтения, которые не проходят вовсе; + - actionlint 1.7.12 со shellcheck: новых находок нет; shellcheck 0.11.0 — bootstrap, скрипты actions и рецептов. + - **Выключенные проверки** — 18, каждая выключена в копии или сломана и возвращена, и её плохой случай проходит: + - подпись сумм, личность подписавшего, коммит в подписи, сумма файла; + - свежесть переданной версии и на повторной попытке; у кнопки — старшая версия, только релизы и коммит аннотированного тега; + - версия и коммит в health образа, коммит в smoke; + - выкладка только по digest; + - страница сайта — та, что в `web/dist`, и без кеша; + - в bootstrap: чтение политики с повторами — без него снятие старой привязки молча пропускается, а скрипт пишет «Done»; роль под одним условием — без этого рядом ложится вторая привязка; + - в Terraform: роли SPA совпадают со списком условия bootstrap — тест краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`. + + Fuzz нет, флейков не было. + - **Ревью `/code-review high`:** + - первый круг, 8 находок. Исправлено: + - снятие привязки «весь репозиторий» зависело от одного чтения без повтора, и неудачное чтение молча его пропускало. Теперь чтение повторяется (`retry_read`), а сбой останавливает скрипт; + - новый список ролей в условии `projectIamAdmin` ложился второй привязкой рядом со старой. Теперь bootstrap держит роль под одним условием; + - грамматика тега релиза была скопирована в четыре рецепта. Теперь она одна — `RELEASE_TAG`; + - R58 утверждал, что «только `main`» держит платформа и для аккаунта Terraform. Уточнено: у него есть ещё план на PR. + + Отброшено: + - общий action для чтения версии из `ARG` — отвергнут в R56, а пропущенная копия падает с ошибкой, а не молча; + - общий шаг проверки свежести для трёх заданий: три шага по четыре строки рядом с комментарием проще абстракции (CLAUDE.md, «Simplicity beats purity»); + - `rm -rf web/dist` и `gcloud auth configure-docker` в рецептах: `web/dist` — игнорируемая сборка, которую называет `firebase.json`, а запись помощника docker идемпотентна и та же, что в образце. + + Два вопроса — автору, в «Открыто». + - второй круг, 6 находок. Исправлено: + - кнопка на другой ветке вставала в общую группу `cd` и вытесняла бы ждущую выкладку `main`. Теперь она ждёт в группе своей ветки; + - роли SPA записаны и в `iam.tf`, и в условии bootstrap, и расхождение проявилось бы 403 после мержа. Теперь их сверяет тест `terraform test`; он краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`; + - `--proto-redir` без `-L` ничего не делал, а комментарий обещал защиту от понижения. Флаг убран, комментарии говорят как есть: редиректы не выполняются; + - переменная `condition` в bootstrap держала и условие пула, и путь к файлу условия роли. Теперь файл — `condition_file`. + + Отброшено: «`ci-hosting-check` примет старую страницу, если `index.html` не изменился». Страница называет скрипт по хешу содержимого, а скрипт несёт номер релиза, поэтому одинаковых страниц у двух релизов не бывает: у v0.1.2 — `index-D48AILHJ.js`, у сборки дерева — `index-CTruYdVY.js`. Это записано в комментарий рецепта. + + Вопрос — автору: контракт `release.yml` ↔ `deploy.yml` до мержа не проверяется. Исправления второго круга прошли проверки, третьего ревью нет. +- **Открыто:** + - вопросы автору по ревью: + - отдельный аккаунт плана, только для чтения, вместо аккаунта Terraform на PR. Тогда PR не сможет применить конфигурацию. Сейчас это принятая цена плана: PR из ветки репозитория открывает только автор (R58, «Следствие»); + - выкладка ревизии без трафика, с тегом: smoke по адресу тега, и только затем трафик. Сейчас трафик переходит до smoke, а упавший smoke оставляет ревизию в работе до ручного отката. Её `/health` проверен на том же образе до push, а startup probe не даёт трафик неотвечающей ревизии; + - actionlint в CI. Он сверяет и контракт вызова `release.yml` → `deploy.yml` — входы и секреты, — который сейчас проверяется только после мержа: сломанный контракт оставит коммит без релиза. Ему нужно исключение для `queue: max`, которого он не знает (R52); + - живые шаги до мержа, с подтверждения автора (≈ $0): + - автор создаёт окружения `prod` и `prod-hosting` (только `main`) и секрет `TF_VAR_billing_account`; + - Claude сверяет окружения через `gh api`, повторяет bootstrap и проверяет привязки; + - задание `plan` на PR этой задачи — первое живое доказательство subject PR и условия пула; + - мерж — первая выкладка. «Как проверить» после мержа сверяется в T12, откат и возврат — на второй выкладке (T12); + - пересборка devcontainer автором: в нём появится `firebase`; + - actionlint 1.7.12 не знает `queue: max` в задании `release` (T08): это его находка, к T11 она не относится; + - `ci-toolchain-image` пишет вывод push через `tee /dev/stderr`, и если stderr перенаправлен в файл, файл обрезается. В CI stderr — труба, и проблемы нет; рецепт T08 не тронут; + - неизменяемые релизы и правило на теги — открыто с T08 (R58, «Следствие»); + - concurrency вызванного workflow держится на опыте сообщества, а не на документации. #### T12. Первый деплой @@ -1223,7 +1319,7 @@ - **Сделать:** - **Я** — дела без API, каждое один раз: - bootstrap сделан в T10, его строки уже в `infra/ci.env`. Повторить `just bootstrap` — только если скрипт менялся (`docs/self-hosting.md`); - - секрет репозитория `TF_VAR_billing_account`; + - секрет репозитория `TF_VAR_billing_account` и окружения `prod` и `prod-hosting` — уже в T11, до её мержа: её мерж — первая выкладка; - секрет репозитория `TF_VAR_operator_email` — почта для алерта (T10). Без неё алерт не создаётся; - Firebase: принять условия, если этого не может Terraform; - своих доменов нет (R55): адреса — `run.app` и `web.app`, DNS не нужен. @@ -1242,6 +1338,7 @@ - SPA открывается по своему адресу; - бюджетные оповещения и алерт настроены; - в Artifact Registry действует автоочистка; + - откат и возврат по командам `docs/self-hosting.md` возвращают прошлую ревизию и прошлую SPA, `/health` называет её релиз, а возврат — снова новый. Проверяется на второй выкладке: после первой прошлая ревизия — образ `hello`, а прошлого выпуска SPA нет (T11); - `just usage` показывает расход в бесплатных лимитах. - **Стоимость:** $0 в бесплатных лимитах. - **Риски:** проект и бакет состояния не пересоздавать. diff --git a/SECURITY.md b/SECURITY.md index bacd2ce..10940ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,7 +43,7 @@ The service is still being built, in the order [`RUN.md`](RUN.md) sets; this is ## Supported versions -There is one: the newest commit of `main`. There is no maintenance branch — a fix goes to `main`. Every merge to `main` whose checks are green is published as a signed release. The delivery is still being set up: once it is, each release is deployed from its tag, and the service names the release it runs. +There is one: the newest commit of `main`. There is no maintenance branch — a fix goes to `main`. Every merge to `main` whose checks are green is published as a signed release, and a release that is still the newest commit of `main` is deployed at once: the server and the SPA it signed, with the configuration of the commit its tag names. The service names the release it runs at `/health`. ## Checking that a release is ours @@ -61,6 +61,8 @@ sha256sum --check --ignore-missing SHA256SUMS The first command says the sums were signed by this repository's release workflow, run on `main`; the second, that each file downloaded beside them is the one they describe — on macOS, `shasum -a 256 --check --ignore-missing SHA256SUMS` does the same. A release cut from another branch names that branch in its certificate instead, and fails the first command as written. +The delivery runs the same check before anything reaches production, and one more: it adds `--certificate-github-workflow-sha` with the commit the release's tag names, so that the signed files of one release cannot pass for another's (`just ci-release-fetch`). + ## How this is kept honest Every change arrives through a pull request that cannot be merged until the following pass: diff --git a/docs/decisions.md b/docs/decisions.md index 266a1ba..2175a4f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -143,6 +143,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - `.dockerignore` исключает `**/.env`, а не только `.env` в корне: файл с секретами из любой папки не уходит в контекст сборки. - `just claude-update` получает версию через окружение, а не вставкой в текст скрипта, и до правки проверяет, что оба пина на месте. - Python и uv образца не ставятся, расширений Python нет: в canvas-api Python нет (T03). Поэтому gcloud на arm, как и прежде, идёт из образа Google (R28 standalone). +- Firebase CLI, которого в образце нет, ставится автономным бинарником и, как gcloud, только на x86_64: другой сборки нет (R58). Остальные отличия — имена проекта, сети и томов, порт 5173 для Vite (T03) и комментарии, которые описывают canvas-api. Временных отличий не осталось. Их было три, и все закрыты: - `.dockerignore` был короче образца — его список дополнил T04; @@ -151,7 +152,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо Версии инструментов, features, расширения VS Code, кроме пакета Python, и digest базового образа совпадают с образцом: сверено 2026-10-11 и ещё раз в T60. -— *Почему:* сверка с образцом берёт его файлы и меняет в них только то, что названо в задаче. Без этой записи следующая сверка вернула бы то, что здесь исправлено или решено иначе. — *Отвергнуто:* вернуть в этих местах файлы образца как есть — их недостатки названы выше. — `.devcontainer/`, `.gitattributes`, `.dockerignore`, `justfile`; R11; standalone: R28, R36. — *Поправка 2026-10-13:* временные отличия закрыты, список выше поправлен на месте (T07). +— *Почему:* сверка с образцом берёт его файлы и меняет в них только то, что названо в задаче. Без этой записи следующая сверка вернула бы то, что здесь исправлено или решено иначе. — *Отвергнуто:* вернуть в этих местах файлы образца как есть — их недостатки названы выше. — `.devcontainer/`, `.gitattributes`, `.dockerignore`, `justfile`; R11; standalone: R28, R36. — *Поправка 2026-10-13:* временные отличия закрыты, список выше поправлен на месте (T07). — *Поправка 2026-10-14:* добавлен Firebase CLI (T11). **R47. SPA собирает Vite 8 на React 19 с `@vitejs/plugin-react`; TypeScript только проверяет типы, Biome форматирует и линтует, Vitest тестирует в happy-dom.** Тулчейн — тот же, что у виджета standalone (R84 standalone), с тремя отличиями: два — ниже, третье — в абзаце о типах. - **JSX собирает `@vitejs/plugin-react`.** В dev-сервере правка компонента обновляет его на месте, без перезагрузки страницы (Fast Refresh). Образец собирает Preact по настройкам tsconfig, без плагина. @@ -435,7 +436,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - **Вывод плана и apply маскируется** (`_tf-redact`): значения обоих секретов заменяются их именами. ID бюджета содержит ID платёжного аккаунта и печатается в строках `Refreshing state…`, а в Terraform 1.16.3 нет `plan -concise`. GitHub маскирует секреты в логах и сводках, но не в терминале и не в скопированном логе. - **План — без блокировки состояния, apply ждёт её до 5 минут.** План из PR не роняет apply выкладки. - **`tf-check` — ещё и в CI**, заданием «Terraform» на раннере; в образце его в CI нет. Провайдеры ставятся строго по lock (`init -lockfile=readonly`). -- **Тесты конфигурации на mock-провайдерах** (`terraform test`, 12 случаев, без облака): +- **Тесты конфигурации на mock-провайдерах** (`terraform test`, без облака; 12 случаев в T10, 16 — после T11): - алерт создаётся только с адресом; - валидации отказывают; - размер и старт сервиса — по spec 15.3; @@ -446,7 +447,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - **Экосистемы `terraform` в Dependabot нет.** Dependabot обновляет lock своим Terraform (1.16.4 в dependabot-core на 2026-10-14), а `required_version` точный, так что такой PR падал бы всегда. Провайдеры и lock поднимаются руками, вместе с `ARG TERRAFORM_VERSION`. - **Bootstrap.** - Роли — только для ресурсов эпика, с причинами в комментарии скрипта. Добавлены `firebase.admin`, `logging.configWriter` и `observability.editor` (R57). - - Не перенесены `secretmanager.admin` — придёт с первым секретом, `resourcemanager.projectIamAdmin` — понадобится T11, потому что роль выкладки Hosting выдаётся на проект, и `bigquery.admin` — это аналитика образца. + - Не перенесены `secretmanager.admin` — придёт с первым секретом, `resourcemanager.projectIamAdmin` — понадобится T11, потому что роль выкладки Hosting выдаётся на проект, и `bigquery.admin` — это аналитика образца. T11 выдал `projectIamAdmin` с условием: только две роли выкладки SPA (R58). - Observability API включается ещё в bootstrap: его настройки какое-то время отказывают после включения API. - **Условие провайдера пула пускает один workflow — выкладку, `.github/workflows/deploy.yml`, с любой ветки** (`assertion.job_workflow_ref`). В образце пускает любой workflow репозитория. Здесь это значило бы, что одолжить аккаунт Terraform и аккаунт деплоя может, например, задание релиза: оно держит токен личности для подписи cosign и собирает SPA сторонними пакетами. До T11, где появится `deploy.yml`, не войдёт никто. Повтор bootstrap приводит условие к записанному (`update-oidc`): так новое условие и легло на живой проект. - Всё, что скрипт создаёт, он перед каждой попыткой ищет (`ensure`): создание, отвергнутое на этой стороне, могло пройти на той, и следующая попытка находит его, а не падает. Остальные вызовы повторяются несколько раз. Живой прогон T10 показал, что на только что созданном проекте права владельца доходят не сразу: первое создание пула получило PERMISSION_DENIED. @@ -469,7 +470,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - провайдеры отстают, пока их не поднимут руками: 8.3.0 против 8.7.0 от 2026-10-13; - адрес оператора лежит в состоянии открытым текстом, в закрытом бакете; - самые широкие роли аккаунта Terraform — `billing.costsManager` на весь платёжный аккаунт, `firebase.admin` и `iam.serviceAccountAdmin` (выше). Узкой готовой роли для добавления Firebase в проект нет; -- условие пула пускает `deploy.yml` с любой ветки, в том числе из PR этого репозитория, который поменял сам `deploy.yml`. Закрыть это — решение T11: окружение `prod` и WIF по его subject (RUN.md, T11); +- условие пула пускает `deploy.yml` с любой ветки, в том числе из PR этого репозитория, который поменял сам `deploy.yml`. Закрыть это — решение T11: окружение `prod` и WIF по его subject (RUN.md, T11). Закрыто в T11: аккаунты одалживаются по subject окружений, а PR получает только аккаунт Terraform — для плана (R58); - имя `deploy.yml` — часть условия: переименование выкладки требует правки bootstrap и его повтора. — *Отвергнуто:* @@ -480,7 +481,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - кеш провайдеров в задании «Terraform»: задание и так берёт у HashiCorp сам Terraform в каждом прогоне, провайдеры добавляют около 15 с, а хеши lock проверяют их и без кеша; - общий шаг для чтения версии из `ARG`: три одинаковые строки в action `just`, `node` и `terraform` проще, чем абстракция ради них. -— `infra/`, `justfile` (`tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan`, `ci-tf-apply`, `ci-tf-outputs`, `_tf-redact`), `.github/workflows/ci.yml` («Terraform»), `.github/actions/terraform/`, `.github/dependabot.yml`, `docs/self-hosting.md`; R31–R34, R45, R50, R54, R55, R57; standalone: R26, R27, R29, R32, R33, R266. +— `infra/`, `justfile` (`tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan`, `ci-tf-apply`, `ci-tf-outputs`, `_tf-redact`), `.github/workflows/ci.yml` («Terraform»), `.github/actions/terraform/`, `.github/dependabot.yml`, `docs/self-hosting.md`; R31–R34, R45, R50, R54, R55, R57; standalone: R26, R27, R29, R32, R33, R266. — *Поправка 2026-10-14 (T11):* аккаунт выкладки SPA, привязки WIF по subject окружений вместо всего репозитория, условный `projectIamAdmin` у аккаунта Terraform; четыре новых теста (R58). **R57. Журналы и трассы проекта — в `us-east4`.** Решение автора 2026-10-14 в планировании T10, по открытому пункту T09: - **журналы** — в бакете `default-us-east4`, 30 дней (privacy 4, 8). Бакеты `_Default` и `_Required` Cloud Logging создаёт в `global`, и место бакета не меняется (документация Cloud Logging, прочитана 2026-10-14). Поэтому sink `_Default` перенаправлен в новый бакет с прежним фильтром, слово в слово. Бакет `_Default` остаётся пустым; @@ -500,6 +501,79 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо — `infra/terraform/logging.tf`, `infra/bootstrap.sh`; privacy 4, 7, 8; spec 16; R54, R56. +**R58. Выкладка canvas-api (CD) — перенос образца; чем отличается.** Решение T11 (план утверждён автором 2026-10-14). `deploy.yml`, action `deployment-target` и рецепты выкладки перенесены из образца (standalone: R29, R30, R32, R33, R154). Цепочка — R45: задание `deploy` в `release.yml` вызывает `deploy.yml` с номером релиза, пока `main` стоит на коммите релиза. Задания `deploy.yml`: +- `target` — какой релиз и выкатывать ли; учётных данных у него нет; +- `infra` — `terraform apply` из коммита тега; +- `deploy` — образ, новая ревизия и smoke API; +- `hosting` — SPA и проверка её страницы; +- `plan` — только на PR: план в summary. + +Решения автора в планировании: +1. **Окружения GitHub `prod` и `prod-hosting`, выкладка в них — только из `main`; WIF — по subject окружения.** Префикс subject неизменяемый: в нём числовые ID владельца и репозитория (`github_oidc_subject_prefix`), которые не получит никто другой. Кто какой аккаунт одалживает: + - аккаунт Terraform — задания окружения `prod` и план на PR (`:pull_request`); + - аккаунт деплоя — только `prod`; + - аккаунт SPA — только `prod-hosting`. + + Привязку «весь репозиторий» bootstrap снимает, условие пула (`deploy.yml`) остаётся. Для аккаунтов деплоя и SPA «только `main`» теперь держит платформа, а не текст workflow, который может поменять PR. Аккаунт Terraform получает ещё и план на PR — это цена плана («Следствие» ниже). +2. **Выкатываются подписанные файлы релиза, а не сборка из тега.** + - Образ — тот же distroless по digest вокруг сервера amd64 из релиза, SPA — архив из релиза. + - До использования `SHA256SUMS` проходит `cosign verify-blob`: подписал `release.yml@refs/heads/main`, и сертификат называет коммит тега (`--certificate-github-workflow-sha`). Затем файл сверяется со своей строкой в суммах. + - В заданиях с доступом к prod нет ни сборки Go, ни npm. + - Конфигурация — Terraform, `firebase.json`, Dockerfile — из коммита тега. +3. **Свой аккаунт выкладки SPA, `canvas-api-hosting`**, с ролями `firebasehosting.admin` и `serviceusage.apiKeysViewer`. Обе выдаются на проект: уже проекта Hosting роли не даёт. Аккаунт Terraform получает `resourcemanager.projectIamAdmin` с условием IAM `modifiedGrantsByRole`: выдавать и отзывать на проекте он может только эти две роли. Bootstrap держит эту роль только под записанным условием: под другим или без условия он её сначала снимает, иначе новая привязка легла бы рядом со старой. +4. **firebase-tools — автономный бинарник v15.33.0** (`ARG FIREBASE_TOOLS_VERSION`): в devcontainer — на x86_64, в CI его скачивает action `firebase`. + +Отличия от образца и от текста T11 — каждое решение или исправление: +- **Порядок R31 поправлен.** Smoke API идёт сразу после новой ревизии и до Hosting: новая SPA не выходит к API, который не отвечает. У Hosting своя проверка: страница по адресу сайта байт в байт равна `index.html` архива и отдаётся с `Cache-Control: no-cache`. +- **Свежесть.** Задание `target` проверяет её в начале прогона. Задания `infra`, `deploy` и `hosting` повторяют проверку только на второй и следующих попытках (`github.run_attempt`): «Re-run failed jobs» не перезапускает `target`, и его выход устаревает. На первой попытке хватает очереди `cd`: `main` только идёт вперёд. Устаревший релиз — зелёный прогон с notice, а не красный. +- **Секреты передаются по имени**, `TF_VAR_billing_account` и `TF_VAR_operator_email`, а не `secrets: inherit`: выкладке достаются только они. +- **Права — по заданиям.** Workflow получает `contents: read`, а `id-token: write` есть только у `plan`, `infra`, `deploy` и `hosting`. В образце `id-token: write` выдан всему workflow. +- **Каждый инструмент ставит свой action** — `terraform`, `gcloud` и `firebase`, версию каждый читает из `ARG` devcontainer. `deployment-target` только загружает `infra/ci.env` и отказывает, если в нём нет одной из двух строк. В образце и в тексте T11 `deployment-target` читает ещё и версии; здесь их чтение уже разнёс T10 (R56). +- **`docker-mirror` выкладке не нужен:** образ релиза собирается из distroless (`gcr.io`) и бинарника, с Docker Hub ничего не тянется. +- **Образ — стадия `release` корневого Dockerfile** (`--target release`), а бинарник приходит отдельным контекстом `binary`: + - без provenance — иначе в реестр ушёл бы индекс, а не образ; + - метки OCI `version`, `revision` и `source`; тег — номер релиза, выкатывается образ по digest из вывода push; + - до push образ проверяется, как образ CI: `/health` называет версию релиза и коммит. Коммит в `/health` короткий (`git rev-parse --short`), поэтому проверяется, что он — начало полного SHA длиной от семи знаков. +- **`ci-deploy` после `services update` выполняет `update-traffic --to-latest`.** После отката трафик закреплён за старой ревизией, и новая иначе его не получила бы. +- **`firebase.json`:** + - `public` — `web/dist`, без `site`: выкладка с `--project` идёт в сайт по умолчанию, а ID проекта записан один раз — в tfvars; + - без `ignore`: сайт — весь архив; + - без rewrites: маршрутов у SPA нет, и `/__/` остаётся свободным для входа по redirect (R55); + - заголовки: `no-cache` на `/` и `/index.html`, `public, max-age=31536000, immutable` на `/assets/**`; + - строгий JSON без комментариев, как `biome.json`; схема firebase-tools v15.33.0 его принимает. +- **План на PR — в summary, блоком кода.** Если план упал, задание краснеет — уже после того, как вывод показан. +- **Кнопка вне `main`** падает в задании `target`, до всего остального. Окружения не пустили бы её и так. Ждёт она в своей группе concurrency: в общей `cd` она вытеснила бы ждущую выкладку `main`. + +— *Почему:* образец — отправная точка, а не предел. В prod выходит ровно то, что выпущено и подписано, байт в байт, а задания с доступом к prod не запускают сторонних пакетов сборки. Выкладку «только из `main`» держит платформа. firebase-tools — большая сторонняя программа, и в своём окружении она не дотянется ни до аккаунта Terraform, ни до аккаунта деплоя. + +— *Выяснилось (прочитано 2026-10-14):* +- в токене задания вызванного workflow `job_workflow_ref` называет `deploy.yml`, так что условие пула из T10 подходит. Окружение объявляет задание вызванного workflow; задание, которое его вызывает, этого не может (документация GitHub Actions); +- верхнеуровневый `concurrency` вызванного workflow действует, а `github.*` в нём — контекст вызывающего; +- firebase-tools берёт учётные данные ADC, в том числе федеративные (`external_account`). `firebase.json` он читает через cjson, а со схемой сверяет только в debug-журнале (исходники v15.33.0); +- выкладке Hosting из CLI нужны `firebasehosting.admin` и чтение ключей API; своя роль не подходит. Откат — `firebase hosting:clone SITE@VERSION SITE:live`, список выпусков — REST v1beta1 `sites/SITE/releases` (документация Firebase); +- после закрепления трафика новые ревизии получают 0 %, пока его не вернут на latest (документация Cloud Run); +- `gh release download` пишет файлы с правами 0644, отсюда `COPY --chmod=0555`. cosign из образа работает от uid 65532, и папке с файлами нужны права 0755; +- сертификат в бандле релиза несёт SHA коммита: v0.1.2 проходит с 701cb47 и отказывает с 8b1fe03. Для `workflow_run` это SHA `main` в момент события. У релиза, который доходит до выкладки, он равен коммиту релиза: иначе `main` ушёл бы дальше и `newest` был бы ложен. + +— *Следствие:* +- первая выкладка может потребовать «Re-run failed jobs»: свежие API Firebase и привязки, созданные за секунды до использования; +- новая роль проекта для аккаунтов сначала попадает в список условия bootstrap, а bootstrap повторяется до мержа («Когда меняется bootstrap» в `docs/self-hosting.md`); +- аккаунт Terraform достижим из PR этого репозитория через `deploy.yml`. PR может поменять задание плана и сделать им всё, что может этот аккаунт, и правило окружения `prod` его не держит. Открыть PR из ветки репозитория может только автор. Задания CI на PR не входят в облако только благодаря условию пула: их subject тоже `:pull_request`; +- ассеты релиза и теги можно поменять, пока не включены неизменяемые релизы и правило на теги (открыто с T08). Подпись с коммитом и проверка свежести сводят это к тому, что `release.yml` на `main` подписал для этого коммита; +- невалидный `deploy.yml` роняет весь прогон `release.yml`, и коммит остаётся без релиза. PR с правкой `deploy.yml` запускает его на `pull_request`, и синтаксис проверяется до мержа. Контракт вызова — входы и секреты, которые передаёт `release.yml`, — до мержа проверяет только actionlint, запущенный руками: в CI его нет; +- роли, которые конфигурация выдаёт на проект, и список условия в bootstrap держит вместе тест `terraform test`: роль, выданная без записи в условии, краснеет до мержа, а не 403 после него; +- firebase и gcloud в контейнере есть только на x86_64; на arm64 команды отката — из Cloud Shell. + +— *Отвергнуто:* +- сборка образа и SPA из тега в самой выкладке (текст T11) — решение автора, выше; +- `secrets: inherit` — выкладке хватает двух секретов; +- общий аккаунт для деплоя и Hosting; +- firebase-tools из npm: дерево пакетов ставилось бы в задание с доступом к prod, а автономный бинарник несёт свой Node.js; +- проверять свежесть в каждом задании и на первой попытке: очередь `cd` и порядок `main` дают то же без лишних вызовов; +- `queue: max` в группе `cd`: выкладке нужна только новейшая из ожидающих. + +— `.github/workflows/deploy.yml`, `.github/workflows/release.yml` (задание `deploy`), `.github/actions/{deployment-target,gcloud,firebase}/`, `justfile` (`ci-release-target`, `ci-release-current`, `ci-release-fetch`, `ci-image-push`, `ci-deploy`, `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`, `_image-health`), `Dockerfile` (стадия `release`), `firebase.json`, `infra/terraform/iam.tf`, `infra/bootstrap.sh`, `docs/self-hosting.md`; spec 15.5; R31, R33, R45, R52, R55, R56; standalone: R29, R30, R32, R33, R154. + ## Идентификация и доступ **R14. Для Firebase используем Admin SDK (`firebase.google.com/go/v4`).** Через него проверяем ID token и App Check и удаляем пользователей. `golang-jwt/jwt/v5` нужен только для подписи токенов Centrifugo. `keyfunc` не используется. @@ -738,15 +812,14 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо **R30. Проверка здоровья — `GET /health`, не `/healthz`.** — *Почему:* Google Front End в Cloud Run перехватывает пути, которые заканчиваются на `z`. mathtrail-standalone на это уже наткнулся (его R34). — spec 8.2, 15. **R31. Порядок выкладки — как в mathtrail-standalone.** Шаги: -1. подписанный релиз; всё дальше идёт из его тега (R45); +1. подписанный релиз; всё дальше идёт из его тега (R45): конфигурация — из коммита тега, сервер и SPA — подписанные файлы релиза (R58); 2. `terraform apply` (образы Terraform игнорирует); -3. сборка образов; +3. образ — вокруг подписанного сервера релиза; 4. миграции — jobs на новом образе; -5. новая версия сервисов; -6. Hosting; -7. smoke-проверка `/health`. +5. новая версия сервисов и smoke-проверка `/health`; +6. Hosting и проверка его страницы. -Контейнеры сервиса стартуют параллельно, startup probe сайдкара срабатывает каждую секунду. — *Почему:* при миграциях до `terraform apply` jobs работали на старом образе, а первый прогон падал без registry. Последовательный старт с probe по умолчанию растягивал холодный старт примерно до 11 с. — spec 14.2, 15. — *Поправка 2026-10-11:* первым шагом стал релиз — как в образце с 2026-10-03 (R45). +Контейнеры сервиса стартуют параллельно, startup probe сайдкара срабатывает каждую секунду. — *Почему:* при миграциях до `terraform apply` jobs работали на старом образе, а первый прогон падал без registry. Последовательный старт с probe по умолчанию растягивал холодный старт примерно до 11 с. — spec 14.2, 15. — *Поправка 2026-10-11:* первым шагом стал релиз — как в образце с 2026-10-03 (R45). — *Поправка 2026-10-14 (T11):* smoke API — сразу после новой версии сервисов и до Hosting, чтобы новая SPA не вышла к неотвечающему API; у Hosting своя проверка. Образ не собирается из исходников заново, а оборачивает сервер, подписанный релизом (R58). **R32. В MVP одна среда — prod; вторая (dev) создаётся после успешного MVP.** Один проект GCP со своим проектом Firebase. Каждый мерж в `main` выкатывается в prod конвейером из R31. Когда MVP состоится, заводим вторую среду: отдельные проекты GCP и Firebase, тот же Terraform со своим `*.auto.tfvars`. — *Почему:* решение автора при составлении плана реализации (RUN.md). - Главный довод — ручная настройка. Одна среда вдвое сокращает её: проект, Firebase, Neon, Upstash, секреты. @@ -771,11 +844,11 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо **R45. Выкладка — после релиза, из его тега; запреты R33 остаются.** Цепочка: 1. CI на `main` зелёный; 2. `release.yml` по `workflow_run`, только от push из этого репозитория, публикует подписанный релиз. Номер — следующий patch линии `RELEASE_LINE`, его выдаёт `just release-version`; -3. задание `deploy` того же прогона вызывает `deploy.yml` через `workflow_call` с номером релиза. Инфраструктура, образ, Hosting и smoke идут из тега релиза; -4. выкладка идёт, только если `main` всё ещё стоит на этом коммите. Иначе перезапуск CI старого коммита откатил бы prod. Это проверяет сам `deploy.yml` при каждом запуске, а не только выход релиза: при «Re-run failed jobs» выход берётся из прежнего прогона и может устареть. +3. задание `deploy` того же прогона вызывает `deploy.yml` через `workflow_call` с номером релиза. Конфигурация берётся из коммита тега, а сервер и SPA — подписанные файлы релиза (R58); +4. выкладка идёт, только если `main` всё ещё стоит на этом коммите. Иначе перезапуск CI старого коммита откатил бы prod. Это проверяет и сам `deploy.yml`, а не только выход релиза: его задание `target` — в начале прогона, а каждое следующее задание — на повторной попытке. При «Re-run failed jobs» выходы прежних заданий берутся из прежнего прогона и могут устареть. Ещё: -- образ собирается с номером релиза, и `/health` его называет; +- образ несёт сервер релиза с его номером, и `/health` его называет; - кнопка релиза работает только на `main` и только при зелёном CI этого коммита. Кнопка выкладки — только на `main`: она выкатывает релиз, чей тег стоит на вершине `main`, а если такого нет — ничего. «Новейший по номеру» не годится: перезапуск CI старого коммита выпускает его под бо́льшим номером; - упавшую выкладку повторяют только «Re-run failed jobs». @@ -785,7 +858,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - CI как переиспользуемый workflow внутри `deploy.yml` (`needs: [ci]`, прежняя T11): выкладка шла из коммита, релиз выходил отдельно, и выкатанное ничем не было связано с выпущенным; - кнопки образца, которые работают с любой ветки и без CI (R33). -Поправляет R31 и R33. — RUN.md (T08, T11); spec 15.5. +Поправляет R31 и R33. — RUN.md (T08, T11); spec 15.5. — *Поправка 2026-10-14 (T11):* выкатываются подписанные файлы релиза, а не сборка из тега; свежесть на повторной попытке проверяет каждое задание (R58). **R35. Исчерпанный дневной лимит не прерывает занятие: ребёнок решает дальше сам, без проверки.** - Черновик и поле ответа работают как обычно, запись остаётся на устройстве. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 3907f67..ed83636 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,6 +1,6 @@ # Развёртывание canvas-api -Первая версия (T10): облако как код и разовый bootstrap. Выкладку пайплайном добавляет T11, первый деплой и его чек-лист — T12; полностью документ дописывает T57. Решения, на которых всё держится, — R54–R57 в [decisions.md](decisions.md). +T10 — облако как код и разовый bootstrap, T11 — выкладка пайплайном, откат и возврат. Первый деплой и его чек-лист добавит T12, полностью документ допишет T57. Решения, на которых всё держится, — R54–R58 в [decisions.md](decisions.md). ## Что делается руками @@ -9,8 +9,9 @@ 1. **Bootstrap** — `just bootstrap`. Создаёт то, без чего пайплайну не войти в облако: проект, привязку к платёжному аккаунту, бакет состояния, федеративный пул с провайдером GitHub и аккаунт Terraform с его ролями. 2. **`infra/ci.env`** — две строки, которые печатает bootstrap, коммитятся в репозиторий. 3. **Секреты репозитория** (Settings → Secrets and variables → Actions): - - `TF_VAR_billing_account` — ID платёжного аккаунта, нужен каждому плану и apply пайплайна; + - `TF_VAR_billing_account` — ID платёжного аккаунта. Нужен каждому плану и apply пайплайна, без него выкладка не начнётся; - `TF_VAR_operator_email` — адрес для алерта нагрузки. Необязателен: без него алерта нет. +4. **Окружения GitHub** `prod` и `prod-hosting` (Settings → Environments → New environment), до первой выкладки. В каждом: «Deployment branches and tags» → «Selected branches and tags» → правило `main`. Ни ревьюеров, ни секретов в них нет. Окружение, которого нет, GitHub создаёт сам при первом задании — без правила, и тогда «только `main`» держал бы один текст workflow (R58). Всё остальное — проект, сервис, Firebase, бюджет, алерт, журналы — описывает Terraform в `infra/terraform/`, а применяет пайплайн. Руками в консоли ничего не настраивается: следующий apply вернул бы настройку как было. @@ -18,7 +19,7 @@ - Платёжный аккаунт Google Cloud и право привязывать к нему проекты. Его ID — `gcloud billing accounts list`. - Право создавать проекты. -- Devcontainer на x86_64: gcloud ставится только туда (R28 standalone). На arm64 те же команды запускаются из Cloud Shell или из образа `google-cloud-cli`. +- Devcontainer на x86_64: gcloud и Firebase CLI ставятся только туда (R28 standalone, R58). На arm64 те же команды запускаются из Cloud Shell, где есть оба. ## 1. Описать развёртывание @@ -26,11 +27,17 @@ | Файл | Что в нём | |---|---| -| `infra/terraform/prod.auto.tfvars` | ID проекта, регион, репозиторий и числовой ID его владельца, пул, бюджет и его валюта | +| `infra/terraform/prod.auto.tfvars` | ID проекта, регион, репозиторий и числовой ID его владельца, пул, начало subject токенов GitHub, бюджет и его валюта | | `infra/terraform/backend.hcl` | бакет состояния и префикс | | `infra/ci.env` | две строки из вывода bootstrap | -Своя копия меняет здесь ID проекта (он глобально уникален), бакет состояния, репозиторий и ID владельца — `https://api.github.com/users/<владелец>`. Адрес SPA выводится из ID проекта: `https://.web.app` (R55). +Своя копия меняет здесь: +- ID проекта: он глобально уникален; +- бакет состояния; +- репозиторий и ID владельца — `https://api.github.com/users/<владелец>`; +- начало subject — `gh api repos/<владелец>/<репозиторий>/actions/oidc/customization/sub --jq .sub_claim_prefix`. + +Адрес SPA выводится из ID проекта: `https://.web.app` (R55). ## 2. Bootstrap @@ -41,7 +48,9 @@ just bootstrap ``` - Повтор безопасен: всё, что скрипт создаёт, он перед каждой попыткой ищет. Условие провайдера пула приводится к записанному в скрипте. -- Пул пускает один workflow — выкладку, `.github/workflows/deploy.yml` (T11). Ни проверки, ни релиз не могут одолжить ни аккаунт Terraform, ни аккаунт деплоя, а до появления `deploy.yml` не может никто (R56). +- Пул пускает один workflow — выкладку, `.github/workflows/deploy.yml`. Ни проверки, ни релиз не могут одолжить ни один аккаунт (R56). +- Аккаунт Terraform одалживают только задания окружения `prod` и план на PR — по subject их токенов (R58). Привязку «весь репозиторий», которую ставил bootstrap T10, повтор снимает. +- `roles/resourcemanager.projectIamAdmin` выдаётся с условием: выдавать и отзывать на проекте аккаунт Terraform может только две роли выкладки SPA. - ID платёжного аккаунта скрипт не печатает и в ошибках gcloud заменяет его на ``: вывод можно вставлять куда угодно. - `just` не читает `.env`. Если ID лежит там строкой `TF_VAR_billing_account=…`, его экспортирует `set -a; . ./.env; set +a`. - До всего остального скрипт сверяет валюту платёжного аккаунта с `budget_currency`: бюджет в чужой валюте отклоняется, и иначе это выяснилось бы только на первом apply. @@ -53,12 +62,19 @@ just bootstrap ```bash gcloud projects get-iam-policy --flatten=bindings \ --filter="bindings.members:serviceAccount:canvas-api-tf@.iam.gserviceaccount.com" \ - --format='value(bindings.role)' + --format='value(bindings.role, bindings.condition.title)' +``` + +Кто может его одолжить — два subject, `…:environment:prod` и `…:pull_request`: + +```bash +gcloud iam service-accounts get-iam-policy canvas-api-tf@.iam.gserviceaccount.com \ + --project= --flatten='bindings[].members' --format='value(bindings.members)' ``` ## 3. План вручную -Пайплайн строит план сам (T11), но его можно построить и из контейнера, своими учётными данными: +Пайплайн строит план сам — на каждом PR, который трогает `infra/`, `deploy.yml`, actions или `justfile`, в summary прогона. Его можно построить и из контейнера, своими учётными данными: ```bash gcloud auth application-default login --no-launch-browser @@ -69,18 +85,68 @@ TF_VAR_billing_account=… just ci-tf-plan - Весь вывод проходит через маскировку: ID платёжного аккаунта и адрес заменяются на `` и ``. ID бюджета содержит ID аккаунта, и без маскировки он попадал бы в строки `Refreshing state…`. - План не берёт блокировку состояния, поэтому не мешает apply пайплайна. Apply ждёт чужую блокировку до 5 минут. -## Порядок применения +## Выкладка + +Каждый мерж в `main` с зелёным CI выпускает подписанный релиз, и прогон «Release and deploy» тут же выкатывает его — если `main` всё ещё стоит на коммите релиза (R45, R58). Выкладка — `.github/workflows/deploy.yml`, задание за заданием: + +1. **Which release** — какой релиз и выкатывать ли. Если `main` ушёл дальше, прогон зелёный с notice, а выкатывает новый релиз его собственный прогон. +2. **The cloud, as the release describes it** — окружение `prod`: `just ci-tf-apply` из коммита тега. +3. **The server of the release** — окружение `prod`: + - сервер amd64 из релиза, подпись `SHA256SUMS` проверена (`just ci-release-fetch`); + - образ вокруг него, проверка `/health` до push, push, ревизия по digest и трафик на неё (`ci-image-push`, `ci-deploy`); + - smoke: `/health` по адресу сервиса называет релиз и коммит (`ci-smoke`). +4. **The SPA of the release** — окружение `prod-hosting`, свой аккаунт: архив SPA из релиза, `firebase deploy`, затем страница сайта сверяется с архивом (`ci-hosting-deploy`, `ci-hosting-check`). -Apply делает пайплайн, в порядке R31: подписанный релиз → `just ci-tf-apply` → образ → новая ревизия → Hosting → smoke (T11). Первый apply создаёт: +Кнопка — Actions → Deploy → Run workflow на `main`: выкатывает релиз на вершине `main`, а если его нет — ничего. На другой ветке она падает в первом задании. Упавшую выкладку повторяют «Re-run failed jobs» в прогоне «Release and deploy»; повторная попытка сначала проверяет, что `main` всё ещё стоит на коммите релиза. + +Первый apply создаёт: - API проекта, Artifact Registry с автоочисткой; -- аккаунты сервиса и деплоя, свою узкую роль деплоя и привязку WIF; +- аккаунты сервиса, деплоя и выкладки SPA, свою узкую роль деплоя, две роли Hosting и привязки WIF по окружениям; - Cloud Run `canvas-api` на начальном образе Google `hello` — он отвечает на любой путь, в том числе на `/health`, пока CD не выкатит свой образ; - Firebase в проекте и сайт Hosting; - бюджет и, если задан адрес, алерт; - бакет журналов в регионе и перенаправление `_Default`, место хранения трасс. -Первый apply может потребовать одного повтора — «Re-run failed jobs»: только что включённый API и только что созданный аккаунт видны не сразу. +Первая выкладка может потребовать одного повтора — «Re-run failed jobs»: только что включённые API и только что созданные аккаунты и привязки видны не сразу. + +## Откат и возврат + +Откат — трафиком на прошлую ревизию и прошлым выпуском Hosting, а не `terraform apply` (spec 15.5). SPA и API откатываются вместе. Команды — из devcontainer на x86_64 или из Cloud Shell, с учётными данными человека, у которого есть права на проект (`gcloud auth login` и `gcloud auth application-default login`). + +**Сервис.** Ревизии, новые сверху; закрепить трафик за прошлой и проверить, что отвечает её релиз: + +```bash +gcloud run revisions list --service=canvas-api --region=us-east4 --project=mathtrail-canvas-prod +gcloud run services update-traffic canvas-api --region=us-east4 --project=mathtrail-canvas-prod \ + --to-revisions=<ревизия>=100 +curl -fsS https://canvas-api-796865465273.us-east4.run.app/health +``` + +Пока трафик закреплён, новые ревизии его не получают (документация Cloud Run, прочитана 2026-10-14). Следующая выкладка возвращает его на новую ревизию сама (`ci-deploy`). + +**SPA.** Выпуски сайта, новые сверху, с номером релиза в сообщении; прошлый выпуск — снова в live: + +```bash +curl -fsS -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H "x-goog-user-project: mathtrail-canvas-prod" \ + "https://firebasehosting.googleapis.com/v1beta1/sites/mathtrail-canvas-prod/releases?pageSize=5" \ + | jq -r '.releases[] | "\(.version.name | split("/") | last) \(.releaseTime) \(.message // "")"' +GOOGLE_CLOUD_QUOTA_PROJECT=mathtrail-canvas-prod firebase hosting:clone \ + mathtrail-canvas-prod@<версия> mathtrail-canvas-prod:live --project mathtrail-canvas-prod +``` + +`firebase` берёт учётные данные `application-default`, а API Hosting с учётными данными человека требует проект квоты — его задаёт `GOOGLE_CLOUD_QUOTA_PROJECT` (документация Firebase, прочитана 2026-10-14). + +**Возврат** — кнопкой выкладки: она выкатывает релиз на вершине `main` целиком, сервис и SPA, и возвращает трафик на новую ревизию. Вручную — то же в обратную сторону: + +```bash +gcloud run services update-traffic canvas-api --region=us-east4 --project=mathtrail-canvas-prod --to-latest +GOOGLE_CLOUD_QUOTA_PROJECT=mathtrail-canvas-prod firebase hosting:clone \ + mathtrail-canvas-prod@<версия последнего релиза> mathtrail-canvas-prod:live --project mathtrail-canvas-prod +``` + +Откат и возврат проверяет T12 на второй выкладке: после первой прошлая ревизия — образ `hello`, а прошлого выпуска SPA нет. ## Переменные @@ -88,6 +154,7 @@ Apply делает пайплайн, в порядке R31: подписанны |---|---|---|---| | `project_id`, `region` | `prod.auto.tfvars` | — | проект (он же проект Firebase) и регион всего контура (R54) | | `github_repository`, `github_owner_id`, `workload_identity_pool_id` | `prod.auto.tfvars` | — | чьи workflow могут выкатывать и через какой пул | +| `github_oidc_subject_prefix` | `prod.auto.tfvars` | — | как GitHub начинает subject токенов репозитория: владелец и репозиторий с их числовыми ID; по нему и окружению аккаунты одалживаются (R58) | | `budget_amount`, `budget_currency` | `prod.auto.tfvars` | — | месячный бюджет; валюта — платёжного аккаунта | | `billing_account` | `TF_VAR_billing_account` | — | платёжный аккаунт; `sensitive` | | `operator_email` | `TF_VAR_operator_email` | пусто | адрес алерта нагрузки; `sensitive`; пусто — алерта нет | @@ -126,6 +193,7 @@ gcloud logging read 'resource.type="cloud_run_revision"' --project=mathtrail-can - **Cloud Trace** — сверх 2,5 млн спанов в месяц. - **Алерты Cloud Monitoring** — бесплатны по меньшей мере до 2027-09-01: плату начнут брать не раньше этой даты, около $0.35 в месяц за метрику в условии. - **Firebase Hosting** — сверх бесплатной квоты Spark, которую тариф Blaze сохраняет. +- **Выкладка** — ничего сверх этого: GitHub Actions бесплатны для публичного репозитория, каждая выкладка добавляет один образ в реестр и одну ревизию. Ревизия без трафика при min 0 не стоит ничего. ## Когда меняется bootstrap @@ -139,12 +207,13 @@ gcloud logging read 'resource.type="cloud_run_revision"' --project=mathtrail-can Убранную из списка роль скрипт не отзывает. Это делается руками: `gcloud projects remove-iam-policy-binding --member=… --role=…`. +Особый случай — роль, которую конфигурация выдаёт на проект, как две роли выкладки SPA. Аккаунт Terraform может выдать только роли из условия своего `projectIamAdmin`, поэтому новая роль сначала попадает в выражение `hasOnly([...])` в bootstrap. Иначе apply получит 403 на её выдаче. Повтор заменяет прежнее условие новым, а не добавляет второе: IAM различает привязки по условию, и старое продолжало бы действовать. + Уже известные изменения: -- `roles/resourcemanager.projectIamAdmin` — T11: роль выкладки Hosting выдаётся только на уровне проекта; - `roles/secretmanager.admin` — с первым секретом (эпик MVP); -- WIF по окружению вместо всего репозитория — решение автора в T11: сейчас условие пускает `deploy.yml` с любой ветки, в том числе из PR этого репозитория; -- переименование `deploy.yml` — оно часть условия пула. +- переименование `deploy.yml` — оно часть условия пула; +- переименование окружения `prod` — оно часть subject, по которому аккаунт Terraform одалживается. ## Удаление развёртывания diff --git a/docs/spec.md b/docs/spec.md index 01b6c37..c94aa54 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1791,7 +1791,7 @@ infra/terraform/ apis.tf включение API (Run, Artifact Registry, IAM и WIF, Firebase, бюджеты, Monitoring, Logging, Observability; Secret Manager, Agent Platform и reCAPTCHA Enterprise — с задачами эпика MVP) registry.tf Artifact Registry для образов secrets.tf семь секретов (значения кладутся вне ресурсов Terraform; R34) - iam.tf сервисные аккаунты, роли, WIF для GitHub Actions + iam.tf аккаунты сервиса, деплоя и выкладки SPA, их роли; кто их одалживает — по окружению GitHub (R58) service_api.tf Cloud Run: canvas-api + сайдкар OpenFGA service_centrifugo.tf Cloud Run: Centrifugo jobs.tf Cloud Run jobs: canvas-migrate, openfga-migrate, cleanup, admin (образ меняет CD) @@ -1848,21 +1848,23 @@ Centrifugo v6 получает секреты переменными окруж - **main.** Каждый мерж выкатывается в prod (R32), но только после зелёного CI этого коммита и только из подписанного релиза (R33, R45): 1. CI на `main` зелёный; 2. `release.yml` по `workflow_run` — только от push из этого репозитория — публикует подписанный релиз. Номер — следующий patch линии `RELEASE_LINE`, его выдаёт `just release-version`. В релизе сервер для amd64 и arm64, архив SPA и `SHA256SUMS`, каждый файл подписан cosign (R52); - 3. задание `deploy` того же прогона вызывает `deploy.yml` через `workflow_call` с номером релиза — только если `main` всё ещё стоит на этом коммите. `deploy.yml` в начале каждого запуска проверяет это ещё раз: иначе перезапуск CI старого коммита откатил бы prod. + 3. задание `deploy` того же прогона вызывает `deploy.yml` через `workflow_call` с номером релиза — только если `main` всё ещё стоит на этом коммите. `deploy.yml` проверяет это ещё раз: в начале прогона и, на повторной попытке, в каждом задании. Иначе перезапуск CI старого коммита откатил бы prod. - Сама выкладка идёт из тега релиза, в порядке R31: + Сама выкладка — это релиз (R58): конфигурация берётся из коммита его тега, а сервер и SPA — подписанные файлы релиза. Прежде чем взять файл, выкладка проверяет подпись `SHA256SUMS`: её сделал `release.yml` на `main` для коммита тега. Затем файл сверяется со своей строкой в суммах. Порядок — R31: 1. `terraform apply` — инфраструктура. При первом прогоне создаются Artifact Registry, сервисы и jobs с начальным образом; образы Terraform не трогает; - 2. образ собирается с номером релиза, проверяется до публикации и публикуется в Artifact Registry; дальше он идёт только по digest; + 2. образ — distroless вокруг сервера amd64 из релиза. До публикации `/health` образа называет релиз и коммит. Публикуется образ в Artifact Registry, дальше идёт только по digest; 3. `gcloud run jobs update --image` для всех jobs — `canvas-migrate`, `openfga-migrate`, `cleanup` и `admin`, — затем `gcloud run jobs execute --wait` для двух миграций. Миграции идут на **новом** образе и до новой версии сервиса, а очистка и команды оператора — на той же схеме и тех же задачах, что и сервис; - 4. `gcloud run services update --image` для canvas-api и Centrifugo, трафик — на новую ревизию. При старте canvas-api сам применяет модель OpenFGA (12.3); - 5. `firebase deploy --only hosting` — SPA, собранная из тега; - 6. smoke-проверка `GET /health`: он называет релиз и коммит. + 4. `gcloud run services update --image` для canvas-api и Centrifugo, затем `update-traffic --to-latest`: после отката трафик закреплён, и новая ревизия иначе его не получит. При старте canvas-api сам применяет модель OpenFGA (12.3). Сразу после этого — smoke-проверка `GET /health`: он называет релиз и коммит. Так новая SPA не выходит к неотвечающему API; + 5. `firebase deploy --only hosting` — SPA из архива релиза, со своим аккаунтом, в своём окружении. Затем страница сайта сверяется с `index.html` архива байт в байт и должна отдаваться с `Cache-Control: no-cache`. Что и как отдаёт Hosting, задаёт `firebase.json`: страница не кешируется, `/assets/**` с хешем в имени кешируются на год, rewrites нет — `/__/` свободен (R55). - **Кнопки.** Кнопка релиза работает только на `main` и только при зелёном CI этого коммита. Кнопка выкладки — только на `main`: она выкатывает релиз, чей тег стоит на вершине `main`, а если такого нет — ничего (R45). - **Повтор** упавшей выкладки — только «Re-run failed jobs» в прогоне релиза (R45). -- **Откат** — трафик canvas-api и Centrifugo на прошлую ревизию (`gcloud run services update-traffic`), образ jobs `cleanup` и `admin` — на прошлый релиз и прошлый выпуск Hosting, а не `terraform apply`. Миграции не откатываются: на этом держится их совместимость (ниже). SPA и API откатываются вместе: новая SPA может звать то, чего у прошлой версии API нет. Способ отката Hosting сверяется с документацией Firebase, команды записываются в `docs/self-hosting.md`. Следующая выкладка возвращает трафик на новую ревизию сама. +- **Откат** — трафик canvas-api и Centrifugo на прошлую ревизию (`gcloud run services update-traffic`), образ jobs `cleanup` и `admin` — на прошлый релиз и прошлый выпуск Hosting, а не `terraform apply`. Миграции не откатываются: на этом держится их совместимость (ниже). SPA и API откатываются вместе: новая SPA может звать то, чего у прошлой версии API нет. Hosting откатывается копией прошлого выпуска в live (`firebase hosting:clone`, документация Firebase, прочитана 2026-10-14); команды отката и возврата — в `docs/self-hosting.md`. Следующая выкладка возвращает трафик на новую ревизию сама. - **Совместимость миграций.** Миграции goose пишутся совместимыми со старым кодом, по схеме «сначала добавить, потом убрать»: между шагами 3 и 4 старая версия сервиса ещё работает. На этом держится и откат. - **Модель OpenFGA меняется так же.** Прошлая ревизия при старте снова запишет свою модель (12.3), поэтому новый релиз отношений не убирает и не переименовывает — только добавляет. Убрать отношение можно релизом позже, когда к прошлой модели уже не откатываются. -- **Доступ к GCP** — через Workload Identity Federation, как в `mathtrail-standalone/infra/bootstrap.sh`. +- **Доступ к GCP** — через Workload Identity Federation, как в `mathtrail-standalone/infra/bootstrap.sh`, без ключей. Пул пускает один workflow — `deploy.yml`, а каждый аккаунт одалживается по subject окружения GitHub, куда выкладка идёт только из `main` (R58): + - аккаунт Terraform — задания окружения `prod` и план на PR; + - аккаунт деплоя — только `prod`; + - аккаунт выкладки SPA — только `prod-hosting`. - **Административные команды** (`cmd/admin`: инвайты, подтверждение согласия) запускаются оператором как Cloud Run job с аргументами, не через публичный API. ## 16. Наблюдаемость @@ -1942,6 +1944,7 @@ infra/terraform/ раздел 15.2 infra/bootstrap.sh разовый шаг: проект, бакет состояния, WIF (15.2, R56) web/ SPA: React + Konva (R13) src/gen/ сгенерированный TS +firebase.json что и как отдаёт Hosting (15.5) docs/ эти документы draft/ макет экрана: предложение, а не спецификация .devcontainer/ justfile .github/workflows/ diff --git a/docs/stack.html b/docs/stack.html index f0fc7c4..1bad736 100644 --- a/docs/stack.html +++ b/docs/stack.html @@ -515,12 +515,13 @@

После MVP: платформа MathTrai { n: "Terraform CLI", v: "1.16.3", r: "Весь проект как код; состояние в GCS; проект, бакет и WIF — разовый bootstrap; tf-check в CI", s: "repo", ref: "spec 15.2 · R56 · T10" }, { n: "Провайдеры Terraform", p: "google; google-beta — для Firebase", v: "8.3.0", r: "Точные версии и .terraform.lock.hcl в репозитории; поднимаются руками", s: "repo", ref: "spec 15.2 · R56 · T10" }, { n: "Neon и Upstash в Terraform", p: "их провайдеры", v: "", r: "Если провайдер есть и лицензия подходит; иначе руками по инструкции", s: "open", q: "выбор в E2", ref: "spec 15.2 · E2" }, - { n: "GitHub Actions", p: "actions по SHA", v: "", r: "CI на каждом PR и коммите main — в образе тулчейна по digest; подписанный релиз после зелёного CI на main и выкладка из его тега — T08, T11", s: "repo", ref: "R33, R45, R49 · T06, T08, T11" }, - { n: "Workload Identity Federation", v: "", r: "CI входит в GCP без ключей сервисных аккаунтов; пул и провайдер создаёт bootstrap", s: "repo", ref: "spec 15.5 · R56 · T10" }, - { n: "gcloud CLI", p: "на x86_64; на arm64 — из образа Google", v: "585.0.0", r: "jobs update и execute, services update в CD", s: "repo", ref: "spec 15.5 · T11" }, - { n: "firebase-tools", v: "закрепит T11", r: "firebase deploy --only hosting", s: "spec", ref: "T11" }, - { n: "Образ сервиса", p: "golang → gcr.io/distroless/static-debian13", v: "по digest", r: "Многоэтапная сборка: CGO_ENABLED=0, -trimpath, пользователь nonroot", s: "repo", ref: "T04" }, - { n: "cosign + gh release", p: "подпись без ключей, OIDC", v: "v3.1.3", r: "Подписанный релиз vX.Y.Z на каждом зелёном main: сервер amd64 и arm64, архив SPA, SHA256SUMS; выкладка идёт из его тега. Тем же cosign main подписывает образ тулчейна и сверяет подпись перед проверками", s: "repo", ref: "R40 standalone, R45, R52, R53 · T08" }, + { n: "GitHub Actions", p: "actions по SHA", v: "", r: "CI на каждом PR и коммите main — в образе тулчейна по digest; подписанный релиз после зелёного CI на main и его выкладка в том же прогоне; план Terraform на PR", s: "repo", ref: "R33, R45, R49, R58 · T06, T08, T11" }, + { n: "Окружения GitHub", p: "prod · prod-hosting", v: "только main", r: "Задания, которые меняют prod, и выкладка SPA; по их subject аккаунты и одалживаются", s: "repo", ref: "R58 · T11" }, + { n: "Workload Identity Federation", v: "", r: "Выкладка входит в GCP без ключей: пул пускает только deploy.yml, каждый аккаунт одалживается по subject окружения; пул и провайдер создаёт bootstrap", s: "repo", ref: "spec 15.5 · R56 · R58 · T10, T11" }, + { n: "gcloud CLI", p: "на x86_64; на arm64 — из образа Google", v: "585.0.0", r: "services update и update-traffic в CD, откат трафиком; jobs update и execute — с jobs", s: "repo", ref: "spec 15.5 · R58 · T11" }, + { n: "firebase-tools", p: "автономный бинарник, x86_64", v: "15.33.0", r: "firebase deploy --only hosting в CD; откат — hosting:clone прошлого выпуска", s: "repo", ref: "R58 · T11" }, + { n: "Образ сервиса", p: "golang → gcr.io/distroless/static-debian13", v: "по digest", r: "Многоэтапная сборка: CGO_ENABLED=0, -trimpath, пользователь nonroot; в prod — тот же distroless вокруг подписанного сервера релиза", s: "repo", ref: "T04 · R58" }, + { n: "cosign + gh release", p: "подпись без ключей, OIDC", v: "v3.1.3", r: "Подписанный релиз vX.Y.Z на каждом зелёном main: сервер amd64 и arm64, архив SPA, SHA256SUMS. Выкладка берёт его файлы, проверив подпись и коммит. Тем же cosign main подписывает образ тулчейна и сверяет подпись перед проверками", s: "repo", ref: "R40 standalone, R45, R52, R53, R58 · T08, T11" }, { n: "Бюджет GCP", p: "budget.tf", v: "$1 · 50 / 90 / 100 %", r: "Оповещения о тратах проекта", s: "repo", ref: "spec 14.4 · R56 · T10" } ]}, { id: "obs", name: "Наблюдаемость", path: "internal/telemetry/", note: "в логах только UUID и коды", rows: [ @@ -553,7 +554,7 @@

После MVP: платформа MathTrai { n: "vegeta", p: "github.com/tsenart/vegeta/v12", v: "v12.13.0", r: "Темп и перцентили нагрузочного инструмента tools/load", s: "sample", ref: "E2" }, { n: "Playwright", p: "из образа по digest", v: "", r: "e2e на синтетических штрихах: ответ, Check, вердикт; обрыв сети и перезагрузка", s: "spec", ref: "spec 17 · E2" } ]}, - { id: "dev", name: "Среда разработки", path: ".devcontainer/ · justfile", note: "только внутри контейнера", foot: "Terraform, gcloud, golangci-lint и mockery ставятся отсюда же — они в разделах «Облако и поставка» и «Качество кода».", rows: [ + { id: "dev", name: "Среда разработки", path: ".devcontainer/ · justfile", note: "только внутри контейнера", foot: "Terraform, gcloud, firebase-tools, golangci-lint и mockery ставятся отсюда же — они в разделах «Облако и поставка» и «Качество кода».", rows: [ { n: "Go", v: "1.27.2", r: "Тулчейн сервиса; та же версия в go.mod", s: "repo", ref: "Dockerfile · go.mod" }, { n: "Node.js", v: "24.21.0", r: "Тулчейн SPA", s: "repo", ref: "Dockerfile" }, { n: "just", v: "1.58.0", r: "Вход для всех команд; ci-* — то, что вызывает CI", s: "repo", ref: "Dockerfile" }, diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..e2f86b3 --- /dev/null +++ b/firebase.json @@ -0,0 +1,19 @@ +{ + "hosting": { + "public": "web/dist", + "headers": [ + { + "source": "/", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "/index.html", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "/assets/**", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + } + ] + } +} diff --git a/infra/bootstrap.sh b/infra/bootstrap.sh index 93e720c..e24598b 100755 --- a/infra/bootstrap.sh +++ b/infra/bootstrap.sh @@ -5,9 +5,12 @@ # Terraform runs in the pipeline (R56). # # Safe to run again: every step checks before it creates, and the pool's -# provider is brought back to the condition written here. What it cannot undo +# provider is brought back to the condition written here. What it does not undo # is a grant: a role taken out of the list below stays granted until it is -# removed by hand (docs/self-hosting.md). +# removed by hand (docs/self-hosting.md). It takes back two things alone: the +# binding that once lent the Terraform identity to every job of the +# repository, and the role of the project's policy held under any condition +# but the one written below (R58). # # It reads the deployment's facts from infra/terraform/, so there is one place # where they are written down, and prints at the end the two lines that a @@ -50,6 +53,21 @@ retry() { "$@" > /dev/null } +# A read, tried as often as a change above, that hands on what it printed. A +# read that keeps failing stops the script: read as nothing there, it would +# skip a step without a word. +retry_read() { + local attempt output + for attempt in 1 2 3 4; do + if output="$("$@" 2> /dev/null)"; then + printf '%s\n' "$output" + return 0 + fi + sleep $((attempt * 3)) + done + "$@" +} + # Makes a thing unless it is there, and looks again before every new try: a # create refused on this side may still have succeeded on the other, and the # next try then finds it instead of failing on it. The first argument asks @@ -75,6 +93,7 @@ region="$(hcl_value region "$tfvars")" repository="$(hcl_value github_repository "$tfvars")" owner_id="$(hcl_value github_owner_id "$tfvars")" pool="$(hcl_value workload_identity_pool_id "$tfvars")" +subject_prefix="$(hcl_value github_oidc_subject_prefix "$tfvars")" currency="$(hcl_value budget_currency "$tfvars")" bucket="$(hcl_value bucket "$backend")" @@ -83,6 +102,7 @@ require region "$region" require github_repository "$repository" require github_owner_id "$owner_id" require workload_identity_pool_id "$pool" +require github_oidc_subject_prefix "$subject_prefix" require budget_currency "$currency" require bucket "$bucket" @@ -234,11 +254,13 @@ ensure identity_exists make_identity # two keep the logs and the traces in the deployment's region: a log bucket # and the sink that fills it, and where traces are stored (R57). # -# None of them may change who holds what in the project itself. Service -# accounts are the exception: the configuration makes them and says who may act -# as them, and the role that allows it allows it for every account of the -# project, this one included. That is the power the pool's condition guards: -# only the delivery's workflow can borrow this identity (R56). +# None of them may change who holds what in the project itself; the one role +# that may is granted after them, under a condition. Service accounts are the +# exception: the configuration makes them and says who may act as them, and +# the role that allows it allows it for every account of the project, this one +# included. That is the power the bindings at the end guard: only the jobs of +# the delivery's environment, and a pull request's plan, can borrow this +# identity (R56, R58). echo "==> what it may do" for role in \ roles/serviceusage.serviceUsageAdmin \ @@ -275,14 +297,85 @@ retry billing_hidden gcloud billing accounts add-iam-policy-binding "$billing" \ --quiet echo " roles/billing.costsManager on the billing account" +# Who holds what in the project may be changed for two roles alone: the ones +# the SPA's publisher holds, which Hosting grants on the project and on nothing +# narrower (R58). Without the condition this role would make the identity the +# project's owner in all but name; with it, a grant or a revocation of any other +# role is refused (IAM's "Set limits on granting roles", read 2026-10-14). The +# condition goes in from a file, since the commas in it would split it on the +# command line. A role the configuration comes to grant on the project joins +# this list first, in the same change, and the script is run again before the +# merge (docs/self-hosting.md). +# +# IAM tells bindings apart by their condition, so a list written anew would be +# granted beside the one before it, and a role taken out of the list would stay +# grantable under the old binding. So the role is held under this condition +# alone: under any other, or none, it is taken back first, and when it is held +# as written, nothing is touched. A policy read in its first version names a +# role held under a condition with a suffix, so the role is matched as a prefix. +admin_role=roles/resourcemanager.projectIamAdmin +expression="api.getAttribute('iam.googleapis.com/modifiedGrantsByRole', []).hasOnly(['roles/firebasehosting.admin', 'roles/serviceusage.apiKeysViewer'])" +condition_file="$(mktemp)" +trap 'rm -f "$condition_file"' EXIT +cat > "$condition_file" < who may borrow it" project_number="$(gcloud projects describe "$project" --format='value(projectNumber)')" -principal="principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool}/attribute.repository/${repository}" -retry gcloud iam service-accounts add-iam-policy-binding "$service_account" \ - --project="$project" \ - --member="$principal" \ - --role=roles/iam.workloadIdentityUser \ - --quiet +subject="principal://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool}/subject/${subject_prefix}" +for member in "${subject}:environment:prod" "${subject}:pull_request"; do + retry gcloud iam service-accounts add-iam-policy-binding "$service_account" \ + --project="$project" \ + --member="$member" \ + --role=roles/iam.workloadIdentityUser \ + --quiet + echo " ${member##*/subject/}" +done + +# The binding that came before them lent this identity to every job of the +# repository. It is taken back once the two above hold, and only if it is +# there, so that a run again changes nothing. The policy is read first and +# searched second: inside the test, a read that failed would look like a +# policy without the binding. +everyone="principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool}/attribute.repository/${repository}" +borrowers="$(retry_read gcloud iam service-accounts get-iam-policy "$service_account" --project="$project" \ + --flatten='bindings[].members' --format='value(bindings.members)')" +if grep -qxF "$everyone" <<< "$borrowers"; then + retry gcloud iam service-accounts remove-iam-policy-binding "$service_account" \ + --project="$project" \ + --member="$everyone" \ + --role=roles/iam.workloadIdentityUser \ + --quiet + echo " every job of the repository: taken back" +fi provider="projects/${project_number}/locations/global/workloadIdentityPools/${pool}/providers/github" diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf index 3112446..8c1cd8b 100644 --- a/infra/terraform/iam.tf +++ b/infra/terraform/iam.tf @@ -6,6 +6,17 @@ # cannot be built by whoever walks in by it: the pool, its provider and the # identity that runs Terraform are created once, from outside, by # infra/bootstrap.sh, and named here by id. +# +# Each identity is lent to the jobs of one GitHub environment, by the subject +# GitHub gives such a job, which names the environment. Only main may run in +# either environment, and each environment's jobs reach their own identity +# alone: the delivery's jobs run in prod, the Hosting job in prod-hosting +# (deploy.yml, R58). + +locals { + # Everybody whose token carries one subject, ready for its environment. + github_subject = "principal://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.workload_identity_pool_id}/subject/${var.github_oidc_subject_prefix}" +} # What the service runs as. It holds no role yet, because the service calls no # Google API of its own: each grant arrives with the code that first uses it — @@ -18,8 +29,8 @@ resource "google_service_account" "runtime" { } # How a deployment gets in: an identity with just enough rights to push an image -# and roll a revision, borrowed through the tokens GitHub issues to one -# repository. No service account key exists anywhere, so there is none to leak. +# and roll a revision, borrowed by the delivery's jobs. No service account key +# exists anywhere, so there is none to leak. resource "google_service_account" "deployer" { account_id = "${var.service_name}-deploy" display_name = "Deployment identity of the ${var.service_name} service" @@ -30,7 +41,7 @@ resource "google_service_account" "deployer" { resource "google_service_account_iam_member" "deployer_from_github" { service_account_id = google_service_account.deployer.name role = "roles/iam.workloadIdentityUser" - member = "principalSet://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.workload_identity_pool_id}/attribute.repository/${var.github_repository}" + member = "${local.github_subject}:environment:prod" } # Push an image into the one repository, @@ -74,3 +85,35 @@ resource "google_service_account_iam_member" "deployer_runs_as_runtime" { role = "roles/iam.serviceAccountUser" member = "serviceAccount:${google_service_account.deployer.email}" } + +# The SPA's publisher: Firebase Hosting and nothing else. Its job runs a large +# program of somebody else's, the Firebase CLI, so the job has an environment +# and an identity of its own, and reaches neither the service nor this +# configuration. Hosting takes no custom role and its rights are granted on the +# project: the ready-made Hosting role, and the reading of the project's API +# keys, which the CLI's deploy asks for (Firebase's documentation, read +# 2026-10-14). These two are the only roles the identity that applies this +# configuration may grant on the project (infra/bootstrap.sh, R58). +resource "google_service_account" "hosting" { + account_id = "${var.service_name}-hosting" + display_name = "Publishing identity of the ${var.service_name} SPA" + + depends_on = [google_project_service.enabled] +} + +resource "google_service_account_iam_member" "hosting_from_github" { + service_account_id = google_service_account.hosting.name + role = "roles/iam.workloadIdentityUser" + member = "${local.github_subject}:environment:prod-hosting" +} + +resource "google_project_iam_member" "hosting" { + for_each = toset([ + "roles/firebasehosting.admin", + "roles/serviceusage.apiKeysViewer", + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.hosting.email}" +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 7226097..0a3853d 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -40,6 +40,11 @@ output "deploy_service_account" { value = google_service_account.deployer.email } +output "hosting_service_account" { + description = "The identity the SPA is published with." + value = google_service_account.hosting.email +} + output "log_bucket" { description = "The bucket the project's logs are kept in, in its region (R57)." value = google_logging_project_bucket_config.logs.bucket_id diff --git a/infra/terraform/prod.auto.tfvars b/infra/terraform/prod.auto.tfvars index 7db7232..966a708 100644 --- a/infra/terraform/prod.auto.tfvars +++ b/infra/terraform/prod.auto.tfvars @@ -24,6 +24,10 @@ github_owner_id = "261630499" # are exchanged for Google ones. workload_identity_pool_id = "canvas-api-github" +# How GitHub begins the subject of this repository's tokens: its owner and its +# name, each with its numeric id, which no later owner of either name is given. +github_oidc_subject_prefix = "repo:MathTrail@261630499/canvas-api@1390897704" + # What a month of the project may cost before mail goes out, at 50, 90 and 100 # percent of it (spec 14.4). The currency is the billing account's own: a # budget in another one is refused, and infra/bootstrap.sh compares the two. diff --git a/infra/terraform/tests/configuration.tftest.hcl b/infra/terraform/tests/configuration.tftest.hcl index c510e7f..0be8c1e 100644 --- a/infra/terraform/tests/configuration.tftest.hcl +++ b/infra/terraform/tests/configuration.tftest.hcl @@ -173,3 +173,53 @@ run "a_service_name_ending_in_a_hyphen_is_refused" { expect_failures = [var.service_name] } + +# Each identity is lent to the jobs of one environment, and to nobody else: the +# delivery's to prod, the SPA's publisher to prod-hosting (R58). +run "each_identity_is_lent_to_one_environment" { + command = plan + + assert { + condition = google_service_account_iam_member.deployer_from_github.member == "principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/canvas-api-github/subject/repo:MathTrail@261630499/canvas-api@1390897704:environment:prod" + error_message = "The deployment identity is lent to the jobs of the environment prod alone." + } + + assert { + condition = google_service_account_iam_member.hosting_from_github.member == "principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/canvas-api-github/subject/repo:MathTrail@261630499/canvas-api@1390897704:environment:prod-hosting" + error_message = "The publishing identity is lent to the jobs of the environment prod-hosting alone." + } +} + +# The roles the identity that applies this configuration may grant on the +# project, and no others: the condition in infra/bootstrap.sh names the same two. +run "the_publisher_holds_two_project_roles" { + command = plan + + assert { + condition = toset(keys(google_project_iam_member.hosting)) == toset(["roles/firebasehosting.admin", "roles/serviceusage.apiKeysViewer"]) + error_message = "The publishing identity holds Hosting's role and the reading of API keys, and nothing else on the project." + } +} + +run "a_subject_prefix_of_another_repository_is_refused" { + command = plan + + variables { + github_oidc_subject_prefix = "repo:MathTrail@261630499/mathtrail-standalone@1373391176" + } + + expect_failures = [var.github_oidc_subject_prefix] +} + +# The identity that applies this configuration may grant on the project only +# the roles its condition in infra/bootstrap.sh lists. A role granted here and +# not listed there would pass every other check and be refused at the apply, +# after the merge (docs/self-hosting.md). +run "the_publisher_holds_what_bootstrap_lets_the_configuration_grant" { + command = plan + + assert { + condition = toset(keys(google_project_iam_member.hosting)) == toset(regexall("roles/[A-Za-z.]+", regex("hasOnly\\(\\[([^\\]]*)\\]\\)", file("../bootstrap.sh"))[0])) + error_message = "The roles granted on the project are the ones the condition in infra/bootstrap.sh lets the Terraform identity grant: change both together, and run bootstrap again before the merge." + } +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index f2b33a9..a4941fb 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -59,6 +59,16 @@ variable "workload_identity_pool_id" { } } +variable "github_oidc_subject_prefix" { + description = "How GitHub begins the subject of this repository's tokens: the owner and the repository, each with its numeric id, as `gh api repos/OWNER/NAME/actions/oidc/customization/sub --jq .sub_claim_prefix` prints it. A job's subject goes on to name the environment it runs in, so an identity bound to one subject is lent to the jobs of one environment alone (R58)." + type = string + + validation { + condition = can(regex("^repo:[^/@]+@[0-9]+/[^/@]+@[0-9]+$", var.github_oidc_subject_prefix)) && startswith(var.github_oidc_subject_prefix, "repo:${replace(var.github_repository, "/", "@${var.github_owner_id}/")}@") + error_message = "github_oidc_subject_prefix is repo:OWNER@OWNER_ID/NAME@REPOSITORY_ID, for the github_repository and github_owner_id given." + } +} + variable "service_name" { description = "The name of the Cloud Run service, and the prefix of everything named after it, its identities among them." type = string @@ -66,7 +76,7 @@ variable "service_name" { validation { condition = can(regex("^[a-z][a-z0-9-]{0,20}[a-z0-9]$", var.service_name)) - error_message = "service_name is 2 to 22 lowercase letters, digits and hyphens, starting with a letter and ending with a letter or digit: Cloud Run names a service so, and the identities named after it take up to seven characters more, of 6 to 30." + error_message = "service_name is 2 to 22 lowercase letters, digits and hyphens, starting with a letter and ending with a letter or digit: Cloud Run names a service so, and the identities named after it take up to eight characters more, of 6 to 30." } } diff --git a/justfile b/justfile index 0e909e6..318dfb9 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,11 @@ TOOLCHAIN_IMAGE := "ghcr.io/mathtrail/canvas-api/toolchain" # (R53). TOOLCHAIN_SIGNER := "https://" + MODULE + "/.github/workflows/ci.yml@refs/heads/main" GITHUB_ISSUER := "https://token.actions.githubusercontent.com" +# Who signs a release: the release workflow, run for a commit of main (R52). +# A delivery rolls out nothing it did not sign (R58). +RELEASE_SIGNER := "https://" + MODULE + "/.github/workflows/release.yml@refs/heads/main" +# The repository on GitHub, as gh names it. +REPOSITORY := trim_start_match(MODULE, "github.com/") # The definition of the checks' environment: a hash of the toolchain stage of # the development image — every line above the stage built on top of it. It is # the image's tag, and main's signature names it too, so that a signature of an @@ -32,6 +37,19 @@ GO_LICENSES := "github.com/google/go-licenses/v2@v2.0.1" # The image the runtime image is scanned with, pinned by tag and digest like # every other image this repository runs. TRIVY_IMAGE := "aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa" +# The image a release's signature is checked with: the cosign the release signs +# with, run as SECURITY.md runs it. +COSIGN_IMAGE := "ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8" + +# What a release's tag looks like: v and three numbers as SemVer writes them, +# digits alone with no leading zero. A release is numbered so, and a delivery +# takes nothing else for one. +RELEASE_TAG := '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + +# What the health of a build answers when it is the build named: ok, the version +# exactly, and a commit that begins the full one given — a build stamps git's +# short form of it, as long as it has to be to name one commit. +HEALTH_MATCHES := '.status == "ok" and .version == $version and (.commit | type == "string" and test("^[0-9a-f]{7,40}$")) and (.commit as $short | $commit | startswith($short))' # The deployment, as Terraform describes it (spec 15.2), and the filter what # Terraform prints about it passes through (_tf-redact, below). @@ -131,7 +149,8 @@ release-version $line="" $requested="": fi # The tags a release makes, oldest first. Any other tag that starts with a # v is no release, and counts for neither the newest nor the line's last. - releases() { grep -E "^v${number}\.${number}\.${number}$" | sort -V || true; } + release={{ quote(RELEASE_TAG) }} + releases() { grep -E "$release" | sort -V || true; } newest=$(git tag --list 'v*' | releases | tail -1) if [ -n "$requested" ]; then version="$requested" @@ -143,7 +162,7 @@ release-version $line="" $requested="": version="v${line}.$((${last##*.} + 1))" fi fi - if [[ ! "$version" =~ ^v${number}\.${number}\.${number}$ ]]; then + if [[ ! "$version" =~ $release ]]; then echo "release-version: '${version}' is not vMAJOR.MINOR.PATCH" >&2 exit 1 fi @@ -429,17 +448,15 @@ ci-secrets: echo "ci-secrets: and what is not committed yet, as $pending" >&2 go run {{ GITLEAKS }} git --no-banner --redact --verbose --log-opts="-1 $pending" . -# The image is started as Cloud Run starts it, with PORT and nothing else, and -# asked for its health as the platform's probe asks. It has to answer, and to -# name the build it was made from: an image that lost its version on the way -# would roll out as a revision nobody can tell from the one before. +# An image that lost its version on the way would roll out as a revision nobody +# can tell from the one before, so the image built here has to name the build +# it was made from. # Fail if the image does not start, or its health does not name this build ci-image-smoke tag="canvas-api:dev": (docker-build tag) #!/usr/bin/env bash set -euo pipefail - # The tag and the build identity reach the script as quoted text, as in - # docker-build, and it reads them only from these variables. - tag={{ quote(tag) }} + # The build identity reaches the script as quoted text, as in docker-build, + # and it reads it only from these variables. version={{ quote(VERSION) }} commit={{ quote(COMMIT) }} # Where git cannot tell the build, it is named dev at unknown — which is @@ -449,13 +466,25 @@ ci-image-smoke tag="canvas-api:dev": (docker-build tag) echo "ci-image-smoke: the build is named ${version} at ${commit}, as a binary that lost its version names itself, so its answer would prove nothing; run the check in a clone, with its history" >&2 exit 1 fi + {{ quote(just_executable()) }} --justfile {{ quote(justfile()) }} _image-health {{ quote(tag) }} "$version" "$(git rev-parse HEAD)" + +# The image is started as Cloud Run starts it, with PORT and nothing else, and +# asked for its health as the platform's probe asks. It has to answer, and to +# name the build given: the checks hold the image they build to it, and a +# delivery the image of a release before it pushes it. +_image-health tag version commit: + #!/usr/bin/env bash + set -euo pipefail + tag={{ quote(tag) }} + version={{ quote(version) }} + commit={{ quote(commit) }} # Created first and started second, so that the container is removed when # the check ends, whether it never started, stopped at once — its logs are # still there to show — or is serving still. container=$(docker create --publish 127.0.0.1::8080 --env PORT=8080 "$tag") trap 'docker rm --force "$container" > /dev/null' EXIT if ! docker start "$container" > /dev/null; then - echo "ci-image-smoke: ${tag} did not start" >&2 + echo "image health: ${tag} did not start" >&2 exit 1 fi answered=false @@ -470,16 +499,16 @@ ci-image-smoke tag="canvas-api:dev": (docker-build tag) sleep 0.5 done if [ "$answered" != true ]; then - echo "ci-image-smoke: ${tag} never answered its health check" >&2 + echo "image health: ${tag} never answered its health check" >&2 docker logs "$container" >&2 exit 1 fi if ! jq --exit-status --arg version "$version" --arg commit "$commit" \ - '.status == "ok" and .version == $version and .commit == $commit' <<< "$body" > /dev/null 2>&1; then - echo "ci-image-smoke: ${tag} answers ${body}; want status ok, version ${version}, commit ${commit}" >&2 + {{ quote(HEALTH_MATCHES) }} <<< "$body" > /dev/null 2>&1; then + echo "image health: ${tag} answers ${body}; want status ok, version ${version}, and a commit that begins ${commit}" >&2 exit 1 fi - echo "ci-image-smoke: ${tag} answers as ${version} at ${commit}" + echo "image health: ${tag} answers as ${version} at $(jq -r .commit <<< "$body")" # Trivy runs from its own image, so nothing about it is installed here and the # verdict is the same on a laptop and in CI. It reads a saved copy of the image @@ -704,7 +733,7 @@ ci-tf-apply: _tf-init ci-tf-outputs: #!/usr/bin/env bash set -euo pipefail - for name in deploy_service_account image_repository service_name region service_url hosting_url; do + for name in project_id deploy_service_account hosting_service_account image_repository service_name region service_url hosting_url; do value="$(terraform -chdir={{ TF_DIR }} output -raw "$name")" echo "${name}=${value}" done @@ -730,3 +759,289 @@ _tf-redact: if [ -n "$email" ]; then line="${line//"$email"/""}"; fi printf '%s\n' "$line" done + +# -- Delivery --------------------------------------------------------------- + +# A delivery rolls out a release, named by its tag: the server and the SPA that +# release built and signed, with the configuration — Terraform, firebase.json, +# the Dockerfile — of the commit the tag names (R45, R58). +# +# The button delivers the release at the tip of main, and nothing when there is +# none there: it never builds a commit nobody released, and it never reaches +# back for an older release, which would roll prod back. Should the tip carry +# more than one, the highest version is taken. A version handed over — by the +# release just published — is delivered only while main still ends at its +# commit: an older commit released late, or released again because its checks +# were run again, would roll prod back too. +# +# What is printed is for $GITHUB_OUTPUT: the version, the full commit its tag +# names, and whether to deliver it. Why not goes to stderr. +# Say which release a delivery rolls out, the commit it is of, and whether main still ends there +ci-release-target version="": + #!/usr/bin/env bash + set -euo pipefail + version={{ quote(version) }} + release={{ quote(RELEASE_TAG) }} + if [ -n "$version" ] && [[ ! "$version" =~ $release ]]; then + echo "ci-release-target: '${version}' is not vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + # Read from GitHub rather than from this clone, which may be behind it. + tip=$(git ls-remote origin refs/heads/main | cut -f1) + if [[ ! "$tip" =~ ^[0-9a-f]{40}$ ]]; then + echo "ci-release-target: could not read where main ends" >&2 + exit 1 + fi + # Every tag, with the commit it names. An annotated tag's own line names + # the tag object, and a second line, marked ^{}, the commit. + tags=$(git ls-remote --tags origin | awk ' + { + name = $2 + sub(/^refs\/tags\//, "", name) + if (substr(name, length(name) - 2) == "^{}") { + peeled[substr(name, 1, length(name) - 3)] = $1 + } else { + named[name] = $1 + } + } + END { for (name in named) print name, ((name in peeled) ? peeled[name] : named[name]) }') + if [ -z "$version" ]; then + version=$(awk -v tip="$tip" '$2 == tip { print $1 }' <<< "$tags" | { grep -E "$release" || true; } | sort -V | tail -1) + if [ -z "$version" ]; then + echo "ci-release-target: no release is at the tip of main, ${tip}, so nothing is delivered" >&2 + printf 'version=\ncommit=\ndeliver=false\n' + exit 0 + fi + fi + commit=$(awk -v version="$version" '$1 == version { print $2 }' <<< "$tags") + if [ -z "$commit" ]; then + echo "ci-release-target: no tag is called ${version}" >&2 + exit 1 + fi + deliver=true + if [ "$commit" != "$tip" ]; then + deliver=false + echo "ci-release-target: main ends at ${tip} now, past ${version} at ${commit}; the release of the tip delivers itself, so nothing is delivered here" >&2 + fi + printf 'version=%s\ncommit=%s\ndeliver=%s\n' "$version" "$commit" "$deliver" + +# "Re-run failed jobs" keeps what the first attempt of a delivery decided, the +# release to deliver among it, however long ago that was. So every job of a +# later attempt asks again, and goes no further once main has moved on: the +# newer commit's release delivers it, and this one would roll it back. +# Fail unless main still ends at the commit given +ci-release-current commit: + #!/usr/bin/env bash + set -euo pipefail + commit={{ quote(commit) }} + tip=$(git ls-remote origin refs/heads/main | cut -f1) + if [ "$tip" != "$commit" ]; then + echo "ci-release-current: main ends at ${tip:-a commit GitHub did not name} now, not at ${commit}: its own release delivers it, and this one would roll it back" >&2 + exit 1 + fi + echo "ci-release-current: main still ends at ${commit}" + +# Nothing reaches prod that the release did not sign. The sums are checked +# first: their signature has to be the release workflow's, run on main for this +# very commit — so the files of another release, signed as well, do not pass +# for this one's. cosign runs from its image, as SECURITY.md runs it. Only then +# is the file held to its own line among the sums, and only the file that +# passed lands in the folder given. +# Download one file of a release, and keep it only if the release workflow signed it at the commit given +ci-release-fetch version commit file dir: + #!/usr/bin/env bash + set -euo pipefail + version={{ quote(version) }} + commit={{ quote(commit) }} + file={{ quote(file) }} + dir={{ quote(dir) }} + release={{ quote(RELEASE_TAG) }} + if [[ ! "$version" =~ $release ]]; then + echo "ci-release-fetch: '${version}' is not vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "ci-release-fetch: '${commit}' is not a commit's full hash" >&2 + exit 1 + fi + # The name of a file of the release: never a path, and never a pattern, + # which gh would match against every file there. + if [[ ! "$file" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then + echo "ci-release-fetch: '${file}' is not the name of a file of a release" >&2 + exit 1 + fi + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + # cosign runs as a user of its own, who has to be able to read the folder. + chmod 0755 "$scratch" + gh release download "$version" --repo {{ quote(REPOSITORY) }} --dir "$scratch" \ + --pattern SHA256SUMS --pattern SHA256SUMS.sigstore.json --pattern "$file" + docker run --rm --volume "$scratch:/release:ro" --workdir /release {{ COSIGN_IMAGE }} \ + verify-blob --bundle SHA256SUMS.sigstore.json \ + --certificate-identity {{ quote(RELEASE_SIGNER) }} \ + --certificate-oidc-issuer {{ quote(GITHUB_ISSUER) }} \ + --certificate-github-workflow-sha "$commit" \ + SHA256SUMS + if ! line=$(awk -v name="$file" 'NF == 2 && $2 == name { print; found++ } END { exit found != 1 }' "$scratch/SHA256SUMS"); then + echo "ci-release-fetch: the sums of ${version} do not name ${file} once" >&2 + exit 1 + fi + (cd "$scratch" && sha256sum --check --strict <<< "$line") + mkdir -p "$dir" + mv "$scratch/$file" "$dir/$file" + +# The image of a release is the runtime around the server that release built +# and signed, never one built again: what is rolled out is what was released, +# byte for byte (R58). It has to name the release before it is pushed, as the +# image the checks build has to name its build, and it is named afterwards by +# the digest its own push reported: a tag can be moved, and a digest cannot. +# The tag is the release's number, for a person reading the registry. +# Build the image of a release around the server it signed, check it, push it, and print its exact reference +ci-image-push binary version commit image: + #!/usr/bin/env bash + set -euo pipefail + binary={{ quote(binary) }} + version={{ quote(version) }} + commit={{ quote(commit) }} + image={{ quote(image) }} + release={{ quote(RELEASE_TAG) }} + if [[ ! "$version" =~ $release ]]; then + echo "ci-image-push: '${version}' is not vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "ci-image-push: '${commit}' is not a commit's full hash" >&2 + exit 1 + fi + # A repository of a registry, which the tag and the digest are added to. + if [[ ! "$image" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]]; then + echo "ci-image-push: '${image}' is not an image repository" >&2 + exit 1 + fi + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + # The context the Dockerfile's release stage takes the server from, holding + # it alone. It is the stage's only input from outside, so nothing else of + # the checkout is sent to the builder. + mkdir "$scratch/context" + cp "$binary" "$scratch/context/server" + tag="${image}:${version}" + # No provenance: the build would hang it on the image as a second + # manifest, and what is pushed would be an index rather than the image. + docker build --target release --build-context "binary=${scratch}/context" \ + --provenance=false \ + --label "org.opencontainers.image.source=https://{{ MODULE }}" \ + --label "org.opencontainers.image.version=${version}" \ + --label "org.opencontainers.image.revision=${commit}" \ + --tag "$tag" . >&2 + {{ quote(just_executable()) }} --justfile {{ quote(justfile()) }} _image-health "$tag" "$version" "$commit" >&2 + # A push needs a credential helper for that registry, the first component + # of the image's path. + gcloud auth configure-docker "${image%%/*}" --quiet >&2 + if ! docker push "$tag" | tee "$scratch/push" >&2; then + echo "ci-image-push: could not push ${tag}; docker push says why above" >&2 + exit 1 + fi + digest=$(sed -n 's/^.*: digest: \(sha256:[0-9a-f]\{64\}\) size: [0-9]*$/\1/p' "$scratch/push" | tail -1) + if [ -z "$digest" ]; then + echo "ci-image-push: docker push named no digest for ${tag}" >&2 + exit 1 + fi + # The one thing on stdout, so that a caller can read it with a substitution. + echo "${image}@${digest}" + +# The service is created once, by the configuration, with everything around +# it; a delivery only ever changes the image it serves, and only by digest. The +# new revision then takes all the traffic, wherever a rollback left it: once +# the traffic is pinned to a revision, a new one gets none until it is sent +# back to the latest (Cloud Run's documentation, read 2026-10-14). +# Roll a revision of the service onto the image given, and send it all the traffic +ci-deploy service region image: + #!/usr/bin/env bash + set -euo pipefail + service={{ quote(service) }} + region={{ quote(region) }} + image={{ quote(image) }} + if [[ ! "$image" =~ @sha256:[0-9a-f]{64}$ ]]; then + echo "ci-deploy: an image is deployed by its digest, never by a tag: ${image}" >&2 + exit 1 + fi + gcloud run services update "$service" --region="$region" --image="$image" --quiet + gcloud run services update-traffic "$service" --region="$region" --to-latest --quiet + +# Answering is half of it: what answers has to be the release just rolled out, +# by its version and its commit, rather than the revision before it. No +# redirect is followed: the answer has to come from the address itself, which +# is the caller's to choose, so this also probes a container on this machine. +# Fail unless the service answers its health as the release given, at the commit given +ci-smoke url version commit: + #!/usr/bin/env bash + set -euo pipefail + url={{ quote(url) }} + version={{ quote(version) }} + commit={{ quote(commit) }} + body="" + for attempt in $(seq 1 10); do + if body=$(curl -fsS --max-time 10 "${url}/health") \ + && jq --exit-status --arg version "$version" --arg commit "$commit" \ + {{ quote(HEALTH_MATCHES) }} <<< "$body" > /dev/null 2>&1; then + echo "ci-smoke: ${url} answers as ${version} at $(jq -r .commit <<< "$body")" + exit 0 + fi + echo "health: not this release yet (attempt ${attempt})" >&2 + sleep 3 + done + echo "ci-smoke: ${url}/health answers ${body:-nothing}; want status ok, version ${version}, and a commit that begins ${commit}" >&2 + exit 1 + +# Hosting serves exactly the SPA the release built and signed: the archive is +# unpacked where firebase.json says the site's files are, over nothing an +# earlier build left there, and published to the project's default site under +# the release's number, which Hosting keeps beside the version in its history. +# Publish the SPA of a release, from its archive, to Firebase Hosting +ci-hosting-deploy archive project version: + #!/usr/bin/env bash + set -euo pipefail + archive={{ quote(archive) }} + project={{ quote(project) }} + version={{ quote(version) }} + rm -rf web/dist + mkdir -p web/dist + tar -C web/dist -xzf "$archive" + if [ ! -f web/dist/index.html ]; then + echo "ci-hosting-deploy: ${archive} holds no index.html" >&2 + exit 1 + fi + firebase deploy --only hosting --project "$project" --non-interactive --message "$version" + +# The page has to be the one just published, byte for byte, and served +# uncached: a browser that kept it would ask for the files of a release that +# the next one removed. No two releases share a page: it names the script by a +# hash of the script, and the script carries the release's number. So the page +# of the release before never passes for this one's, and the check waits the +# few seconds Hosting takes to put a new version in front of everybody. As +# with the service, no redirect is followed. +# Fail unless the site serves the page in web/dist, uncached +ci-hosting-check url: + #!/usr/bin/env bash + set -euo pipefail + url={{ quote(url) }} + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + for attempt in $(seq 1 10); do + if curl -fsS --max-time 10 \ + --dump-header "$scratch/headers" --output "$scratch/page" "${url}/" \ + && cmp -s "$scratch/page" web/dist/index.html; then + cache=$(tr -d '\r' < "$scratch/headers" | sed -n 's/^[Cc][Aa][Cc][Hh][Ee]-[Cc][Oo][Nn][Tt][Rr][Oo][Ll]: *//p' | tail -1) + if [ "$cache" != no-cache ]; then + echo "ci-hosting-check: ${url}/ is the page in web/dist, served with Cache-Control '${cache}'; want no-cache" >&2 + exit 1 + fi + echo "ci-hosting-check: ${url}/ is the page in web/dist, uncached" + exit 0 + fi + echo "hosting: not this page yet (attempt ${attempt})" >&2 + sleep 3 + done + echo "ci-hosting-check: ${url}/ is not the page in web/dist" >&2 + exit 1 From 053820891a6039cddf9ae8a0c8a938affd438271 Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:21:39 -0500 Subject: [PATCH 3/4] CD: deliver every signed release to Cloud Run and Firebase Hosting (T11) --- .devcontainer/devcontainer.json | 4 +- .github/actionlint.yaml | 13 +++ .github/dependabot.yml | 4 +- .github/workflows/ci.yml | 50 +++++++---- .github/workflows/codeql.yml | 4 +- .github/workflows/deploy.yml | 6 +- .github/workflows/release.yml | 15 ++-- .github/workflows/scorecard.yml | 3 +- .gitignore | 2 +- .gitleaks.toml | 2 +- .golangci.yml | 29 +++---- .mockery.yaml | 4 +- CLAUDE.md | 11 +-- CONTRIBUTING.md | 2 +- Dockerfile | 2 +- RUN.md | 85 +++++++++++++++---- THIRD_PARTY_LICENSES | 2 +- cmd/server/main.go | 13 ++- cmd/server/main_test.go | 2 +- codecov.yml | 4 +- docs/decisions.md | 11 +-- docs/stack.html | 1 + infra/bootstrap.sh | 27 +++--- infra/terraform/alert.tf | 10 +-- infra/terraform/apis.tf | 6 +- infra/terraform/budget.tf | 4 +- infra/terraform/firebase.tf | 8 +- infra/terraform/iam.tf | 8 +- infra/terraform/logging.tf | 6 +- infra/terraform/outputs.tf | 6 +- infra/terraform/prod.auto.tfvars | 10 +-- infra/terraform/service_api.tf | 30 +++---- .../terraform/tests/configuration.tftest.hcl | 11 +-- infra/terraform/variables.tf | 10 +-- infra/terraform/versions.tf | 2 +- justfile | 75 +++++++++++----- web/src/App.tsx | 2 +- web/vite.config.ts | 4 +- 38 files changed, 303 insertions(+), 185 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8b797ec..d23ffd6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,4 +1,4 @@ -// Devcontainer for canvas-api: all development happens here (see CLAUDE.md). +// Devcontainer for canvas-api: all development happens here. // Tool versions are pinned directly in this file and in .devcontainer/Dockerfile. { "name": "canvas-api", @@ -38,7 +38,7 @@ "source=canvas-api-gh,target=/home/vscode/.config/gh,type=volume" ], "containerEnv": { - // Marker checked by Claude sessions (CLAUDE.md, "Devcontainer only"). + // Tells a Claude session it runs in here, the one place it may work. "MATHTRAIL_DEVCONTAINER": "1", "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", "DISABLE_AUTOUPDATER": "1" diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..40a2d84 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,13 @@ +# What actionlint reports and GitHub accepts, waived with its reason. Each +# waiver is held to one file and one message, so the same finding anywhere else +# still fails the check. +paths: + # The release queues its waiting runs with concurrency's `queue: max`, a key + # GitHub documents and accepts — every release so far ran with it — and + # actionlint 1.7.12 does not know yet. Without the queue, a second waiting + # release would push out the first, and a commit of main merged in between + # would be left without its release. Once an actionlint that knows the key is + # pinned, this waiver hides nothing and goes. + .github/workflows/release.yml: + ignore: + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f57e508..012b3e9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,12 +4,12 @@ # the development image installs from its ARGs are raised by hand: no # ecosystem here reads them. So are Terraform's providers and their lock file: # Dependabot would lock them with a Terraform of its own, and the configuration -# accepts only the one the development image installs (R56). +# accepts only the one the development image installs. # # Every ecosystem waits three days after a release before proposing it. That is # Dependabot's own default, written down because CI counts on it: a release # that was hijacked is most often pulled within that time, and the job that -# publishes the toolchain image runs what a pull request proposes (R53). The +# publishes the toolchain image runs what a pull request proposes. The # wait does not hold back a security update, and it needs the registry to say # when a release came out: Microsoft's registry, of the development image's # base, does not, and its new versions are proposed at once. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c89528..3fa451e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ # # A check a merge waits for comes from a workflow that always starts: one held # back by a filter on paths leaves its checks waiting for good, and the pull -# request with them (R231 standalone). So nothing here filters on paths, and -# work that only some changes need is skipped by a step inside its job. +# request with them. So nothing here filters on paths, and work that only some +# changes need is skipped by a step inside its job. name: CI on: @@ -23,7 +23,7 @@ permissions: # only its newest commit can be merged. On main every commit gets a run of its # own: a group holds at most one waiting run and drops the one that waited # before it, which would leave a commit of main unchecked, and every commit of -# main is one a release is cut from (R45). +# main is one a release is cut from. concurrency: group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -57,9 +57,9 @@ jobs: timeout-minutes: 30 # The one job that publishes the image: it is built and published here when # nothing is published yet for its definition, or, on main, when main did - # not sign what is (R53); the job after it only adds the signature. A pull request from a fork - # gets a token that cannot publish, and runs on what is published already - # (R20 standalone). + # not sign what is; the job after it only adds the signature. A pull request + # from a fork gets a token that cannot publish, and runs on what is + # published already. permissions: contents: read packages: write @@ -71,7 +71,7 @@ jobs: persist-credentials: false - uses: ./.github/actions/just # A push is main's, and main runs its checks only in an image it signed - # itself; cosign is what reads the signature (R53). + # itself; cosign is what reads the signature. - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 if: github.event_name == 'push' with: @@ -94,7 +94,7 @@ jobs: # the identity it is made with, and it runs nothing but cosign. A signature # that failed — an outage of Sigstore — fails no check: the image was built # by this very run, and the next run of main builds it again and signs it - # then (R53). + # then. toolchain-signature: name: Toolchain image signature needs: toolchain @@ -170,7 +170,7 @@ jobs: - run: just ci-test # Codecov holds the changed lines to 90% through its patch status, which # the ruleset of main requires; the upload itself fails nothing, so that - # a run with no token to upload with, or an outage, is no red test (R50). + # a run with no token to upload with, or an outage, is no red test. - name: Send the coverage profile to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: @@ -224,7 +224,7 @@ jobs: # No cache: while .mockery.yaml names no interface, the check builds nothing, # and a cache with nothing in it only warns on every run that there was - # nothing to save. The first interface brings *go-cache back (R49). + # nothing to save. The first interface brings *go-cache back. mocks: name: Generated mocks needs: toolchain @@ -269,7 +269,7 @@ jobs: # The recipes a release is cut with, run here with the version release.yml # would publish, so that a change that breaks them cannot merge and leave - # main without its release. The release itself builds on the runner (R52). + # main without its release. The release itself builds on the runner. # Nothing built here leaves the job. The whole history: the version is read # from the tags. release: @@ -300,7 +300,7 @@ jobs: # the development image adds it in the stage on top. Nothing here signs in # anywhere, so the job's token reads the code and holds no identity token: # Dependabot's pull requests run it too. What an apply would change is shown - # by deploy.yml, the one workflow the federated pool admits (R58). + # by deploy.yml, the one workflow the federated pool admits. terraform: name: Terraform runs-on: ubuntu-24.04 @@ -315,15 +315,33 @@ jobs: - uses: ./.github/actions/terraform - run: just tf-check + # The workflows themselves, read as GitHub will read them, the call from + # release.yml to deploy.yml among them. On the runner: actionlint runs from + # its image, and the toolchain image has no Docker client. It pulls no + # package of this repository, so its token reads the code alone. + workflows: + name: Workflows + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/just + - uses: ./.github/actions/docker-mirror + - run: just ci-workflows + sonar: name: Static analysis needs: [test, web] runs-on: ubuntu-24.04 timeout-minutes: 15 - # SonarCloud reports and gates nothing (R31 standalone), and so may not stop - # a release either, which waits for this whole run to be green (R45). A - # failed analysis — no token on main, an outage — turns this job red for - # anyone to see, and leaves the run green (R50). + # SonarCloud reports and gates nothing, and so may not stop a release + # either, which waits for this whole run to be green. A failed analysis — no + # token on main, an outage — turns this job red for anyone to see, and + # leaves the run green. continue-on-error: true # A third party's scanner runs here with a token of ours, so the job's own # token reads the code and nothing else: no package of this repository. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7429d0a..0e44161 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ # taken from a request or a file ends up, and whether it reaches somewhere it # must not. The linter's own security checks stay where they are; this one sees # what a single file cannot. Its findings hold a merge through the ruleset of -# main, which requires CodeQL's code scanning results (R50). +# main, which requires CodeQL's code scanning results. name: CodeQL on: @@ -20,7 +20,7 @@ permissions: # As in CI: a new push to a pull request replaces the run of the one before, # and every commit of main is analysed in a run of its own, since a group holds -# at most one waiting run and would drop the commits in between (R49). +# at most one waiting run and would drop the commits in between. concurrency: group: codeql-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0ab2497..49009fb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,6 @@ # Continuous delivery: brings the cloud up to what a release describes, rolls a # revision of the service onto the server that release signed, and publishes -# its SPA to Firebase Hosting (R32, R45, R58). +# its SPA to Firebase Hosting. # # Every step is a pinned action or a recipe of the justfile, so the same # commands work from a laptop. No key exists anywhere: each job borrows a @@ -55,7 +55,7 @@ defaults: jobs: # The plan reads the state, and the state holds what the deployment signs in - # with (R34), so it is shown only to a pull request of a branch of this + # with, so it is shown only to a pull request of a branch of this # repository, which only its owner can push: never to a fork, and never to # Dependabot, whose pull requests change the versions of the actions this job # would run with the Terraform identity before anybody has read them. @@ -197,7 +197,7 @@ jobs: # The server goes out before the SPA, and is checked before the SPA goes out: # a new page in front of a service that does not answer would break what - # works now (R58). + # works now. deploy: name: The server of the release needs: [target, infra] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd871cf..695e8c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,19 +2,18 @@ # the SPA as Firebase Hosting serves it, the sums of what was built, and a # signature for each of them. What matters is that anybody can check that a # file came from this repository, from this workflow run on main, at the commit -# the release's tag points at (R52). +# the release's tag points at. # # Nobody tags anything by hand: the version is the next patch of the line the # releases are numbered in, and the tag is created with the release. A number # of its own comes from raising RELEASE_LINE in the change that deserves it. The # button releases the tip of main when no release has yet — after a release run # that failed, say — and only then is a version asked for on it used; it works -# on main alone, and only once the checks have passed on that commit (R33, R45, -# R52). +# on main alone, and only once the checks have passed on that commit. # # A release that is still the newest of main is then delivered to prod, by # deploy.yml, within this same run: its status, here, is the status of the -# whole way from a green main to prod (R45, R58). +# whole way from a green main to prod. name: Release and deploy on: @@ -49,7 +48,7 @@ jobs: # before it published before it can work out the next. Every one waits its # turn, up to a hundred, rather than the newest pushing out the one that # waited before it, so each commit of main gets its release however fast - # the merges come (R52). + # the merges come. concurrency: group: release cancel-in-progress: false @@ -162,7 +161,7 @@ jobs: # main is read once, and the answer serves twice. Latest is the release # of the newest of main: an older commit whose checks finished late comes # out under a higher number, and GitHub would otherwise call it Latest by - # that number. And only the newest of main is delivered (R45), so the + # that number. And only the newest of main is delivered, so the # output newest says the same thing the release page does. - name: Publish the release id: newest @@ -193,8 +192,8 @@ jobs: # Only while main still ends at the commit just released: a release of an # older commit — one whose checks finished late, or were run again — would - # roll prod back (R45). The delivery is given the two secrets it needs, by - # name, and none of the others this repository keeps (R58). + # roll prod back. The delivery is given the two secrets it needs, by + # name, and none of the others this repository keeps. deploy: name: Deploy needs: release diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7fe5272..ea78558 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,8 +1,7 @@ # A public reading of how this repository is run: whether its dependencies are # pinned, whether its workflows hand out more permission than they need, whether # a branch can be pushed to without review. It measures practices, not code, and -# it blocks nothing — the badge in the README is the point (R50). The findings no -# change can close are answered once, in the decision log (R51). +# it blocks nothing — the badge in the README is the point. # # Publishing the results holds this file to the Scorecard API's rules: no # environment or defaults at the top, nothing but read permission there, and diff --git a/.gitignore b/.gitignore index ac9bd5e..c485b44 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,7 @@ firebase-debug.log* firebase-debug.*.log* # Claude Code's settings of one person on one machine, such as the narrow -# permission of a live run, which is deleted once the run is over (CLAUDE.md). +# permission of a live run, which is deleted once the run is over. .claude/settings.local.json # Reference copies of other repositories: read-only material, never committed. diff --git a/.gitleaks.toml b/.gitleaks.toml index 8a7f372..9615f29 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -2,7 +2,7 @@ # false alarm. The examples of what canvas-api publishes to Centrifugo carry an # idempotency key made of the event's kind and the check's UUID, such as # hint-c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f, and the generic rule for API keys -# reads it as a key (spec 10.7). The exception is held to that file and to that +# reads it as a key. The exception is held to that file and to that # shape together, so a real key there, or a key of that shape anywhere else, is # still reported. [extend] diff --git a/.golangci.yml b/.golangci.yml index d63c66e..b0d4a88 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,16 +32,15 @@ linters: depguard: rules: # The domain and the use cases may not import the libraries that reach - # the outside world (CLAUDE.md, "The dependency rule"): HTTP and its - # routers, the clients of Firebase, OpenFGA, Gemini, Postgres, - # Centrifugo and Google. The domain is pure computation, and a use case - # reaches out only through the ports it declares, which is what lets - # both be tested with tables, properties and fakes. The use cases are - # check and the packages the MVP epic puts beside it (spec 18), so the - # rule holds for every package under internal/ but those whose job is - # the outside world: the adapters, the transport, the wiring and the - # telemetry's export. A new package of that kind joins the list below - # by name. + # the outside world: HTTP and its routers, the clients of Firebase, + # OpenFGA, Gemini, Postgres, Centrifugo and Google. The domain is pure + # computation, and a use case reaches out only through the ports it + # declares, which is what lets both be tested with tables, properties + # and fakes. The use cases are check and the packages that will sit + # beside it, so the rule holds for every package under internal/ but + # those whose job is the outside world: the adapters, the transport, the + # wiring and the telemetry's export. A new package of that kind joins + # the list below by name. domain-stays-pure: files: - "**/internal/**" @@ -73,7 +72,7 @@ linters: - pkg: cloud.google.com/go desc: Google's clients belong in internal/infra # pgx is the driver of one adapter and of the migrations, and of - # nothing else: everywhere else the database is a port (spec 18). + # nothing else: everywhere else the database is a port. pgx-stays-in-postgres: files: - "$all" @@ -112,8 +111,8 @@ linters: settings: hugeParam: # The check's own default. The sample moves it to 88 for a value of - # its product that weighs exactly 80 bytes (R47 standalone); nothing - # here carries that reason. + # its product that weighs exactly 80 bytes; nothing here carries that + # reason. sizeThreshold: 80 govet: @@ -143,8 +142,8 @@ linters: exclusions: # No comments preset, unlike the sample: it hides what revive's exported - # and package-comments report, and those are how CLAUDE.md's rule of a doc - # comment on every package and every exported identifier is enforced. + # and package-comments report, and those are what holds every package and + # every exported identifier to a doc comment. presets: - std-error-handling rules: diff --git a/.mockery.yaml b/.mockery.yaml index 13187df..8032761 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -4,8 +4,8 @@ # A port lives in the package that needs it, so this file lists those packages # as they appear. It is empty while no interface has earned a mock: one does # when its real implementation needs the network, a clock, or is simply slow, -# and what a test checks is that a call happened, with these arguments -# (CLAUDE.md, "Tests"). The first are expected to be the ports of check. +# and what a test checks is that a call happened, with these arguments. The +# first are expected to be the ports of check. # # testify, unlike the sample's `template: mockery` and `with-expecter`, which # mockery v3.8.0 refuses: it has no template of that name, and the testify diff --git a/CLAUDE.md b/CLAUDE.md index 191fcee..0415c06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ The documents under `docs/` and RUN.md are in Russian (R25); they are cited by s - [docs/privacy.md](docs/privacy.md) — children's data and the law: what we collect and keep, what the external AI service receives, consent, deletion and retention. Open questions П-… are in section 12. - [docs/decisions.md](docs/decisions.md) — the decision log, R01…: what was decided, why, and what was rejected. Read it before proposing a design change. - [docs/stack.html](docs/stack.html) — the stack by architecture layer: each technology with its version, status and the section, decision or task behind it. A summary, not a source: where it differs from spec, the decision log or RUN.md, they win. -- [RUN.md](RUN.md) — the implementation plan, in epics (R44). Table 1 holds the current epic's tasks, run one at a time; table 2 holds later epics, broken into tasks only when their turn comes. Full executor rules are in its «Как пользоваться» section. +- [RUN.md](RUN.md) — the implementation plan, in epics (R44). Table 1 holds the current epic's tasks, run one at a time; table 2 holds later epics, broken into tasks only when their turn comes; table 3 holds the questions the author has not answered yet, and table 4 the technical debt. Full executor rules are in its «Как пользоваться» section. - `docs/live/` — reports of the spikes and of live runs in the cloud (from T12). Every report follows one format: - its status, in the first line under the title; - no identifier of a person or an account — no email, UID, invite code, billing account or token: the repository is public; @@ -63,14 +63,14 @@ Project context lives in these files, not in chat history. - The author runs tasks one at a time: «Выполни задачу Txx из RUN.md». Do only that task; do not touch files that belong to other tasks unless the task says so. - Before starting, read the RUN.md task, the product, spec and privacy sections it cites, and `docs/decisions.md`; a DevX task also reads the files of the sample it names under `reference/mathtrail-standalone/`. If the task contradicts them or something is missing, stop and ask. Do not silently fill gaps; record them as open questions under the next free number, which the section's opening names — О- in product 15 (the open ones are in 15.2), Т- in spec 20, П- in privacy 12 — or in the «Замечания» section of the document being written. - Before calling any change done — code, config, docs or content — run `/code-review high` on it. Skip it only when the author asks, for that change alone, and say in the report that it did not run. - - Run it once the change is written and its checks — `just ci-lint`, `just ci-test` and `just ci-mocks-check` (from T04), `just ci-licenses` and `just ci-vuln` (from T05), `just ci-secrets`, `just ci-image-smoke` and `just ci-image-scan` (from T06), `just release-version` and `just release-artifacts` (from T08), `just tf-check` (from T10), the others RUN.md rule 7 names for what the change touches, whatever else the task names — are green. + - Run it once the change is written and its checks — `just ci-lint`, `just ci-test` and `just ci-mocks-check` (from T04), `just ci-licenses` and `just ci-vuln` (from T05), `just ci-secrets`, `just ci-image-smoke` and `just ci-image-scan` (from T06), `just release-version` and `just release-artifacts` (from T08), `just tf-check` (from T10), `just ci-workflows` (from T11), the others RUN.md rule 7 names for what the change touches, whatever else the task names — are green. - The checks and the review run in a clean copy of the tree that holds this change and nothing else, so that another session's uncommitted work gets into neither. A tree that holds nothing but the change is that copy already. Otherwise the copy is a `git worktree` of `HEAD`, outside the repository, given this change's own edits, new files included, and what its checks need that git does not keep — a link to `reference/`, `web/node_modules` installed from the lockfile — and the checks and the review run from inside it. Fixes are made in the tree and carried into the copy, and `git worktree remove --force` takes the copy away after the review: it holds nothing the tree does not. - It reads the working tree plus the branch's commits — those not yet pushed, or all since `main` without an upstream — but not untracked files. Mark new files with `git add -N` for the review and unmark them with `git reset -- ` afterwards: the mark makes `git stash` fail. Pass no path — a path is reviewed instead of the diff, not beside it. - Verify each finding against the repository before acting on it; do not use `--fix`. Fix a real defect your uncommitted work introduced, wherever it shows up, with a test that fails without the fix when the defect is in code. A defect your work did not introduce — in an earlier commit, in code your work does not touch, or in another session's uncommitted work in the same tree — goes to the report as open. - Drop a wrong finding with a one-line reason, and one that re-proposes a rejected alternative with no new evidence by naming the decision. A finding that questions product, spec, privacy or a decision for a reason that holds goes to the author as a question. - If anything was fixed, run the checks and the review once more. That second round is the last: its findings are handled the same way, and its fixes get the checks but no third review. The report says what was fixed, what was dropped and why, and what is left open. - `/code-review ultra` is billed and started only by the author; this rule does not cover it. -- A finding of a code analyser — CodeQL, SonarCloud, Scorecard — is answered by changing the code. Marking one false is the last resort: the mark cites a decision in `docs/decisions.md` and the test that proves it (standalone: R35, R50, R111). A finding no change can close, such as Scorecard's ask for a second reviewer, stays open with its reason written down (R51 standalone). +- A finding of a code analyser — CodeQL, SonarCloud, Scorecard — is answered by changing the code. Marking one false is the last resort: the mark gives its reason in words and names the test that proves it (standalone: R35, R50, R111). A finding no change can close, such as Scorecard's ask for a second reviewer, stays open with its reason written down (R51 standalone). - When done, mark the task in RUN.md's table 1, `[ ]` → `[x]`, and add «Сделано (дата)» and «Открыто» to its body (RUN.md, rule 10). - Finish with a short report in Russian: what was done, which files, how to check it (commands), what the review found, what is still open. - It says how many checks in the code were switched off to prove their tests and whether each one turned its test red, and how long each fuzz test ran. @@ -100,6 +100,7 @@ Project context lives in these files, not in chat history. - **Language.** Code, every comment in code and config files (Go, TS, SQL, protobuf, YAML, HCL, Dockerfile, justfile, `.env.example`, …), `CLAUDE.md`, `README.md`, `CONTRIBUTING.md` and `SECURITY.md`, the texts of the UI, the hints the child sees and the model's prompts are in English (R02). - RUN.md and every document under `docs/` are in Russian (R25). - Reports to the author in chat are in Russian. + - A comment, and any text a person reads from code or config — a description, an error message, a test's name — gives its reason in its own words. It cites no decision, task or section of the documents: no `(R58)`, `(T11)`, `(spec 15.3)`, `(RUN.md, rule 13)`. The documents do not outlive the code. - **Children's data** (privacy 1, 4, 5). - About the child we keep only a pseudonym, an avatar from the set and the grade — never a name, photo, birth date, school or contacts. - Logs, metrics and traces never carry a direct identifier (email, name), the pseudonym, a check image, the read answer, hint text or a token; an ID in them is a pseudonymous one — a UUID or the parent's Firebase UID (spec 13, 16; privacy 4). @@ -267,7 +268,7 @@ Implementations of the same interface must be interchangeable, and that is worth - A pre-commit hook in `.githooks/`, enabled by `post-start`; `govulncheck`, `npm audit` and `gitleaks` in CI. - The justfile is the entry point for everything; `ci-*` recipes are what CI calls — same recipe names as `mentor-api` (`fmt`, `fmt-check`, `build`, `test`, `mocks`, `ci-lint`, `ci-test`, `ci-mocks-check`). - Multi-stage Dockerfile, minimal non-root runtime image pinned by digest, `CGO_ENABLED=0`, `-trimpath`. -- Services and tools that are not part of the build run from images pinned by digest instead of being installed into the devcontainer: Postgres, OpenFGA, Centrifugo and Redis for local runs and integration tests, the OpenFGA CLI for the model's tests, Playwright for the end-to-end tests, Trivy for the scan of the runtime image. The SPA's own packages are the exception, because they are part of the build: they install into `web/node_modules` from its lockfile. +- Services and tools that are not part of the build run from images pinned by digest instead of being installed into the devcontainer: Postgres, OpenFGA, Centrifugo and Redis for local runs and integration tests, the OpenFGA CLI for the model's tests, Playwright for the end-to-end tests, Trivy for the scan of the runtime image, actionlint for the workflows. The SPA's own packages are the exception, because they are part of the build: they install into `web/node_modules` from its lockfile. ## Reference copies @@ -310,7 +311,7 @@ Inside the container: - **Ports.** 8080 (the API) and 5173 (Vite's dev server for the SPA) are forwarded to the host. - **Just recipes.** `just --list` shows all of them. - The everyday ones are `just fmt`, `just test`, `just lint`, `just build` and `just run`, and for the SPA `just web-dev`, which serves it on port 5173, `just web-fmt` and `just web-test`, and for the cloud `just tf-fmt`. `just web-build` builds the SPA into `web/dist`; `just build` and the image leave it out (R10). - - The ones that must be green before a task is done are **`just ci-lint`** (formatting and lint), **`just ci-test`** (the race detector and coverage), **`just ci-mocks-check`**, **`just ci-licenses`**, **`just ci-vuln`**, **`just ci-secrets`** (gitleaks over the whole history and over what is not committed yet), **`just ci-image-smoke`** (the runtime image starts, and its `/health` names this build), **`just ci-image-scan`** (Trivy over that image, HIGH and CRITICAL with a fix out), **`just release-version`** (the version the next release takes, from `RELEASE_LINE` in `release.yml` and the tags), **`just release-artifacts`** (what a release holds: the server for amd64 and arm64, the SPA's archive and `SHA256SUMS`, in `dist/`) and **`just tf-check`** (the Terraform files formatted, the configuration valid against the providers its lock file names, and its tests on mock providers, with no credential), and they are what CI runs (`.github/workflows/ci.yml`). `ci-licenses` holds every Go module and npm package to the license list and `THIRD_PARTY_LICENSES` to the dependency graph — `just licenses` rewrites it. `ci-vuln` runs govulncheck and `npm audit` (R48). A false alarm of the secret scan is waived in `.gitleaks.toml`, by a rule, a path and the shape of a value together, and a vulnerability only in `.trivyignore`; each with its reason. + - The ones that must be green before a task is done are **`just ci-lint`** (formatting and lint), **`just ci-test`** (the race detector and coverage), **`just ci-mocks-check`**, **`just ci-licenses`**, **`just ci-vuln`**, **`just ci-secrets`** (gitleaks over the whole history and over what is not committed yet), **`just ci-image-smoke`** (the runtime image starts, and its `/health` names this build), **`just ci-image-scan`** (Trivy over that image, HIGH and CRITICAL with a fix out), **`just release-version`** (the version the next release takes, from `RELEASE_LINE` in `release.yml` and the tags), **`just release-artifacts`** (what a release holds: the server for amd64 and arm64, the SPA's archive and `SHA256SUMS`, in `dist/`), **`just tf-check`** (the Terraform files formatted, the configuration valid against the providers its lock file names, and its tests on mock providers, with no credential) and **`just ci-workflows`** (actionlint, with shellcheck over the scripts of the workflows' steps but not of the composite actions': what GitHub would refuse or misread in the workflows, the call from `release.yml` to `deploy.yml` among it), and they are what CI runs (`.github/workflows/ci.yml`). `ci-licenses` holds every Go module and npm package to the license list and `THIRD_PARTY_LICENSES` to the dependency graph — `just licenses` rewrites it. `ci-vuln` runs govulncheck and `npm audit` (R48). A false alarm of the secret scan is waived in `.gitleaks.toml`, by a rule, a path and the shape of a value together, a vulnerability only in `.trivyignore`, and a finding of actionlint only in `.github/actionlint.yaml`; each with its reason. - A change to `web/` must also pass **`just ci-web`**: Biome, the types, the tests with their coverage, and a build. - gofmt never walks into `reference/`: the copy is the sample's, read-only. - **Git hooks.** `.githooks/pre-commit` is enabled by `post-start` through `core.hooksPath`. It runs formatting, a build and the tests — enough to catch what is embarrassing, while the linter, the race detector, `govulncheck` and `gitleaks` wait for CI. Where no Go toolchain is reachable — a Git client outside the container, for instance — it says so and skips those checks instead of blocking the commit: CI runs them again and is what gates a merge. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61881d6..0c03acd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ The conventions are written down in [`CLAUDE.md`](CLAUDE.md) — it is addressed The short version: -- **The full checks are green**: `just ci-lint`, `just ci-test`, `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-image-smoke`, `just ci-image-scan`, `just release-version`, `just release-artifacts` and `just tf-check`, and `just ci-web` too when the SPA in `web/` changed. They are what the pull request checks run — in the same toolchain image, but for the two image checks, which need Docker, and the Terraform check, which the image does not carry: those run beside it — so a failure here is a failure there. +- **The full checks are green**: `just ci-lint`, `just ci-test`, `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-image-smoke`, `just ci-image-scan`, `just release-version`, `just release-artifacts`, `just tf-check` and `just ci-workflows`, and `just ci-web` too when the SPA in `web/` changed. They are what the pull request checks run — in the same toolchain image, but for the two image checks and the workflow check, which need Docker, and the Terraform check, which the image does not carry: those run beside it — so a failure here is a failure there. - **Tests come with the change.** A pull request whose changed lines are less than 90% covered cannot be merged. A new check is not considered covered until its test has been seen to fail with the check switched off. - **Nothing personal reaches a log.** Log lines, metrics and traces carry IDs, codes and timings — never an email, a name, a pseudonym, a check's picture, a read answer or the text of a hint. - **The task's answer stays hidden** until the task is solved, and every hint is checked for leaking it. diff --git a/Dockerfile b/Dockerfile index f7cfeba..8bb7b11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ EXPOSE 8080 ENTRYPOINT ["/server"] # The image a delivery rolls out: the runtime around the server a release -# signed, which the delivery has checked before it gets here (R58). The binary +# signed, which the delivery has checked before it gets here. The binary # arrives in a build context of its own, named binary and holding it alone as # server; downloaded, it has no execute bit, so it is given one here. Built # only when asked for by name, --target release. diff --git a/RUN.md b/RUN.md index fb7fe68..91d479b 100644 --- a/RUN.md +++ b/RUN.md @@ -25,7 +25,7 @@ - Так же закрываются алерты CodeQL и Scorecard во вкладке Security; `gh` в контейнере их показывает. - Когда T11 подключит CD, — ещё и через `gh` проверить, что выкладка, которая несла прошлую правку, прошла. - Это разрешённое отступление от правила 1. - - Находку анализатора сначала чинить кодом. Пометка «ложное» — крайняя мера: со ссылкой на решение в `docs/decisions.md` и с тестом, который это доказывает (standalone: R35, R50, R111). Находка, которую правка закрыть не может, остаётся открытой с записанной причиной (R51 standalone). Молча находку не пропускать. + - Находку анализатора сначала чинить кодом. Пометка «ложное» — крайняя мера: с причиной, названной словами, и с тестом, который это доказывает (standalone: R35, R50, R111). Находка, которую правка закрыть не может, остаётся открытой с записанной причиной (R51 standalone). Молча находку не пропускать. - Эти правки идут в отчёт отдельным абзацем — я коммичу их отдельно от задачи. 4. Все задачи, кроме T00–T03, выполняются только внутри devcontainer. T00–T03 шли на хосте: контейнер создаёт T03. Если сессия запущена не в нём (`MATHTRAIL_DEVCONTAINER` не равен `1`), остановиться и попросить открыть проект в контейнере. 5. Gemini, Google Cloud, Firebase и Apple Developer стоят денег. Живые прогоны, спайки и действия в облаке — только после предупреждения с ожидаемой стоимостью и моего подтверждения. @@ -36,16 +36,18 @@ - `just ci-secrets`, `just ci-image-smoke` и `just ci-image-scan` — всегда (с T06); - `just release-version` и `just release-artifacts` — всегда (с T08); - `just tf-check` — всегда (с T10); + - `just ci-workflows` — всегда (с T11); - `just ci-web` — если задача трогает `web/` (с T05); - `just load-lint` и `just load-test` — всегда; `just ci-load-smoke` — если задача трогает код сервера; `just ci-proto` — если трогает `proto/`; `just ci-integration` — если трогает хранилище, права, миграции или путь запроса. Эти рецепты приносят задачи эпика MVP (E2). Затем любая задача — с кодом или без — проходит `/code-review high`. Порядок и когда его можно пропустить — в CLAUDE.md (How we work). 8. Не коммитить. Коммит делаю я после проверки. 9. В конце — короткий отчёт по-русски: что сделано, какие файлы, как проверить (команды), что нашло ревью, что осталось открытым. Что ещё он называет — флейки, сколько проверок выключалось и сколько шёл fuzz, — сказано в CLAUDE.md (How we work). -10. После выполнения отметить задачу в таблице 1 (`[ ]` → `[x]`) и дописать в её тело «Сделано (дата)» — что сделано и чем проверено — и «Открыто». +10. После выполнения отметить задачу в таблице 1 (`[ ]` → `[x]`) и дописать в её тело «Сделано (дата)» — что сделано и чем проверено — и «Открыто». Вопрос автору из «Открыто» встаёт ещё и строкой в таблицу 3, а то, что отложено, — в таблицу 4. 11. Язык: - код, **все комментарии в коде и конфигах**, `CLAUDE.md`, `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, тексты интерфейса, подсказки ребёнку и промпты модели — строго на английском (R02); - - этот план, отчёты и все документы в `docs/` — на русском (R25). + - этот план, отчёты и все документы в `docs/` — на русском (R25); + - комментарий и любой текст из кода и конфигов, который читает человек, — описание, сообщение, имя теста — называет причину своими словами, без ссылок на решения, задачи и разделы документов: никаких `(R58)`, `(T11)`, `(spec 15.3)`. Документы не переживут код. 12. Если задача оказалась больше, чем на одну проверку (≈ 30–60 минут чтения), — до начала предложить, как её разбить. 13. Каждый мерж выкатывается в prod — единственную среду (R32, R33). Задача, чей код требует новой переменной окружения, секрета, базы или сервиса, приносит их сама — в Terraform и CD, — чтобы выкладка после мержа осталась зелёной. @@ -103,6 +105,8 @@ - **Таблица 1 — текущий эпик.** Его задачи расписаны подробно и запускаются по одной, по правилам выше. - **Таблица 2 — эпики на потом.** У каждого — результат и основа, но ещё не задачи. Порядок строк — порядок, в котором эпики берутся, пока я не решу иначе. +- **Таблица 3 — вопросы автору без ответа**, из «Открыто» задач. Мой ответ записывается туда, куда он относится, — в задачу, документ или таблицу 4, — и строка уходит. +- **Таблица 4 — технический долг:** что отложено сознательно, чем это грозит и когда вернуться. Строка уходит, когда долг закрыт. - **Следующий эпик.** Когда в таблице 1 отмечены все задачи, я выбираю следующий эпик из таблицы 2. - Вместе с Claude в режиме планирования продумываю его и дроблю на задачи. Каждая задача — на одну проверку, ≈ 30–60 минут чтения. - Формат — «Тело задачи» выше, тот же, что у задач E1. @@ -228,6 +232,36 @@ - T34 — лестница по `ladder_key` и пример движка; - T52 и T54 — задачи А и Б, Mathpix. +## Таблица 3. Вопросы автору без ответа + +Собраны 2026-10-14 из «Открыто» задач T00–T11. Открытые вопросы документов — Т- (spec 20), О- (product 15.2) и П- (privacy 12) — живут в своих разделах и решаются при разбиении E2. + +| ID | Вопрос | Откуда | Предлагаю | +|---|---|---|---| +| В1 | Включить неизменяемые релизы и правило ruleset на теги `v*`? Сейчас файлы релиза и теги можно поменять: обе настройки выключены (проверено 2026-10-14) | T08, T11 | Да. Выкладка подмену и так не выкатит — подпись привязана к коммиту, а свежесть сверяется, — но неизменяемость закрывает её в источнике. Перед включением убедиться, что `release.yml` публикует релиз уже со всеми файлами: к неизменяемому релизу их не добавить | +| В2 | Отказывать ли кнопке релиза в версии выше линии — `v1.0.0` при `RELEASE_LINE: "0.1"`? Сейчас такой релиз выходит, а «Release build» потом краснеет, пока не поднят `RELEASE_LINE` | T08 | Отказывать: линия меняется правкой `RELEASE_LINE`, а не кнопкой | +| В3 | Включить CodeQL для workflow (язык `actions`)? | T07 | Да, отдельной задачей. actionlint проверяет синтаксис и контракты вызовов, а CodeQL — путь недоверенных данных в скрипты и права заданий. Его новые находки High будут держать мерж | +| В4 | Удалять после живого прогона весь `.claude/settings.local.json` или только правила прогона? | T59 | Только правила прогона, если в файле есть и другие настройки; весь файл — если его создали ради прогона | +| В5 | Что заменит журнал решений, когда удалят `docs/decisions.md`? На него опираются правила CLAUDE.md — пометка находки анализатора ссылается на решение, журнал читают перед правкой дизайна, — а номера R стоят в RUN.md, spec, product, privacy и `docs/stack.html` | после T11 | Решить до удаления. Пометка находки может называть причину словами — так теперь пишутся комментарии в коде | + +## Таблица 4. Технический долг + +| ID | Долг | Откуда | Чем грозит, пока не закрыт | Когда вернуться | +|---|---|---|---|---| +| Д1 | Отдельный аккаунт плана на PR, только для чтения. Аккаунт Terraform остаётся только окружению `prod` | T11; решение автора 2026-10-14: «да, но не сейчас» | План на PR идёт от аккаунта Terraform: PR из ветки репозитория может поменять задание плана и сделать всё, что может этот аккаунт. Открыть такой PR может только автор | До того, как писать в репозиторий сможет кто-то ещё, или со второй средой (E3) | +| Д2 | Ревизия выходит без трафика, с тегом; smoke — по адресу тега, и только затем трафик | T11; решение автора 2026-10-14: «да, но не сейчас» | Трафик переходит до smoke, и упавший smoke оставляет новую ревизию в работе до ручного отката. Её `/health` проверен на том же образе до push, а startup probe не даёт трафик ревизии, которая не отвечает | До первых семей в бете (E2) | +| Д3 | Первый мок: поднять mockery — 3.8.0 не загружает пакеты на Go 1.27.2 — и вернуть заданию `mocks` кеш Go | T04, T06 | Ничем, пока моков нет: их рецепты ничего не делают | С первым интерфейсом в `.mockery.yaml` (E2) | +| Д4 | Туннель до планшета: `server.allowedHosts` и HMR Vite через cloudflared; туннель открывается, только если `npm audit` вместе с dev-зависимостями чист | T05 | Ничем, пока туннеля нет | Когда SPA понадобится на планшете (E2) | +| Д5 | Провайдеры `google` и `google-beta` 8.3.0 отстают от 8.7.0 (на 2026-10-13). Поднимаются руками, вместе с lock для amd64 и arm64 | T10 | Исправления провайдеров не доходят: Dependabot их не поднимает | При следующей правке Terraform | +| Д6 | `ci-toolchain-image` пишет вывод push через `tee /dev/stderr`. В `ci-image-push` это уже сделано через файл | T11 | Если stderr перенаправлен в файл, файл обрезается. В CI stderr — труба, и поломки нет | При следующей правке рецепта | +| Д7 | `imagetools inspect`, упавший по сети, `ci-toolchain-image` читает как «образ не опубликован» | T08 | Лишняя пересборка образа тулчейна | При следующей правке рецепта | +| Д8 | `release-artifacts` сверяет версию только после сборки SPA | T08 | Неверная версия отказывает поздно, после сборки | При следующей правке рецепта | +| Д9 | `cosign verify` повторён в двух рецептах: `ci-toolchain-image` и `ci-toolchain-sign` | T08 | Проверку подписи легко поправить в одном месте и забыть в другом | При следующей правке проверки подписи | +| Д10 | Задание «Toolchain image signature» идёт на каждом push `main`, даже когда подписывать нечего | T08 | Лишний короткий прогон | Если прогоны `main` начнут ждать раннеров | +| Д11 | Черновик T35 пишет, что API Agent Platform включила T10. Его включит задача, которая выключает кеш входов Gemini (privacy 5.2) | T10 | Ничем, пока черновик не стал задачей | При разбиении E2 | +| Д12 | shellcheck скриптов composite actions в `.github/actions/` — в CI. actionlint берёт у action только объявленные входы, а скрипты не читает | T11 | Ошибка в скрипте `deployment-target`, `gcloud`, `firebase` или `docker-mirror` проявится только в прогоне после мержа. Сейчас их проверяют shellcheck руками, при правке | Со следующей правкой этих action | +| Д13 | actionlint закреплён руками, 1.7.12 — последняя на 2026-10-14; Dependabot образы из `justfile` не читает. Новая версия, которая знает `queue`, снимет исключение в `.github/actionlint.yaml` | T11 | Новые проверки actionlint не приходят, а исключение может пережить свою причину | При выходе actionlint новее 1.7.12 | + --- ## Эпик E1. Настройка репозитория: CI/CD и первый деплой hello world @@ -1297,21 +1331,42 @@ Отброшено: «`ci-hosting-check` примет старую страницу, если `index.html` не изменился». Страница называет скрипт по хешу содержимого, а скрипт несёт номер релиза, поэтому одинаковых страниц у двух релизов не бывает: у v0.1.2 — `index-D48AILHJ.js`, у сборки дерева — `index-CTruYdVY.js`. Это записано в комментарий рецепта. Вопрос — автору: контракт `release.yml` ↔ `deploy.yml` до мержа не проверяется. Исправления второго круга прошли проверки, третьего ревью нет. + - **После отчёта (2026-10-14), по решению автора:** + - три вопроса ревью: отдельный аккаунт плана только для чтения и выкладка ревизии без трафика — «да, но не сейчас», они в таблице 4 (Д1, Д2); actionlint в CI — сейчас; + - **actionlint в CI:** рецепт `just ci-workflows` — actionlint 1.7.12 из образа по digest, с shellcheck 0.11.0 из того же образа — и задание «Workflows» на раннере, с `docker-mirror`. Находка `queue: max` снята в `.github/actionlint.yaml` — только эта и только для `release.yml`. Рецепт вписан в CLAUDE.md, CONTRIBUTING.md и правило 7; поправлены R50, R52 и R58, в `docs/stack.html` — строка actionlint; + - **комментарии без ссылок на план.** Журнал решений будет удалён, и ссылки на него в коде стали бы мусором. Поэтому из комментариев кода и конфигов, описаний Terraform, текста `THIRD_PARTY_LICENSES` и сообщения теста убраны номера решений и задач, разделы spec, product и privacy, вопрос Т-17 и ссылки на разделы CLAUDE.md. Где ссылка несла смысл, фраза переписана. Тест `the_service_is_sized_as_the_spec_says` переименован в `the_service_size_and_start_stay_as_chosen`. Правило записано в CLAUDE.md (Hard rules, Language) и в правило 11. Пометка находки анализатора теперь называет причину словами, а не ссылается на решение (CLAUDE.md, правило 3); + - **таблицы 3 и 4** — вопросы автору без ответа и технический долг, из «Открыто» задач T00–T11. Как их вести, говорят правило 10, «Как устроен план» и раздел Documents в CLAUDE.md; + - проверки: зелёные `ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `ci-web`, `release-version`, `release-artifacts`, `tf-check` (16 тестов), `ci-workflows`, `ci-image-smoke` и `ci-image-scan`, `just --fmt --check`; shellcheck bootstrap; + - выключенные проверки `ci-workflows` — 6, и каждая краснит рецепт: нет файла исключения; `queue: max` в другом workflow; `release.yml` не передаёт обязательный секрет; `deploy.yml` переименовал вход; передан секрет, которого `deploy.yml` не объявляет; замечание shellcheck в скрипте шага. Fuzz нет, флейков не было; + - ревью `/code-review high`: + - первый круг, 9 находок. Исправлено: + - `ci-workflows` отдавал чужому образу всё дерево, `.env` тоже, и с сетью. Теперь он получает копию `.github` и пустой `.git`, без сети; + - правило «без ссылок» нигде не было записано, а CLAUDE.md требовал от пометки находки ссылаться на решение; + - исключение actionlint не говорило, чем доказано и когда уходит; + - CLAUDE.md обещал shellcheck и для скриптов composite actions, а actionlint их не читает: формулировка поправлена, проверка в CI — долг Д12; + - в «Открыто» пропала сверка окружений и привязок Claude после шагов автора; + - `docs/stack.html` называл actionlint держащим мерж, пока «Workflows» не в ruleset. + + Отброшено: + - ссылки на spec, privacy и RUN.md убраны зря — автор велел убрать все ссылки на требования и задачи; + - описание условия роли не дойдёт до проекта, где роль уже есть: у аккаунта Terraform в prod роли `projectIamAdmin` нет (`get-iam-policy`, 2026-10-14), первый повтор bootstrap запишет новое описание. Комментарий скрипта теперь говорит, что сверяется только выражение; + - ссылка на R25 в CONTRIBUTING.md — документы в просьбу автора не входили, это вопрос В5; + - второй круг, 7 находок. Исправлено: + - ссылки на разделы CLAUDE.md в комментариях `main.go`, `.golangci.yml`, `.mockery.yaml`, `devcontainer.json` и `.gitignore`; + - новое правило в CLAUDE.md выдавало удаление журнала за решённое, а другие правила велят его вести: оговорка убрана, вопрос остаётся В5; + - исключение actionlint уйдёт, только если кто-то поднимет версию руками: долг Д13; + - рваный перенос строк там, где ссылка уходила из середины абзаца: такие абзацы перенесены заново по 80 знаков. + + Отброшено: ссылки на R в SECURITY.md и CONTRIBUTING.md — документы, это В5; описание условия роли — повтор первого круга; предложение из `scorecard.yml` о том, где отвечены находки, удалено, а не переписано, — оно указывало на журнал решений, другого места нет (В5). Исправления второго круга прошли проверки, третьего ревью нет. - **Открыто:** - - вопросы автору по ревью: - - отдельный аккаунт плана, только для чтения, вместо аккаунта Terraform на PR. Тогда PR не сможет применить конфигурацию. Сейчас это принятая цена плана: PR из ветки репозитория открывает только автор (R58, «Следствие»); - - выкладка ревизии без трафика, с тегом: smoke по адресу тега, и только затем трафик. Сейчас трафик переходит до smoke, а упавший smoke оставляет ревизию в работе до ручного отката. Её `/health` проверен на том же образе до push, а startup probe не даёт трафик неотвечающей ревизии; - - actionlint в CI. Он сверяет и контракт вызова `release.yml` → `deploy.yml` — входы и секреты, — который сейчас проверяется только после мержа: сломанный контракт оставит коммит без релиза. Ему нужно исключение для `queue: max`, которого он не знает (R52); - - живые шаги до мержа, с подтверждения автора (≈ $0): - - автор создаёт окружения `prod` и `prod-hosting` (только `main`) и секрет `TF_VAR_billing_account`; - - Claude сверяет окружения через `gh api`, повторяет bootstrap и проверяет привязки; - - задание `plan` на PR этой задачи — первое живое доказательство subject PR и условия пула; + - **до мержа, шаги автора (≈ $0):** окружения `prod` и `prod-hosting` (только `main`), секрет `TF_VAR_billing_account` и повтор bootstrap. Claude создать их не смог: auto-режим Claude Code не пускает выдачу прав без разрешения автора; + - после них Claude сверяет окружения и их правило ветки через `gh api`, а привязки аккаунта Terraform — через `get-iam-policy`: subject `:environment:prod` и `:pull_request`, без «всего репозитория», и `projectIamAdmin` под одним условием; + - задание `plan` на PR #15 упало только на пустом `TF_VAR_billing_account`. Вход в облако прошёл — по старой привязке «весь репозиторий», — и план насчитал 30 ресурсов. После секрета и повтора bootstrap задание перезапускается: это первое живое доказательство subject PR и условия пула; + - в обязательных проверках ruleset «main» нет «Release build», «Terraform» и «Workflows» (проверено 2026-10-14) — их вносит автор (R50); - мерж — первая выкладка. «Как проверить» после мержа сверяется в T12, откат и возврат — на второй выкладке (T12); - пересборка devcontainer автором: в нём появится `firebase`; - - actionlint 1.7.12 не знает `queue: max` в задании `release` (T08): это его находка, к T11 она не относится; - - `ci-toolchain-image` пишет вывод push через `tee /dev/stderr`, и если stderr перенаправлен в файл, файл обрезается. В CI stderr — труба, и проблемы нет; рецепт T08 не тронут; - - неизменяемые релизы и правило на теги — открыто с T08 (R58, «Следствие»); - - concurrency вызванного workflow держится на опыте сообщества, а не на документации. + - `tee /dev/stderr` в `ci-toolchain-image` — в таблице 4 (Д6); неизменяемые релизы и правило на теги — в таблице 3 (В1); + - concurrency вызванного workflow держится на опыте сообщества, а не на документации — сверить на первых выкладках (T12). #### T12. Первый деплой diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index c67b7d5..7765934 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -13,7 +13,7 @@ BSD-3-Clause std https://github.com/ The npm packages below are what the SPA is built from: every package its lockfile needs outside development, and every package one of those names as an optional companion, used or not. Firebase Hosting serves the SPA, so -neither the server's binary nor its image carries any of them (R10). Each +neither the server's binary nor its image carries any of them. Each line is a license, the package at the exact version in web/package-lock.json, and the page of that version in the npm registry. diff --git a/cmd/server/main.go b/cmd/server/main.go index 0e86c09..fd344e9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -80,11 +80,10 @@ func run(ctx context.Context, addr string, log io.Writer) error { return serve(ctx, newServer(), listener) } -// startup is the one line the service logs until the MVP epic brings its -// logger: which build is starting, and where it listens. Its first keys are -// the ones Cloud Logging reads a structured entry by and the logger will write -// (CLAUDE.md, "Logging"), so that a deployment's log names the release and the -// commit it runs. +// startup is the one line the service logs until it gets a logger of its +// own: which build is starting, and where it listens. Its first keys are the +// ones Cloud Logging reads a structured entry by and the logger will write, so +// that a deployment's log names the release and the commit it runs. type startup struct { Severity string `json:"severity"` Message string `json:"message"` @@ -149,7 +148,7 @@ func serve(ctx context.Context, server *http.Server, listener net.Listener) erro func routes() http.Handler { mux := http.NewServeMux() // /health rather than /healthz: Cloud Run's front end intercepts paths - // that end in z (R30). + // that end in z. mux.HandleFunc("GET /health", health) return mux } @@ -165,7 +164,7 @@ type healthBody struct { // health says the process is up and serving, and which build it is: the // delivery reads the commit here to know that the revision it rolled out is -// the one that answers (T11), so no cache on the way may answer for it. It +// the one that answers, so no cache on the way may answer for it. It // checks nothing else on purpose: a probe that reached for a dependency would // turn that dependency's outage into restarts of this service. func health(w http.ResponseWriter, _ *http.Request) { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 68d52fa..c23e8d3 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -24,7 +24,7 @@ import ( ) // The service answers its health probe and nothing else. /healthz is not the -// probe (R30), and HEAD is answered wherever GET is, since a probe may ask +// probe, and HEAD is answered wherever GET is, since a probe may ask // either way. func TestRoutes(t *testing.T) { t.Parallel() diff --git a/codecov.yml b/codecov.yml index 148cff5..6d4d6a0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,7 +15,7 @@ coverage: # New and changed lines are held to a higher standard than the average of # everything written before them: this is the number that decides whether # a change arrives with the tests that prove it, and the ruleset of main - # requires it (R50). + # requires it. patch: default: target: 90% @@ -29,7 +29,7 @@ coverage: # Code that is generated, not written, measures nothing about the tests that # come with a change: the mocks mockery writes beside the interfaces they stand -# in for, and the Go and TypeScript buf generates from proto/ (spec 18). All of +# in for, and the Go and TypeScript buf generates from proto/. All of # it is committed, and all of it would count as untested lines. A package of # helpers that tests share goes here too when one arrives: like any test, it # measures rather than being measured. diff --git a/docs/decisions.md b/docs/decisions.md index 2175a4f..4ceff7c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -286,7 +286,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо **R50. Что держит мерж, а что только сообщает; сообщающее не останавливает и релиз.** Как R38 standalone. Мерж держат правила ruleset «main», которые ставит автор (T07): -- **десять заданий CI** (R49) — все, кроме «Static analysis», задания SonarCloud, которое только сообщает (ниже), и «Toolchain image signature», которое идёт только на `main` и ничего не держит (R53). Обязательные проверки — их точные имена: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image», «Release build» (R52), «Terraform» (R56). Задание «Runtime image» — это и Trivy: уязвимости HIGH и CRITICAL, у которых есть исправление. «Toolchain image» обязательно непременно: если оно упало, остальные задания пропускаются, а пропущенное задание может не держать мерж (R49); +- **одиннадцать заданий CI** (R49) — все, кроме «Static analysis», задания SonarCloud, которое только сообщает (ниже), и «Toolchain image signature», которое идёт только на `main` и ничего не держит (R53). Обязательные проверки — их точные имена: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image», «Release build» (R52), «Terraform» (R56), «Workflows» (R58). Задание «Runtime image» — это и Trivy: уязвимости HIGH и CRITICAL, у которых есть исправление. «Toolchain image» обязательно непременно: если оно упало, остальные задания пропускаются, а пропущенное задание может не держать мерж (R49); - **`codecov/patch`** — не меньше 90 % изменённых строк покрыто тестами (`codecov.yml`); - **CodeQL** с набором `security-extended`, для Go и TypeScript. Правило «Require code scanning results»: инструмент CodeQL, Security alerts — High or higher, Alerts — Errors. Задания «Analyze Go» и «Analyze TypeScript» — тоже обязательные проверки, чтобы держал и упавший анализ, а не только его находки. @@ -354,7 +354,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - **номер своей линии — подъёмом `RELEASE_LINE`**, как в R149 standalone. Кнопка выпускает вершину `main`, у которой релиза ещё нет — например, после упавшего прогона релиза; только тогда и действует запрошенная на ней версия; - **Latest — релиз вершины `main`:** `--latest` ставится, только если `main` стоит на этом коммите. Иначе старый коммит, чей CI кончился поздно, вышел бы под бо́льшим номером и стал бы Latest; - **коммит выпускается один раз:** если на нём уже есть тег `v*`, рецепт ничего не печатает, и релиз не публикуется. Перезапуск CI на `main` снова зовёт `workflow_run` (событие `completed` приходит и на перезапуск — документация GitHub), а повтор кнопкой — тот же коммит; -- **очередь `queue: max`:** до 100 ожидающих релизов, по порядку FIFO (документация GitHub, прочитана 2026-10-14). С `queue: single` по умолчанию новый ожидающий вытеснял бы прежний, и при быстрых мержах промежуточный коммит оставался бы без релиза. `cancel-in-progress: true` вместе с `queue: max` запрещён; здесь он `false`. actionlint 1.7.12 и схема SchemaStore этого ключа ещё не знают и ругаются на него; это их отставание, GitHub ключ принимает; +- **очередь `queue: max`:** до 100 ожидающих релизов, по порядку FIFO (документация GitHub, прочитана 2026-10-14). С `queue: single` по умолчанию новый ожидающий вытеснял бы прежний, и при быстрых мержах промежуточный коммит оставался бы без релиза. `cancel-in-progress: true` вместе с `queue: max` запрещён; здесь он `false`. actionlint 1.7.12 и схема SchemaStore этого ключа ещё не знают и ругаются на него; это их отставание, GitHub ключ принимает. В задании CI «Workflows» (T11) находка actionlint снята в `.github/actionlint.yaml` — только эта и только для `release.yml`; - **номер — по SemVer строже:** номер без ведущих нулей; теги, похожие на релиз, но не `vX.Y.Z` (например, `v0.1.0-rc`), не считаются ни новейшим релизом, ни последним в линии. Номер, запрошенный ниже новейшего, тоже отказ — не только линия старше новейшего; - **без строки линия читается из `release.yml`:** `just release-version` без аргументов печатает следующий номер, поэтому рецепт стоит в списке обязательных (CLAUDE.md); - **задание CI «Release build»:** на каждом PR и коммите `main` `release-version` и `release-artifacts` идут в образе тулчейна, как их запустит релиз. Правка, ломающая сборку релиза, не мержится. Автор вносит «Release build» в обязательные проверки ruleset (R50); @@ -558,9 +558,10 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо — *Следствие:* - первая выкладка может потребовать «Re-run failed jobs»: свежие API Firebase и привязки, созданные за секунды до использования; - новая роль проекта для аккаунтов сначала попадает в список условия bootstrap, а bootstrap повторяется до мержа («Когда меняется bootstrap» в `docs/self-hosting.md`); -- аккаунт Terraform достижим из PR этого репозитория через `deploy.yml`. PR может поменять задание плана и сделать им всё, что может этот аккаунт, и правило окружения `prod` его не держит. Открыть PR из ветки репозитория может только автор. Задания CI на PR не входят в облако только благодаря условию пула: их subject тоже `:pull_request`; +- аккаунт Terraform достижим из PR этого репозитория через `deploy.yml`. PR может поменять задание плана и сделать им всё, что может этот аккаунт, и правило окружения `prod` его не держит. Открыть PR из ветки репозитория может только автор. Задания CI на PR не входят в облако только благодаря условию пула: их subject тоже `:pull_request`. Отдельный аккаунт плана, только для чтения, автор отложил (2026-10-14): он — в таблице технического долга RUN.md; +- трафик переходит на новую ревизию до smoke, и упавший smoke оставляет её в работе до отката по `docs/self-hosting.md`. До трафика её `/health` проверен на том же образе перед push, а startup probe не даёт трафик ревизии, которая не отвечает. Выкладку ревизии без трафика, со smoke по адресу её тега, автор отложил (2026-10-14): она — в той же таблице; - ассеты релиза и теги можно поменять, пока не включены неизменяемые релизы и правило на теги (открыто с T08). Подпись с коммитом и проверка свежести сводят это к тому, что `release.yml` на `main` подписал для этого коммита; -- невалидный `deploy.yml` роняет весь прогон `release.yml`, и коммит остаётся без релиза. PR с правкой `deploy.yml` запускает его на `pull_request`, и синтаксис проверяется до мержа. Контракт вызова — входы и секреты, которые передаёт `release.yml`, — до мержа проверяет только actionlint, запущенный руками: в CI его нет; +- невалидный `deploy.yml` роняет весь прогон `release.yml`, и коммит остаётся без релиза. PR с правкой `deploy.yml` запускает его на `pull_request`, и синтаксис проверяется до мержа. Контракт вызова — входы и секреты, которые передаёт `release.yml`, — до мержа проверяет задание CI «Workflows»: `just ci-workflows`, actionlint 1.7.12 из образа по digest вместе с shellcheck (решение автора 2026-10-14). Сломанный контракт краснеет на PR, а не оставляет коммит без релиза после мержа; - роли, которые конфигурация выдаёт на проект, и список условия в bootstrap держит вместе тест `terraform test`: роль, выданная без записи в условии, краснеет до мержа, а не 403 после него; - firebase и gcloud в контейнере есть только на x86_64; на arm64 команды отката — из Cloud Shell. @@ -572,7 +573,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - проверять свежесть в каждом задании и на первой попытке: очередь `cd` и порядок `main` дают то же без лишних вызовов; - `queue: max` в группе `cd`: выкладке нужна только новейшая из ожидающих. -— `.github/workflows/deploy.yml`, `.github/workflows/release.yml` (задание `deploy`), `.github/actions/{deployment-target,gcloud,firebase}/`, `justfile` (`ci-release-target`, `ci-release-current`, `ci-release-fetch`, `ci-image-push`, `ci-deploy`, `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`, `_image-health`), `Dockerfile` (стадия `release`), `firebase.json`, `infra/terraform/iam.tf`, `infra/bootstrap.sh`, `docs/self-hosting.md`; spec 15.5; R31, R33, R45, R52, R55, R56; standalone: R29, R30, R32, R33, R154. +— `.github/workflows/deploy.yml`, `.github/workflows/release.yml` (задание `deploy`), `.github/actions/{deployment-target,gcloud,firebase}/`, `justfile` (`ci-release-target`, `ci-release-current`, `ci-release-fetch`, `ci-image-push`, `ci-deploy`, `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`, `_image-health`, `ci-workflows`), `.github/workflows/ci.yml` («Workflows»), `.github/actionlint.yaml`, `Dockerfile` (стадия `release`), `firebase.json`, `infra/terraform/iam.tf`, `infra/bootstrap.sh`, `docs/self-hosting.md`; spec 15.5; R31, R33, R45, R52, R55, R56; standalone: R29, R30, R32, R33, R154. ## Идентификация и доступ diff --git a/docs/stack.html b/docs/stack.html index 1bad736..cfa6123 100644 --- a/docs/stack.html +++ b/docs/stack.html @@ -537,6 +537,7 @@

После MVP: платформа MathTrai { n: "govulncheck · npm audit", v: "govulncheck v1.8.0", r: "Уязвимости: код Go, который сервис достигает; пакеты, из которых собрана SPA, — high и выше", s: "repo", ref: "R48 · T05" }, { n: "gitleaks", v: "v8.30.1", r: "Секреты по всей истории репозитория; ложная тревога — узким исключением в .gitleaks.toml", s: "repo", ref: "R49 · T06" }, { n: "Trivy", p: "из образа по digest", v: "0.75.0", r: "Скан образа сервиса: HIGH и CRITICAL, у которых есть исправление; читает сохранённую копию образа, без сокета Docker; блокирует мерж", s: "repo", ref: "R49 · T06" }, + { n: "actionlint", p: "из образа по digest, с shellcheck", v: "1.7.12", r: "Workflow так, как их прочтёт GitHub: ключи, выражения, входы и секреты вызова release.yml → deploy.yml, скрипты шагов workflow (скрипты composite actions — нет); исключение — только в .github/actionlint.yaml. Держит мерж, когда задание «Workflows» внесено в обязательные проверки ruleset", s: "repo", ref: "R58 · T11" }, { n: "CodeQL", p: "codeql-action v4.38.3", v: "security-extended", r: "Статический анализ Go и TypeScript на каждом PR и раз в неделю; находки High и выше блокируют мерж", s: "repo", ref: "R50 · T07" }, { n: "SonarCloud", p: "+ SonarQube for IDE", v: "MathTrail_canvas-api", r: "Анализ и витрина качества; только сообщает и не останавливает релиз", s: "repo", ref: "R50 · R31 standalone · T07" }, { n: "Codecov", v: "патч ≥ 90 %", r: "Покрытие Go и web; патч блокирует мерж, общее покрытие только сообщает", s: "repo", ref: "R50 · T07" }, diff --git a/infra/bootstrap.sh b/infra/bootstrap.sh index e24598b..cee15dc 100755 --- a/infra/bootstrap.sh +++ b/infra/bootstrap.sh @@ -2,7 +2,7 @@ # Creates the few things that cannot be created by the pipeline that needs them: # the project, the bucket its state lives in, and the federated identity the # pipeline signs in as. Everything else about the deployment is Terraform, and -# Terraform runs in the pipeline (R56). +# Terraform runs in the pipeline. # # Safe to run again: every step checks before it creates, and the pool's # provider is brought back to the condition written here. What it does not undo @@ -10,7 +10,7 @@ # removed by hand (docs/self-hosting.md). It takes back two things alone: the # binding that once lent the Terraform identity to every job of the # repository, and the role of the project's policy held under any condition -# but the one written below (R58). +# but the one written below. # # It reads the deployment's facts from infra/terraform/, so there is one place # where they are written down, and prints at the end the two lines that a @@ -195,7 +195,7 @@ retry billing_hidden gcloud billing projects link "$project" --billing-account=" # this script exists rather than one more Terraform file. Observability is # here for another reason: its settings refuse requests for a while after the # API is enabled, so it is enabled long before the apply that sets where the -# traces are kept (R57). +# traces are kept. echo "==> the APIs this script and the first apply need" retry gcloud services enable \ serviceusage.googleapis.com \ @@ -222,7 +222,7 @@ ensure pool_exists make_pool # the delivery, from whichever ref it runs: no other workflow of the # repository can borrow either identity behind the pool, the release among # them, whose job builds with third-party packages while it holds an identity -# token of its own for signing. Until the delivery exists, nothing can (R56). +# token of its own for signing. Until the delivery exists, nothing can. # A provider that exists already is given the same mapping and condition # again, so that what is written here is what holds. echo "==> pool provider github" @@ -252,7 +252,7 @@ ensure identity_exists make_identity # Firebase to the project and makes the SPA's Hosting site. The two Monitoring # roles make the load alert, where a deployment names its operator. The last # two keep the logs and the traces in the deployment's region: a log bucket -# and the sink that fills it, and where traces are stored (R57). +# and the sink that fills it, and where traces are stored. # # None of them may change who holds what in the project itself; the one role # that may is granted after them, under a condition. Service accounts are the @@ -260,7 +260,7 @@ ensure identity_exists make_identity # the role that allows it allows it for every account of the project, this one # included. That is the power the bindings at the end guard: only the jobs of # the delivery's environment, and a pull request's plan, can borrow this -# identity (R56, R58). +# identity. echo "==> what it may do" for role in \ roles/serviceusage.serviceUsageAdmin \ @@ -299,7 +299,7 @@ echo " roles/billing.costsManager on the billing account" # Who holds what in the project may be changed for two roles alone: the ones # the SPA's publisher holds, which Hosting grants on the project and on nothing -# narrower (R58). Without the condition this role would make the identity the +# narrower. Without the condition this role would make the identity the # project's owner in all but name; with it, a grant or a revocation of any other # role is refused (IAM's "Set limits on granting roles", read 2026-10-14). The # condition goes in from a file, since the commas in it would split it on the @@ -309,10 +309,13 @@ echo " roles/billing.costsManager on the billing account" # # IAM tells bindings apart by their condition, so a list written anew would be # granted beside the one before it, and a role taken out of the list would stay -# grantable under the old binding. So the role is held under this condition +# grantable under the old binding. So the role is held under this expression # alone: under any other, or none, it is taken back first, and when it is held -# as written, nothing is touched. A policy read in its first version names a -# role held under a condition with a suffix, so the role is matched as a prefix. +# under this one, nothing is touched. The title and the description grant +# nothing and are not compared, so a change to them alone is not carried to a +# project that holds the role already. A policy read in its first version names +# a role held under a condition with a suffix, so the role is matched as a +# prefix. admin_role=roles/resourcemanager.projectIamAdmin expression="api.getAttribute('iam.googleapis.com/modifiedGrantsByRole', []).hasOnly(['roles/firebasehosting.admin', 'roles/serviceusage.apiKeysViewer'])" condition_file="$(mktemp)" @@ -320,7 +323,7 @@ trap 'rm -f "$condition_file"' EXIT cat > "$condition_file" < who may borrow it" project_number="$(gcloud projects describe "$project" --format='value(projectNumber)')" subject="principal://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool}/subject/${subject_prefix}" diff --git a/infra/terraform/alert.tf b/infra/terraform/alert.tf index ee6444f..ee1f86e 100644 --- a/infra/terraform/alert.tf +++ b/infra/terraform/alert.tf @@ -1,8 +1,8 @@ -# The load alert (R266 standalone). The spend alert hears of a burst only once -# Billing has counted it, hours later; this one reads the platform's own count -# of the service's requests, which costs nothing to keep, and mails the operator -# while the burst lasts and again once it is over. A deployment that names no -# operator gets neither the channel nor the alert. +# The load alert. The spend alert hears of a burst only once Billing has counted +# it, hours later; this one reads the platform's own count of the service's +# requests, which costs nothing to keep, and mails the operator while the burst +# lasts and again once it is over. A deployment that names no operator gets +# neither the channel nor the alert. # # The address is a person's, so it is no part of the public description of the # deployment: it arrives as TF_VAR_operator_email, and every plan prints it as diff --git a/infra/terraform/apis.tf b/infra/terraform/apis.tf index 0b3b823..b4f139a 100644 --- a/infra/terraform/apis.tf +++ b/infra/terraform/apis.tf @@ -3,7 +3,7 @@ # the list, because they are what enables the rest: infra/bootstrap.sh turns # them on before this configuration can be applied at all. The APIs of what # comes later — Secret Manager, the models, App Check — are turned on by the -# change that first uses them (RUN.md, rule 13). +# change that first uses them. locals { apis = [ @@ -17,13 +17,13 @@ locals { "iam.googleapis.com", "sts.googleapis.com", "iamcredentials.googleapis.com", - # Firebase in the project, and the Hosting site the SPA is served from (R10). + # Firebase in the project, and the Hosting site the SPA is served from. "firebase.googleapis.com", "firebasehosting.googleapis.com", # The spend alert, and the load alert. "billingbudgets.googleapis.com", "monitoring.googleapis.com", - # Where the logs and the traces are kept (R57). The bootstrap turns + # Where the logs and the traces are kept. The bootstrap turns # Observability on already, long before its settings are written; it is # named here as well, because this is the list of what the configuration # stands on. diff --git a/infra/terraform/budget.tf b/infra/terraform/budget.tf index 5ae0be9..3a3f67a 100644 --- a/infra/terraform/budget.tf +++ b/infra/terraform/budget.tf @@ -10,7 +10,7 @@ resource "google_billing_budget" "spend" { # Only this project's charges, and only what is actually charged: the free # allowance arrives as a credit and is subtracted before this is measured. # The allowance is the billing account's, shared with every project on it, so - # this one can be charged before it has used any (spec 14.4). + # this one can be charged before it has used any. budget_filter { projects = ["projects/${data.google_project.this.number}"] calendar_period = "MONTH" @@ -23,7 +23,7 @@ resource "google_billing_budget" "spend" { } } - # Half of it, nine tenths of it, and all of it (spec 14.4). + # Half of it, nine tenths of it, and all of it. threshold_rules { threshold_percent = 0.5 } diff --git a/infra/terraform/firebase.tf b/infra/terraform/firebase.tf index a473f65..e21e1ef 100644 --- a/infra/terraform/firebase.tf +++ b/infra/terraform/firebase.tf @@ -1,6 +1,6 @@ -# Firebase in the project, and the site the SPA is served from (R10, R55). -# Sign-in and App Check join it with the epic that brings them. Both resources -# exist in the beta provider alone (R56). +# Firebase in the project, and the site the SPA is served from. +# Sign-in and App Check join it with the change that brings them. Both resources +# exist in the beta provider alone. resource "google_firebase_project" "this" { provider = google-beta @@ -9,7 +9,7 @@ resource "google_firebase_project" "this" { } # Named after the project, which is what makes the SPA's address -# https://.web.app (R55). Either way it ends up the project's default +# https://.web.app. Either way it ends up the project's default # site: a project given Firebase after 2026-10-15 gets no site until one is # made, and the first one made becomes the default; one given Firebase before # that date got its default site at once, under the project's id, and this diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf index 8c1cd8b..3ae7d50 100644 --- a/infra/terraform/iam.tf +++ b/infra/terraform/iam.tf @@ -1,5 +1,5 @@ # The identities of the service and of its deployment, and the narrow rights -# each holds (spec 13, 15.2). +# each holds. # # The pool a deployment's tokens are exchanged in is deliberately not described # here. It is what this configuration itself is applied through, and a door @@ -11,7 +11,7 @@ # GitHub gives such a job, which names the environment. Only main may run in # either environment, and each environment's jobs reach their own identity # alone: the delivery's jobs run in prod, the Hosting job in prod-hosting -# (deploy.yml, R58). +# (deploy.yml). locals { # Everybody whose token carries one subject, ready for its environment. @@ -20,7 +20,7 @@ locals { # What the service runs as. It holds no role yet, because the service calls no # Google API of its own: each grant arrives with the code that first uses it — -# a secret it reads, the traces it sends, the model it calls (RUN.md, rule 13). +# a secret it reads, the traces it sends, the model it calls. resource "google_service_account" "runtime" { account_id = "${var.service_name}-run" display_name = "Runtime identity of the ${var.service_name} service" @@ -93,7 +93,7 @@ resource "google_service_account_iam_member" "deployer_runs_as_runtime" { # project: the ready-made Hosting role, and the reading of the project's API # keys, which the CLI's deploy asks for (Firebase's documentation, read # 2026-10-14). These two are the only roles the identity that applies this -# configuration may grant on the project (infra/bootstrap.sh, R58). +# configuration may grant on the project (infra/bootstrap.sh). resource "google_service_account" "hosting" { account_id = "${var.service_name}-hosting" display_name = "Publishing identity of the ${var.service_name} SPA" diff --git a/infra/terraform/logging.tf b/infra/terraform/logging.tf index 3fbf3b4..2360225 100644 --- a/infra/terraform/logging.tf +++ b/infra/terraform/logging.tf @@ -1,5 +1,5 @@ # Where the project's logs and traces are kept: in its region, as the rest of it -# is (R54, R57). Cloud Logging made the project's own buckets in `global`, and a +# is. Cloud Logging made the project's own buckets in `global`, and a # bucket never changes its location, so the logs go to a bucket of this region # instead, by the sink that filled the default one. The bucket `_Required` — the # audit of who changed what in the project — stays where it is: neither it nor @@ -10,7 +10,7 @@ resource "google_logging_project_bucket_config" "logs" { location = var.region bucket_id = "default-${var.region}" retention_days = var.log_retention_days - description = "Every log of the project but the audit of its changes, kept in its region (R57)." + description = "Every log of the project but the audit of its changes, kept in its region." depends_on = [google_project_service.enabled] } @@ -20,7 +20,7 @@ resource "google_logging_project_bucket_config" "logs" { # configuration takes the sink over rather than creating one, and a destroy # leaves it where it is. The bucket is in the sink's own project, so the sink # writes with no identity of its own, as the project's `_Default` does now (seen -# 2026-10-14, T10); asking for a unique one would only make an identity nobody +# 2026-10-14); asking for a unique one would only make an identity nobody # needs. resource "google_logging_project_sink" "default" { name = "_Default" diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 0a3853d..4384886 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -6,12 +6,12 @@ output "project_id" { } output "service_url" { - description = "The service's own address on run.app, the one the SPA calls (R55)." + description = "The service's own address on run.app, the one the SPA calls." value = google_cloud_run_v2_service.api.uri } output "hosting_url" { - description = "The address the SPA is served at (R55)." + description = "The address the SPA is served at." value = google_firebase_hosting_site.spa.default_url } @@ -46,6 +46,6 @@ output "hosting_service_account" { } output "log_bucket" { - description = "The bucket the project's logs are kept in, in its region (R57)." + description = "The bucket the project's logs are kept in, in its region." value = google_logging_project_bucket_config.logs.bucket_id } diff --git a/infra/terraform/prod.auto.tfvars b/infra/terraform/prod.auto.tfvars index 966a708..c07ec8b 100644 --- a/infra/terraform/prod.auto.tfvars +++ b/infra/terraform/prod.auto.tfvars @@ -1,6 +1,6 @@ # The one deployment this repository delivers to. Terraform loads the file by # itself, because of its name, and it is public like the rest of the -# repository: nothing here is a secret or names a person (spec 15.2). Changing +# repository: nothing here is a secret or names a person. Changing # where this repository deploys is a pull request, not a setting in somebody's # console. # @@ -9,8 +9,8 @@ # environment instead, as TF_VAR_billing_account and TF_VAR_operator_email. # The project is the Firebase project too, and its id names the SPA's address, -# https://mathtrail-canvas-prod.web.app (R55). Every service, the images and the -# logs live in one region (R54). +# https://mathtrail-canvas-prod.web.app. Every service, the images and the +# logs live in one region. project_id = "mathtrail-canvas-prod" region = "us-east4" @@ -29,7 +29,7 @@ workload_identity_pool_id = "canvas-api-github" github_oidc_subject_prefix = "repo:MathTrail@261630499/canvas-api@1390897704" # What a month of the project may cost before mail goes out, at 50, 90 and 100 -# percent of it (spec 14.4). The currency is the billing account's own: a -# budget in another one is refused, and infra/bootstrap.sh compares the two. +# percent of it. The currency is the billing account's own: a budget in another +# one is refused, and infra/bootstrap.sh compares the two. budget_amount = "1" budget_currency = "USD" diff --git a/infra/terraform/service_api.tf b/infra/terraform/service_api.tf index d66539e..5214011 100644 --- a/infra/terraform/service_api.tf +++ b/infra/terraform/service_api.tf @@ -1,4 +1,4 @@ -# The API service, canvas-api (spec 15.3). One container for now: the OpenFGA +# The API service, canvas-api. One container for now: the OpenFGA # sidecar joins it with the storage. Which image it serves is the deployment's # to say, not this file's. @@ -12,7 +12,7 @@ resource "google_cloud_run_v2_service" "api" { location = var.region # The SPA calls it from the families' browsers, over the public internet, at - # its own address on run.app (R55). + # its own address on run.app. ingress = "INGRESS_TRAFFIC_ALL" # Nothing here is lost with the service: the data lives elsewhere, and the @@ -21,7 +21,7 @@ resource "google_cloud_run_v2_service" "api" { deletion_protection = false # Down to nothing when nobody is asking, and up to a small ceiling, which - # bounds how fast a burst can spend the free allowance (spec 15.3). Set for + # bounds how fast a burst can spend the free allowance. Set for # the whole service: Cloud Run keeps a revision's own scaling only for the # services that had it before. scaling { @@ -44,18 +44,18 @@ resource "google_cloud_run_v2_service" "api" { } # Billed per request: an instance that is holding no request holds no - # CPU, so a request finishes its work before it answers (R23). + # CPU, so a request finishes its work before it answers. cpu_idle = true - # A starting instance gets the processor at full speed (spec 14.2). + # A starting instance gets the processor at full speed. startup_cpu_boost = true } # An instance gets its first request once its health answers. The probe - # asks every second from the start, as the sidecar's does (spec 14.2): - # with the platform's ten seconds between tries, an instance that missed - # the first one would wait ten more, against a cold start of three. Thirty - # tries leave it half a minute to come up. + # asks every second from the start, as the sidecar's does: with the + # platform's ten seconds between tries, an instance that missed the first + # one would wait ten more, against a cold start of three. Thirty tries + # leave it half a minute to come up. startup_probe { http_get { path = "/health" @@ -80,11 +80,11 @@ resource "google_cloud_run_v2_service" "api" { depends_on = [google_project_service.enabled] # The deployment owns which image is served, and this configuration owns - # everything around it (R29 standalone): without the first line the two would - # take turns. The deployment's gcloud also writes itself into the service as - # its client, which nothing here sets either. Traffic is not described at - # all: the provider computes it when no block names it, so a rollback by - # traffic is not undone by the next apply (spec 15.2). + # everything around it: without the first line the two would take turns. The + # deployment's gcloud also writes itself into the service as its client, which + # nothing here sets either. Traffic is not described at all: the provider + # computes it when no block names it, so a rollback by traffic is not undone + # by the next apply. lifecycle { ignore_changes = [ client, @@ -95,7 +95,7 @@ resource "google_cloud_run_v2_service" "api" { } # Anyone may call it: what decides whether a caller gets anything is the -# service's own checks (spec 8.1). +# service's own checks. resource "google_cloud_run_v2_service_iam_member" "public" { location = google_cloud_run_v2_service.api.location name = google_cloud_run_v2_service.api.name diff --git a/infra/terraform/tests/configuration.tftest.hcl b/infra/terraform/tests/configuration.tftest.hcl index 0be8c1e..d70e266 100644 --- a/infra/terraform/tests/configuration.tftest.hcl +++ b/infra/terraform/tests/configuration.tftest.hcl @@ -44,9 +44,10 @@ run "an_operator_gets_the_alert" { } } -# The size and the start of the service are spec 15.3's, and the runtime is -# told nine tenths of the memory. -run "the_service_is_sized_as_the_spec_says" { +# The size of the service and the way it starts are chosen numbers, and the +# runtime is told nine tenths of the memory: a change to any of them is made +# on purpose, here as well. +run "the_service_size_and_start_stay_as_chosen" { command = plan assert { @@ -93,7 +94,7 @@ run "the_budget_mails_at_half_nine_tenths_and_all" { assert { condition = [for rule in google_billing_budget.spend.threshold_rules : rule.threshold_percent] == [0.5, 0.9, 1.0] - error_message = "The spend alert mails at 50, 90 and 100 percent of the budget (spec 14.4)." + error_message = "The spend alert mails at 50, 90 and 100 percent of the budget." } } @@ -175,7 +176,7 @@ run "a_service_name_ending_in_a_hyphen_is_refused" { } # Each identity is lent to the jobs of one environment, and to nobody else: the -# delivery's to prod, the SPA's publisher to prod-hosting (R58). +# delivery's to prod, the SPA's publisher to prod-hosting. run "each_identity_is_lent_to_one_environment" { command = plan diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index a4941fb..c7ffc06 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -1,7 +1,7 @@ # What a deployment says about itself. The values of this one are in # prod.auto.tfvars, which is public; the two that are not public arrive from the # environment and are marked sensitive, so that a plan, whose output is printed -# where anyone may read it, shows neither (R34). +# where anyone may read it, shows neither. variable "project_id" { description = "The Google Cloud project that holds every resource here, and the Firebase project too. The SPA's address is named after it." @@ -14,7 +14,7 @@ variable "project_id" { } variable "region" { - description = "Where the service, its images and its logs live (R54)." + description = "Where the service, its images and its logs live." type = string } @@ -60,7 +60,7 @@ variable "workload_identity_pool_id" { } variable "github_oidc_subject_prefix" { - description = "How GitHub begins the subject of this repository's tokens: the owner and the repository, each with its numeric id, as `gh api repos/OWNER/NAME/actions/oidc/customization/sub --jq .sub_claim_prefix` prints it. A job's subject goes on to name the environment it runs in, so an identity bound to one subject is lent to the jobs of one environment alone (R58)." + description = "How GitHub begins the subject of this repository's tokens: the owner and the repository, each with its numeric id, as `gh api repos/OWNER/NAME/actions/oidc/customization/sub --jq .sub_claim_prefix` prints it. A job's subject goes on to name the environment it runs in, so an identity bound to one subject is lent to the jobs of one environment alone." type = string validation { @@ -91,7 +91,7 @@ variable "image" { } } -# The size of the service (spec 15.3). Each is a literal on one line: the load +# The size of the service. Each is a literal on one line: the load # runs read them from here, and the calibration of the MVP changes them here. variable "max_instances" { @@ -210,7 +210,7 @@ variable "image_max_age" { } variable "log_retention_days" { - description = "How many days the logs are kept: the platform's default, which the privacy policy names (privacy 4, 8)." + description = "How many days the logs are kept: the platform's default, which the privacy policy names." type = number default = 30 diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf index 70c1507..1e8211d 100644 --- a/infra/terraform/versions.tf +++ b/infra/terraform/versions.tf @@ -7,7 +7,7 @@ # Two providers at one version. Firebase's resources are in the beta provider # alone, and the two are released together, so they move together — by hand, # with the Terraform the development image installs, since Dependabot would -# lock them with a Terraform of its own (R56). +# lock them with a Terraform of its own. terraform { required_version = "1.16.3" diff --git a/justfile b/justfile index 318dfb9..97f96ad 100644 --- a/justfile +++ b/justfile @@ -13,12 +13,11 @@ TOOLCHAIN_IMAGE := "ghcr.io/mathtrail/canvas-api/toolchain" # Who signs that image: the workflow of the checks, run for a commit of main. A # run for a pull request carries the pull request's ref in its certificate, and # no run of a fork is this repository's at all, so neither signs as main. The -# issuer is GitHub's, whose short-lived tokens the certificate is given for -# (R53). +# issuer is GitHub's, whose short-lived tokens the certificate is given for. TOOLCHAIN_SIGNER := "https://" + MODULE + "/.github/workflows/ci.yml@refs/heads/main" GITHUB_ISSUER := "https://token.actions.githubusercontent.com" -# Who signs a release: the release workflow, run for a commit of main (R52). -# A delivery rolls out nothing it did not sign (R58). +# Who signs a release: the release workflow, run for a commit of main. +# A delivery rolls out nothing it did not sign. RELEASE_SIGNER := "https://" + MODULE + "/.github/workflows/release.yml@refs/heads/main" # The repository on GitHub, as gh names it. REPOSITORY := trim_start_match(MODULE, "github.com/") @@ -37,6 +36,9 @@ GO_LICENSES := "github.com/google/go-licenses/v2@v2.0.1" # The image the runtime image is scanned with, pinned by tag and digest like # every other image this repository runs. TRIVY_IMAGE := "aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa" +# The image the workflows are checked with. It carries the shellcheck that +# actionlint runs on the script of every step of a workflow. +ACTIONLINT_IMAGE := "rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667" # The image a release's signature is checked with: the cosign the release signs # with, run as SECURITY.md runs it. COSIGN_IMAGE := "ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8" @@ -51,8 +53,8 @@ RELEASE_TAG := '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' # short form of it, as long as it has to be to name one commit. HEALTH_MATCHES := '.status == "ok" and .version == $version and (.commit | type == "string" and test("^[0-9a-f]{7,40}$")) and (.commit as $short | $commit | startswith($short))' -# The deployment, as Terraform describes it (spec 15.2), and the filter what -# Terraform prints about it passes through (_tf-redact, below). +# The deployment, as Terraform describes it, and the filter what Terraform +# prints about it passes through (_tf-redact, below). TF_DIR := "infra/terraform" TF_REDACT := quote(just_executable()) + " --justfile " + quote(justfile()) + " _tf-redact" @@ -62,8 +64,7 @@ ALLOWED_LICENSES := "MIT,BSD-2-Clause,BSD-3-Clause,Apache-2.0,ISC" # The npm packages allowed a license outside that list, as name=license. Each is # a tool that builds the SPA and ends up in nothing that is shipped, and it is # allowed only while it stays a development dependency. lightningcss, under -# MPL-2.0, is the CSS minifier the SPA's bundler cannot be installed without -# (R48). +# MPL-2.0, is the CSS minifier the SPA's bundler cannot be installed without. NPM_LICENSE_EXCEPTIONS := "lightningcss=MPL-2.0" # The build identity, stamped into the binary at link time. Computed once per @@ -119,10 +120,10 @@ build: # A version asked for is the one published. Otherwise it is the next patch of # the line the releases are numbered in, MAJOR.MINOR, or the line's first when # none of it is out yet; the line is the one release.yml names, unless another -# is given (R149 standalone). Refused: a version that is not vMAJOR.MINOR.PATCH, -# one already released, and one that would come out below the newest release — -# from a line older than it, or asked for so — since each would number the -# releases out of order or twice. +# is given. Refused: a version that is not vMAJOR.MINOR.PATCH, one already +# released, and one that would come out below the newest release — from a line +# older than it, or asked for so — since each would number the releases out of +# order or twice. # # A commit is released once. When it carries a tag of the form a release takes, # v*, nothing is printed and nothing is published, so a button pressed again, @@ -185,7 +186,7 @@ release-version $line="" $requested="": # the build uses. It reaches the script through the environment, never as its # text: a version asked for is typed by a person, and the name of a tag may hold # $ and parentheses. The SPA is built first, naming the same version: Firebase -# Hosting serves it, and no binary carries it (R10). +# Hosting serves it, and no binary carries it. # Build the release artifacts into dist/: the server for every architecture, the SPA as Hosting serves it, and the sums of what was built release-artifacts $version=VERSION: (web-build version) #!/usr/bin/env bash @@ -289,7 +290,7 @@ _license-list: The npm packages below are what the SPA is built from: every package its lockfile needs outside development, and every package one of those names as an optional companion, used or not. Firebase Hosting serves the SPA, so - neither the server's binary nor its image carries any of them (R10). Each + neither the server's binary nor its image carries any of them. Each line is a license, the package at the exact version in web/package-lock.json, and the page of that version in the npm registry. @@ -340,7 +341,7 @@ web-test: web-install npm run --silent test # Vite's dev server, on the port the editor forwards from the container. An -# edited component is refreshed in place, without reloading the page (R47). +# edited component is refreshed in place, without reloading the page. # The version reaches the page as it does in web-build. # Serve the SPA on port 5173 while it is worked on, naming the version given or this tree's [working-directory('web')] @@ -515,7 +516,7 @@ _image-health tag version commit: # rather than Docker itself: handed Docker's socket, the scanner would hold the # whole machine. A finding is waived only in .trivyignore, with its reason, and # the file goes in beside the copy; one in Go's standard library is not waived -# but fixed by a newer Go (R38 standalone). +# but fixed by a newer Go. # Fail on a known HIGH or CRITICAL vulnerability, with a fix out, in the runtime image ci-image-scan tag="canvas-api:dev": (docker-build tag) #!/usr/bin/env bash @@ -534,6 +535,34 @@ ci-image-scan tag="canvas-api:dev": (docker-build tag) --exit-code 1 \ --no-progress +# actionlint runs from its own image as well. It reads the workflows the way +# GitHub will: a key GitHub does not know, an expression of the wrong type, a +# job that needs one that does not exist, an input or a secret that a calling +# workflow passes and the called one does not declare. GitHub itself checks a +# call only when it is made, and release.yml calls deploy.yml on main alone, so +# without this a broken call would surface after the merge, as a commit left +# without its release. The scripts of the composite actions under +# .github/actions are not read: actionlint takes from an action only the inputs +# it declares. A finding is waived only in .github/actionlint.yaml, with its +# reason. +# +# The image is handed a copy of .github alone, with an empty .git beside it, +# which is how actionlint knows the root of a repository, and no network: a tool +# from somebody else's image has no business with the rest of the tree, a local +# .env among it. The image runs as a user of its own, so the copy is readable +# by anyone. +# Fail on anything GitHub would refuse or misread in the workflows +ci-workflows: + #!/usr/bin/env bash + set -euo pipefail + lint=$(mktemp -d) + trap 'rm -rf "$lint"' EXIT + cp -R .github "$lint/" + mkdir "$lint/.git" + chmod -R a+rX "$lint" + docker run --rm --network none --volume "$lint:/repo:ro" --workdir /repo \ + {{ ACTIONLINT_IMAGE }} -no-color + # -- Container -------------------------------------------------------------- # The tag is a hash of the toolchain stage of the development image — every @@ -541,14 +570,14 @@ ci-image-scan tag="canvas-api:dev": (docker-build tag) # environment is one image, and any edit to it a new one, while an edit to the # stage on top, a new Claude Code say, leaves it as it was. Nothing is built # when that tag is published already. What is printed is the digest, not the -# tag: a tag can be moved, and a version here is exact (R20 standalone). +# tag: a tag can be moved, and a version here is exact. # # Whoever may publish the package may move the tag, and every run of a pull -# request of this repository may (Т-17). So a pull request takes the image the +# request of this repository may. So a pull request takes the image the # tag names — accept=published — while main takes only one it signed itself — # accept=signed — and builds any other again. An image built here is named by # the digest its own push reports, never by what the tag names afterwards: a run -# of a pull request may move the tag in between (R53). +# of a pull request may move the tag in between. # Publish the image the full checks run inside, and print its exact reference ci-toolchain-image $accept="published": #!/usr/bin/env bash @@ -607,7 +636,7 @@ ci-toolchain-image $accept="published": # Signed by its digest, since a tag can be moved between a check and a # signature, with the definition it was built from, and only once: a second signature of the same image would only # grow the list a check reads. The signature goes into the package beside the -# image, and its certificate into Sigstore's public log (R53). +# image, and its certificate into Sigstore's public log. # Sign the toolchain image, named by its digest, as main's, unless main signed it already ci-toolchain-sign image: #!/usr/bin/env bash @@ -721,7 +750,7 @@ ci-tf-plan: _tf-init # An apply waits for a lock that another apply holds rather than failing at # once. A secret that a revision reads gets its pass before this one, so that -# the revision finds a version of it (RUN.md, rule 13): there is no secret yet. +# the revision finds a version of it: there is no secret yet. # Apply the deployment's configuration (needs credentials and TF_VAR_billing_account) ci-tf-apply: _tf-init terraform -chdir={{ TF_DIR }} apply -auto-approve -input=false -no-color -lock-timeout=5m 2>&1 | {{ TF_REDACT }} @@ -764,7 +793,7 @@ _tf-redact: # A delivery rolls out a release, named by its tag: the server and the SPA that # release built and signed, with the configuration — Terraform, firebase.json, -# the Dockerfile — of the commit the tag names (R45, R58). +# the Dockerfile — of the commit the tag names. # # The button delivers the release at the tip of main, and nothing when there is # none there: it never builds a commit nobody released, and it never reaches @@ -892,7 +921,7 @@ ci-release-fetch version commit file dir: # The image of a release is the runtime around the server that release built # and signed, never one built again: what is rolled out is what was released, -# byte for byte (R58). It has to name the release before it is pushed, as the +# byte for byte. It has to name the release before it is pushed, as the # image the checks build has to name its build, and it is named afterwards by # the digest its own push reported: a tag can be moved, and a digest cannot. # The tag is the release's number, for a person reading the registry. diff --git a/web/src/App.tsx b/web/src/App.tsx index cc3963e..bc981ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ /** - * App is the SPA's placeholder page, until the epic MVP draws the sheet: the + * App is the SPA's placeholder page until the sheet replaces it: the * product's working name and the build the page came from. */ export function App({ version }: Readonly<{ version: string }>) { diff --git a/web/vite.config.ts b/web/vite.config.ts index ff7f086..84885f9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,9 +1,9 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -// The SPA, built into web/dist for Firebase Hosting to serve (R10). The plugin +// The SPA, built into web/dist for Firebase Hosting to serve. The plugin // compiles JSX and, in the dev server, refreshes an edited component in place -// rather than reloading the page (R47). It compiles with React's automatic +// rather than reloading the page. It compiles with React's automatic // runtime unless told otherwise, which is what "react-jsx" in tsconfig.json // says to the type checker and to the tests, which never load this file. It is // told nothing, so that both stay at that default: a change to how JSX is From 149b887bffa9f87c7d31ad263611ebb7b2c352f5 Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:27:44 -0500 Subject: [PATCH 4/4] Update RUN.md --- RUN.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/RUN.md b/RUN.md index 91d479b..e094ca4 100644 --- a/RUN.md +++ b/RUN.md @@ -1358,11 +1358,18 @@ - рваный перенос строк там, где ссылка уходила из середины абзаца: такие абзацы перенесены заново по 80 знаков. Отброшено: ссылки на R в SECURITY.md и CONTRIBUTING.md — документы, это В5; описание условия роли — повтор первого круга; предложение из `scorecard.yml` о том, где отвечены находки, удалено, а не переписано, — оно указывало на журнал решений, другого места нет (В5). Исправления второго круга прошли проверки, третьего ревью нет. + - **Живые шаги до мержа (2026-10-14), ≈ $0, с подтверждения автора.** + - Автор создал окружения `prod` и `prod-hosting` (только `main`) и секрет `TF_VAR_billing_account` и повторил bootstrap. Claude сделать это не смог: auto-режим Claude Code не пускает выдачу прав без разрешения автора. + - Claude сверил только чтением: + - у обоих окружений одно правило — ветка `main`; + - аккаунт Terraform одалживают только subject `:environment:prod` и `:pull_request`, привязка «весь репозиторий» снята; + - `projectIamAdmin` — одна привязка, под условием на две роли и с новым описанием; + - ролей проекта у аккаунта 13. + - Первый прогон `plan` на PR #15 упал только на пустом секрете: вход прошёл по старой привязке «весь репозиторий». + - На коммите 0538208 `plan` зелёный. Это первое живое доказательство subject PR и условия пула: старой привязки уже нет. План — `31 to add`; лишний против первого прогона — бюджет, который без ID платёжного аккаунта не планировался. ID в логе нет: `***` и `(sensitive value)`. + - Задание «Workflows» зелёное. «Release build» и «Terraform» автор внёс в обязательные проверки ruleset «main». - **Открыто:** - - **до мержа, шаги автора (≈ $0):** окружения `prod` и `prod-hosting` (только `main`), секрет `TF_VAR_billing_account` и повтор bootstrap. Claude создать их не смог: auto-режим Claude Code не пускает выдачу прав без разрешения автора; - - после них Claude сверяет окружения и их правило ветки через `gh api`, а привязки аккаунта Terraform — через `get-iam-policy`: subject `:environment:prod` и `:pull_request`, без «всего репозитория», и `projectIamAdmin` под одним условием; - - задание `plan` на PR #15 упало только на пустом `TF_VAR_billing_account`. Вход в облако прошёл — по старой привязке «весь репозиторий», — и план насчитал 30 ресурсов. После секрета и повтора bootstrap задание перезапускается: это первое живое доказательство subject PR и условия пула; - - в обязательных проверках ruleset «main» нет «Release build», «Terraform» и «Workflows» (проверено 2026-10-14) — их вносит автор (R50); + - «Workflows» — в обязательные проверки ruleset «main»: до первого прогона GitHub его не предлагал (R50); - мерж — первая выкладка. «Как проверить» после мержа сверяется в T12, откат и возврат — на второй выкладке (T12); - пересборка devcontainer автором: в нём появится `firebase`; - `tee /dev/stderr` в `ci-toolchain-image` — в таблице 4 (Д6); неизменяемые релизы и правило на теги — в таблице 3 (В1);