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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ;; \
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ go.work
go.work.sum
*.md
**/node_modules
firebase.json
.firebase
firebase-debug*.log
gha-creds-*.json
13 changes: 13 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -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'
25 changes: 25 additions & 0 deletions .github/actions/deployment-target/action.yml
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 34 additions & 0 deletions .github/actions/firebase/action.yml
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions .github/actions/gcloud/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 2 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 36 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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' }}
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
terraform:
name: Terraform
runs-on: ubuntu-24.04
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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' }}
Expand Down
Loading