nandi/frqpublic Fork 0
627b785
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

The registry it pushes to is the one it has

The image build went to GitLab CI, which was the wrong half of the
arrangement. GitLab reads source and builds nothing -- that is the point of
that file -- and it has no registry here that Modal can pull from without a
credential invented for the occasion. rickub is `origin`, it runs the suites,
and `registry.rickub.com` is already authenticated to docker before a
workflow's first step.

So the jobs move to `.rickub/workflows/web.yml`, and .gitlab-ci.yml goes back
to what it was. A separate workflow from `build.yml` rather than two more
jobs in it: that one is three suites and a few minutes on every push, this is
a Flutter SDK inside a docker build, and it only has to happen for what
actually gets served.

One credential still cannot come from the run. Modal pulls the private image
on every cold start, and a workflow's registry token is short-lived by
design, so `[container] registry_secret` names a Modal Secret holding a
rickub deploy token -- pull-only by construction -- rather than anything this
repo can mint. Making the image public removes it entirely, which
.modal/web/README.md says out loud.

A .dockerignore came with the move. `COPY . .` over a working checkout would
hand the daemon 3.3GB of .toolchain that the image fetches for itself by
sha256 a few lines later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T22:44:30-07:00 Browse files
627b785 parent: 5693bd5
added .dockerignore +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+# What `docker build` should not carry to the daemon, for the one Dockerfile
2+# in this tree (.modal/web/Dockerfile). All of it is either generated by the
3+# build that is about to happen or state belonging to the machine running it,
4+# and .toolchain alone is 3.3GB of SDK that the image fetches for itself by
5+# sha256 anyway.
6+#
7+# The same list, in the same spirit, as `[build] ignore` in
8+# .modal/dev/container.toml -- one mechanism uploads to Modal and the other
9+# to a docker daemon, and neither wants a laptop's build tree.
10+.git
11+.toolchain/
12+build/
13+flutter/build/
14+flutter/.home/
15+flutter/.dart_tool/
16+dart/*/.dart_tool/
17+nim/nimcache/
18+.cpcache/
19+.clj-kondo/
20+.lsp/
21+result
22+result-*
23+__pycache__/
24+.web-local/
new file mode 100644
@@ -0,0 +1,24 @@
1+# What `docker build` should not carry to the daemon, for the one Dockerfile
2+# in this tree (.modal/web/Dockerfile). All of it is either generated by the
3+# build that is about to happen or state belonging to the machine running it,
4+# and .toolchain alone is 3.3GB of SDK that the image fetches for itself by
5+# sha256 anyway.
6+#
7+# The same list, in the same spirit, as `[build] ignore` in
8+# .modal/dev/container.toml -- one mechanism uploads to Modal and the other
9+# to a docker daemon, and neither wants a laptop's build tree.
10+.git
11+.toolchain/
12+build/
13+flutter/build/
14+flutter/.home/
15+flutter/.dart_tool/
16+dart/*/.dart_tool/
17+nim/nimcache/
18+.cpcache/
19+.clj-kondo/
20+.lsp/
21+result
22+result-*
23+__pycache__/
24+.web-local/
modified .gitlab-ci.yml +1 -61
@@ -5,7 +5,7 @@
55 # push. There used to be a second, scheduled job that re-resolved the
66 # jolt-native flake input; there is no jolt half any more and no input to
77 # follow, so there is nothing for a schedule to do.
8-stages: [check, image, deploy]
8+stages: [check]
99
1010 # Two toolchains and one artifact between them.
1111 #
@@ -61,63 +61,3 @@ dart-test:
6161 - apt-get update -qq && apt-get install -y -qq libssl3
6262 script:
6363 - cd dart/frq_core && dart pub get && dart test -r expanded
64-
65-# The web bundle, as an image, built by CI.
66-#
67-# Kaniko rather than docker: a GitLab runner has no docker daemon to lend and
68-# this needs no privileged mode. The context is the repo and the Dockerfile
69-# is `.modal/web/Dockerfile`, whose first stage is the same pinned Flutter and
70-# Nim that `just build web` uses -- so this is not a second way to build the
71-# web target, it is the first one inside an image.
72-#
73-# `--cache=true` makes the toolchain layer a pull rather than a fetch on every
74-# push: the apt line and the COPY above it change rarely, and the gigabyte
75-# behind them is the slow half.
76-build-web-image:
77- stage: image
78- needs: [nim-test]
79- image:
80- name: gcr.io/kaniko-project/executor:v1.23.2-debug
81- entrypoint: [""]
82- script:
83- - mkdir -p /kaniko/.docker
84- - |
85- cat > /kaniko/.docker/config.json <<EOF
86- {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
87- EOF
88- - /kaniko/executor
89- --context "$CI_PROJECT_DIR"
90- --dockerfile "$CI_PROJECT_DIR/.modal/web/Dockerfile"
91- --destination "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
92- --destination "$CI_REGISTRY_IMAGE/web:latest"
93- --cache=true
94- --cache-repo "$CI_REGISTRY_IMAGE/web-cache"
95- rules:
96- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
97-
98-# ...and Modal deploys that image, without rebuilding it.
99-#
100-# `modal deploy` and not `modal run`: a run is a job that ends, and this is a
101-# URL that should still be there on the next push. The app is named by
102-# `[container] name` in `.modal/web/container.toml`, so deploying again
103-# replaces the running one rather than starting a second.
104-#
105-# Two things have to exist outside this file. MODAL_TOKEN_ID and
106-# MODAL_TOKEN_SECRET are CI variables (masked, protected); and a Modal Secret
107-# named `gitlab-registry` holds REGISTRY_USERNAME / REGISTRY_PASSWORD for a
108-# GitLab deploy token with `read_registry`, because the image above is private
109-# and Modal pulls it on every cold start rather than once here.
110-deploy-web:
111- stage: deploy
112- needs: [build-web-image]
113- image: python:3.13-slim
114- variables:
115- # The tag, not `latest`: `${FRQ_WEB_IMAGE}` in container.toml expands to
116- # this, so what is deployed is the commit that was built and no other.
117- FRQ_WEB_IMAGE: "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
118- before_script:
119- - pip install --quiet --no-cache-dir modal
120- script:
121- - modal deploy .modal/web/container.py
122- rules:
123- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
@@ -5,7 +5,7 @@
5 # push. There used to be a second, scheduled job that re-resolved the5 # push. There used to be a second, scheduled job that re-resolved the
6 # jolt-native flake input; there is no jolt half any more and no input to6 # jolt-native flake input; there is no jolt half any more and no input to
7 # follow, so there is nothing for a schedule to do.7 # follow, so there is nothing for a schedule to do.
8-stages: [check, image, deploy]8+stages: [check]
9 9
10 # Two toolchains and one artifact between them.10 # Two toolchains and one artifact between them.
11 #11 #
@@ -61,63 +61,3 @@ dart-test:
61 - apt-get update -qq && apt-get install -y -qq libssl361 - apt-get update -qq && apt-get install -y -qq libssl3
62 script:62 script:
63 - cd dart/frq_core && dart pub get && dart test -r expanded63 - cd dart/frq_core && dart pub get && dart test -r expanded
64-
65-# The web bundle, as an image, built by CI.
66-#
67-# Kaniko rather than docker: a GitLab runner has no docker daemon to lend and
68-# this needs no privileged mode. The context is the repo and the Dockerfile
69-# is `.modal/web/Dockerfile`, whose first stage is the same pinned Flutter and
70-# Nim that `just build web` uses -- so this is not a second way to build the
71-# web target, it is the first one inside an image.
72-#
73-# `--cache=true` makes the toolchain layer a pull rather than a fetch on every
74-# push: the apt line and the COPY above it change rarely, and the gigabyte
75-# behind them is the slow half.
76-build-web-image:
77- stage: image
78- needs: [nim-test]
79- image:
80- name: gcr.io/kaniko-project/executor:v1.23.2-debug
81- entrypoint: [""]
82- script:
83- - mkdir -p /kaniko/.docker
84- - |
85- cat > /kaniko/.docker/config.json <<EOF
86- {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
87- EOF
88- - /kaniko/executor
89- --context "$CI_PROJECT_DIR"
90- --dockerfile "$CI_PROJECT_DIR/.modal/web/Dockerfile"
91- --destination "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
92- --destination "$CI_REGISTRY_IMAGE/web:latest"
93- --cache=true
94- --cache-repo "$CI_REGISTRY_IMAGE/web-cache"
95- rules:
96- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
97-
98-# ...and Modal deploys that image, without rebuilding it.
99-#
100-# `modal deploy` and not `modal run`: a run is a job that ends, and this is a
101-# URL that should still be there on the next push. The app is named by
102-# `[container] name` in `.modal/web/container.toml`, so deploying again
103-# replaces the running one rather than starting a second.
104-#
105-# Two things have to exist outside this file. MODAL_TOKEN_ID and
106-# MODAL_TOKEN_SECRET are CI variables (masked, protected); and a Modal Secret
107-# named `gitlab-registry` holds REGISTRY_USERNAME / REGISTRY_PASSWORD for a
108-# GitLab deploy token with `read_registry`, because the image above is private
109-# and Modal pulls it on every cold start rather than once here.
110-deploy-web:
111- stage: deploy
112- needs: [build-web-image]
113- image: python:3.13-slim
114- variables:
115- # The tag, not `latest`: `${FRQ_WEB_IMAGE}` in container.toml expands to
116- # this, so what is deployed is the commit that was built and no other.
117- FRQ_WEB_IMAGE: "$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA"
118- before_script:
119- - pip install --quiet --no-cache-dir modal
120- script:
121- - modal deploy .modal/web/container.py
122- rules:
123- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
modified .modal/web/Dockerfile +3 -3
@@ -7,9 +7,9 @@
77 # on every cold start, is the small half.
88 #
99 # This is the one place in the tree where a build happens inside a Dockerfile
10-# rather than in `just` or a Modal sandbox. It is deliberate: CI is what has
11-# a registry to push to, and an image is what Modal can deploy without
12-# rebuilding anything. The build itself is still `tools/toolchain.sh` -- the
10+# rather than in `just` or a Modal sandbox. It is deliberate: rickub is what
11+# has a registry to push to -- and docker already authenticated to it -- while
12+# an image is what Modal can deploy without rebuilding anything. The build itself is still `tools/toolchain.sh` -- the
1313 # same pinned Flutter and Nim as `just build web` -- so nothing about the
1414 # output depends on being in a container.
1515
@@ -7,9 +7,9 @@
7 # on every cold start, is the small half.7 # on every cold start, is the small half.
8 #8 #
9 # This is the one place in the tree where a build happens inside a Dockerfile9 # This is the one place in the tree where a build happens inside a Dockerfile
10-# rather than in `just` or a Modal sandbox. It is deliberate: CI is what has10+# rather than in `just` or a Modal sandbox. It is deliberate: rickub is what
11-# a registry to push to, and an image is what Modal can deploy without11+# has a registry to push to -- and docker already authenticated to it -- while
12-# rebuilding anything. The build itself is still `tools/toolchain.sh` -- the12+# an image is what Modal can deploy without rebuilding anything. The build itself is still `tools/toolchain.sh` -- the
13 # same pinned Flutter and Nim as `just build web` -- so nothing about the13 # same pinned Flutter and Nim as `just build web` -- so nothing about the
14 # output depends on being in a container.14 # output depends on being in a container.
15 15
modified .modal/web/README.md +20 -11
@@ -1,26 +1,35 @@
11 # `web`
22
3- FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> \
3+ FRQ_WEB_IMAGE=registry.rickub.com/nandi/frq-web:<sha> \
44 modal deploy .modal/web/container.py
55
66 Defined by `container.toml`; `../_loader.py` is what reads it, and
77 its comments are the spec.
88
9-Unlike `dev`, this container builds nothing. CI builds the image --
10-`Dockerfile` here, two stages, the second one just the bundle and a
11-python -- and pushes it to the GitLab registry; this deploys that
9+Unlike `dev`, this container builds nothing. rickub builds the image
10+-- `Dockerfile` here, two stages, the second one just the bundle and
11+a python -- and pushes it to `registry.rickub.com`; this deploys that
1212 exact tag. So the thing served is the thing that was built and
13-tested, and a deploy is a pull rather than a compile.
13+tested, and a deploy is a pull rather than a compile. The workflow is
14+`.rickub/workflows/web.yml`.
1415
1516 `runtime = "web"`: a Function whose [run] command listens on the one
1617 [network] port, fronted by a stable https URL. `modal deploy` leaves
1718 it up, and deploying again replaces it in place because the app is
1819 named by `[container] name`.
1920
20-Two credentials live outside the repo:
21+Two credentials live outside the repo, both one-time setup:
2122
22-* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as protected CI variables.
23-* A Modal Secret named `gitlab-registry`, holding `REGISTRY_USERNAME`
24- and `REGISTRY_PASSWORD` -- a GitLab deploy token with
25- `read_registry`. Modal pulls the private image on every cold start,
26- not once at deploy time, so this has to be Modal's to keep.
23+* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as rickub repository
24+ secrets. The registry needs no secret of its own -- rickub
25+ authenticates docker before a workflow's first step.
26+* A Modal Secret named `rickub-registry`, holding `REGISTRY_USERNAME`
27+ and `REGISTRY_PASSWORD` for a rickub deploy token (Settings →
28+ Packages; pull-only, and the username is any label). Modal pulls the
29+ private image on every cold start rather than once at deploy time,
30+ so this has to be Modal's to keep and cannot be the run's own
31+ short-lived registry token.
32+
33+Making the image public instead -- its detail page, Manage,
34+visibility -- removes the need for that second one entirely, at the
35+price of anyone being able to pull the bundle.
@@ -1,26 +1,35 @@
1 # `web`1 # `web`
2 2
3- FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> \3+ FRQ_WEB_IMAGE=registry.rickub.com/nandi/frq-web:<sha> \
4 modal deploy .modal/web/container.py4 modal deploy .modal/web/container.py
5 5
6 Defined by `container.toml`; `../_loader.py` is what reads it, and6 Defined by `container.toml`; `../_loader.py` is what reads it, and
7 its comments are the spec.7 its comments are the spec.
8 8
9-Unlike `dev`, this container builds nothing. CI builds the image --9+Unlike `dev`, this container builds nothing. rickub builds the image
10-`Dockerfile` here, two stages, the second one just the bundle and a10+-- `Dockerfile` here, two stages, the second one just the bundle and
11-python -- and pushes it to the GitLab registry; this deploys that11+a python -- and pushes it to `registry.rickub.com`; this deploys that
12 exact tag. So the thing served is the thing that was built and12 exact tag. So the thing served is the thing that was built and
13-tested, and a deploy is a pull rather than a compile.13+tested, and a deploy is a pull rather than a compile. The workflow is
14+`.rickub/workflows/web.yml`.
14 15
15 `runtime = "web"`: a Function whose [run] command listens on the one16 `runtime = "web"`: a Function whose [run] command listens on the one
16 [network] port, fronted by a stable https URL. `modal deploy` leaves17 [network] port, fronted by a stable https URL. `modal deploy` leaves
17 it up, and deploying again replaces it in place because the app is18 it up, and deploying again replaces it in place because the app is
18 named by `[container] name`.19 named by `[container] name`.
19 20
20-Two credentials live outside the repo:21+Two credentials live outside the repo, both one-time setup:
21 22
22-* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as protected CI variables.23+* `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, as rickub repository
23-* A Modal Secret named `gitlab-registry`, holding `REGISTRY_USERNAME`24+ secrets. The registry needs no secret of its own -- rickub
24- and `REGISTRY_PASSWORD` -- a GitLab deploy token with25+ authenticates docker before a workflow's first step.
25- `read_registry`. Modal pulls the private image on every cold start,26+* A Modal Secret named `rickub-registry`, holding `REGISTRY_USERNAME`
26- not once at deploy time, so this has to be Modal's to keep.27+ and `REGISTRY_PASSWORD` for a rickub deploy token (Settings →
28+ Packages; pull-only, and the username is any label). Modal pulls the
29+ private image on every cold start rather than once at deploy time,
30+ so this has to be Modal's to keep and cannot be the run's own
31+ short-lived registry token.
32+
33+Making the image public instead -- its detail page, Manage,
34+visibility -- removes the need for that second one entirely, at the
35+price of anyone being able to pull the bundle.
modified .modal/web/container.toml +8 -4
@@ -6,10 +6,14 @@ description = "the web bundle CI built, served at a URL"
66 # spec is read, so the tag is whatever commit the deploy job is deploying.
77 # Unset, it fails loudly rather than deploying something stale.
88 registry = "${FRQ_WEB_IMAGE}"
9-# The GitLab container registry is private, so Modal needs credentials to
10-# pull. A Modal Secret of this name holding REGISTRY_USERNAME and
11-# REGISTRY_PASSWORD -- a GitLab deploy token with `read_registry` is enough.
12-registry_secret = "gitlab-registry"
9+# Images on registry.rickub.com are private by default, so Modal needs
10+# credentials to pull -- and it pulls on every cold start, not once at deploy
11+# time, so the CI job's own registry token is no use: it is short-lived by
12+# design. A Modal Secret of this name holds REGISTRY_USERNAME and
13+# REGISTRY_PASSWORD for a rickub *deploy* token, which is pull-only by
14+# construction. The username is a label of your choosing; only the token is
15+# checked.
16+registry_secret = "rickub-registry"
1317 # Not a Sandbox: a Sandbox is a run that ends, and this is meant to stay up
1418 # between pushes. `web` is a Function that Modal fronts with a stable https
1519 # URL and `modal deploy` leaves running.
@@ -6,10 +6,14 @@ description = "the web bundle CI built, served at a URL"
6 # spec is read, so the tag is whatever commit the deploy job is deploying.6 # spec is read, so the tag is whatever commit the deploy job is deploying.
7 # Unset, it fails loudly rather than deploying something stale.7 # Unset, it fails loudly rather than deploying something stale.
8 registry = "${FRQ_WEB_IMAGE}"8 registry = "${FRQ_WEB_IMAGE}"
9-# The GitLab container registry is private, so Modal needs credentials to9+# Images on registry.rickub.com are private by default, so Modal needs
10-# pull. A Modal Secret of this name holding REGISTRY_USERNAME and10+# credentials to pull -- and it pulls on every cold start, not once at deploy
11-# REGISTRY_PASSWORD -- a GitLab deploy token with `read_registry` is enough.11+# time, so the CI job's own registry token is no use: it is short-lived by
12-registry_secret = "gitlab-registry"12+# design. A Modal Secret of this name holds REGISTRY_USERNAME and
13+# REGISTRY_PASSWORD for a rickub *deploy* token, which is pull-only by
14+# construction. The username is a label of your choosing; only the token is
15+# checked.
16+registry_secret = "rickub-registry"
13 # Not a Sandbox: a Sandbox is a run that ends, and this is meant to stay up17 # Not a Sandbox: a Sandbox is a run that ends, and this is meant to stay up
14 # between pushes. `web` is a Function that Modal fronts with a stable https18 # between pushes. `web` is a Function that Modal fronts with a stable https
15 # URL and `modal deploy` leaves running.19 # URL and `modal deploy` leaves running.
modified .rickub/workflows/build.yml +6 -4
@@ -2,12 +2,14 @@
22 # deliberately builds nothing. This is the other half: three suites, each one
33 # checking a different seam.
44 #
5-# Nothing here is handed to Modal. The header used to say this job passed the
6-# work to a Sandbox against the `devshell` volume — that was the web bundle,
7-# and both the web target and the container that built it are gone. What runs
8-# now is small enough for a runner: Nim in about a second, Dart in less, and
5+# Nothing here is handed to Modal, and nothing here is built for keeps. What
6+# runs is small enough for a runner: Nim in about a second, Dart in less, and
97 # Flutter for as long as a Flutter toolchain takes to arrive.
108 #
9+# The web bundle is next door in `web.yml`, because it is the other size of
10+# thing — a Flutter SDK inside a docker build, pushed to the registry and
11+# deployed to Modal, and only for what actually gets served.
12+#
1113 # Every toolchain is a sha256-pinned tarball rather than an apt package or a
1214 # third-party setup action, which is this repo's habit — see
1315 # `tools/toolchain.sh`, which fetches the same two by the same hashes.
@@ -2,12 +2,14 @@
2 # deliberately builds nothing. This is the other half: three suites, each one2 # deliberately builds nothing. This is the other half: three suites, each one
3 # checking a different seam.3 # checking a different seam.
4 #4 #
5-# Nothing here is handed to Modal. The header used to say this job passed the5+# Nothing here is handed to Modal, and nothing here is built for keeps. What
6-# work to a Sandbox against the `devshell` volume — that was the web bundle,6+# runs is small enough for a runner: Nim in about a second, Dart in less, and
7-# and both the web target and the container that built it are gone. What runs
8-# now is small enough for a runner: Nim in about a second, Dart in less, and
9 # Flutter for as long as a Flutter toolchain takes to arrive.7 # Flutter for as long as a Flutter toolchain takes to arrive.
10 #8 #
9+# The web bundle is next door in `web.yml`, because it is the other size of
10+# thing — a Flutter SDK inside a docker build, pushed to the registry and
11+# deployed to Modal, and only for what actually gets served.
12+#
11 # Every toolchain is a sha256-pinned tarball rather than an apt package or a13 # Every toolchain is a sha256-pinned tarball rather than an apt package or a
12 # third-party setup action, which is this repo's habit — see14 # third-party setup action, which is this repo's habit — see
13 # `tools/toolchain.sh`, which fetches the same two by the same hashes.15 # `tools/toolchain.sh`, which fetches the same two by the same hashes.
added .rickub/workflows/web.yml +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# The web bundle: built into an image here, served by Modal from that image.
2+#
3+# Separate from `build.yml` because it is a different size of thing. That one
4+# is three suites and a few minutes; this is a Flutter SDK inside a docker
5+# build, and it only has to happen for what actually gets served — so it runs
6+# on the default branch and nowhere else.
7+#
8+# The split is by what each side has. rickub has a registry, and docker is
9+# already logged into it before the first step runs, so this builds and
10+# pushes. Modal has somewhere to run it, so it pulls that exact tag and
11+# compiles nothing. What ends up on the internet is the commit that was
12+# built, by construction rather than by care.
13+#
14+# See .modal/web/README.md for the two credentials this needs, and
15+# https://rickub.com/docs/container-registry for why there is no login step.
16+
17+name: web
18+
19+on:
20+ push:
21+ branches: [main]
22+ workflow_dispatch:
23+
24+jobs:
25+ image:
26+ runs-on: ubuntu-latest
27+ # Claims the image name on the first push. Without it the push is denied
28+ # for a name that does not exist yet, which reads as an auth failure.
29+ permissions:
30+ packages: write
31+ outputs:
32+ image: ${{ steps.build.outputs.image }}
33+ steps:
34+ - uses: actions/checkout@v4
35+
36+ # `$RICKUB_REGISTRY_HOST` and the token behind it are injected into the
37+ # run; docker is authenticated before this step. A `docker login` here
38+ # would be a second, worse copy of that.
39+ #
40+ # Tagged by the full commit sha and not `latest`: the deploy below
41+ # names the same string, so the thing deployed cannot drift from the
42+ # thing built. `latest` moves too, as a convenience for a human pulling
43+ # it by hand.
44+ - name: Build and push
45+ id: build
46+ run: |
47+ set -euo pipefail
48+ image="$RICKUB_REGISTRY_HOST/${{ github.repository }}-web:${{ github.sha }}"
49+ moving="$RICKUB_REGISTRY_HOST/${{ github.repository }}-web:latest"
50+ docker build -f .modal/web/Dockerfile -t "$image" -t "$moving" .
51+ docker push "$image"
52+ docker push "$moving"
53+ echo "image=$image" >> "$GITHUB_OUTPUT"
54+
55+ # ...and Modal serves it.
56+ #
57+ # `modal deploy` and not `modal run`: a run is a job that ends, and this is
58+ # a URL that should still be there on the next push. The app is named by
59+ # `[container] name` in `.modal/web/container.toml`, so deploying again
60+ # replaces the running one rather than standing a second one beside it.
61+ deploy:
62+ runs-on: ubuntu-latest
63+ needs: [image]
64+ steps:
65+ - uses: actions/checkout@v4
66+ - uses: actions/setup-python@v5
67+ with:
68+ python-version: "3.13"
69+ - run: pip install --quiet modal
70+ - name: Deploy
71+ env:
72+ MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
73+ MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
74+ # What `${FRQ_WEB_IMAGE}` in container.toml expands to. The job
75+ # above computed it; passing it forward rather than recomputing it
76+ # keeps one place saying what the tag is.
77+ FRQ_WEB_IMAGE: ${{ needs.image.outputs.image }}
78+ run: modal deploy .modal/web/container.py
new file mode 100644
@@ -0,0 +1,78 @@
1+# The web bundle: built into an image here, served by Modal from that image.
2+#
3+# Separate from `build.yml` because it is a different size of thing. That one
4+# is three suites and a few minutes; this is a Flutter SDK inside a docker
5+# build, and it only has to happen for what actually gets served — so it runs
6+# on the default branch and nowhere else.
7+#
8+# The split is by what each side has. rickub has a registry, and docker is
9+# already logged into it before the first step runs, so this builds and
10+# pushes. Modal has somewhere to run it, so it pulls that exact tag and
11+# compiles nothing. What ends up on the internet is the commit that was
12+# built, by construction rather than by care.
13+#
14+# See .modal/web/README.md for the two credentials this needs, and
15+# https://rickub.com/docs/container-registry for why there is no login step.
16+
17+name: web
18+
19+on:
20+ push:
21+ branches: [main]
22+ workflow_dispatch:
23+
24+jobs:
25+ image:
26+ runs-on: ubuntu-latest
27+ # Claims the image name on the first push. Without it the push is denied
28+ # for a name that does not exist yet, which reads as an auth failure.
29+ permissions:
30+ packages: write
31+ outputs:
32+ image: ${{ steps.build.outputs.image }}
33+ steps:
34+ - uses: actions/checkout@v4
35+
36+ # `$RICKUB_REGISTRY_HOST` and the token behind it are injected into the
37+ # run; docker is authenticated before this step. A `docker login` here
38+ # would be a second, worse copy of that.
39+ #
40+ # Tagged by the full commit sha and not `latest`: the deploy below
41+ # names the same string, so the thing deployed cannot drift from the
42+ # thing built. `latest` moves too, as a convenience for a human pulling
43+ # it by hand.
44+ - name: Build and push
45+ id: build
46+ run: |
47+ set -euo pipefail
48+ image="$RICKUB_REGISTRY_HOST/${{ github.repository }}-web:${{ github.sha }}"
49+ moving="$RICKUB_REGISTRY_HOST/${{ github.repository }}-web:latest"
50+ docker build -f .modal/web/Dockerfile -t "$image" -t "$moving" .
51+ docker push "$image"
52+ docker push "$moving"
53+ echo "image=$image" >> "$GITHUB_OUTPUT"
54+
55+ # ...and Modal serves it.
56+ #
57+ # `modal deploy` and not `modal run`: a run is a job that ends, and this is
58+ # a URL that should still be there on the next push. The app is named by
59+ # `[container] name` in `.modal/web/container.toml`, so deploying again
60+ # replaces the running one rather than standing a second one beside it.
61+ deploy:
62+ runs-on: ubuntu-latest
63+ needs: [image]
64+ steps:
65+ - uses: actions/checkout@v4
66+ - uses: actions/setup-python@v5
67+ with:
68+ python-version: "3.13"
69+ - run: pip install --quiet modal
70+ - name: Deploy
71+ env:
72+ MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
73+ MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
74+ # What `${FRQ_WEB_IMAGE}` in container.toml expands to. The job
75+ # above computed it; passing it forward rather than recomputing it
76+ # keeps one place saying what the tag is.
77+ FRQ_WEB_IMAGE: ${{ needs.image.outputs.image }}
78+ run: modal deploy .modal/web/container.py
modified CLAUDE.md +4 -3
@@ -29,9 +29,10 @@ nothing until the Sandbox starts — the image build streams to the client and
2929 nowhere else.
3030
3131 Two containers, and they are not the same kind of thing. `dev` is a build
32-that ends. `web` is a deploy: CI builds `.modal/web/Dockerfile` into the
33-GitLab registry, and `modal deploy .modal/web/container.py` serves that exact
34-tag at a URL, building nothing. So `just build web` on a laptop and the thing
32+that ends. `web` is a deploy: rickub builds `.modal/web/Dockerfile` into
33+`registry.rickub.com` (`.rickub/workflows/web.yml`), and
34+`modal deploy .modal/web/container.py` serves that exact tag at a URL,
35+building nothing. So `just build web` on a laptop and the thing
3536 on the internet come from the same two commands, run in different places —
3637 and a deploy is a pull rather than a compile.
3738
@@ -29,9 +29,10 @@ nothing until the Sandbox starts — the image build streams to the client and
29 nowhere else.29 nowhere else.
30 30
31 Two containers, and they are not the same kind of thing. `dev` is a build31 Two containers, and they are not the same kind of thing. `dev` is a build
32-that ends. `web` is a deploy: CI builds `.modal/web/Dockerfile` into the32+that ends. `web` is a deploy: rickub builds `.modal/web/Dockerfile` into
33-GitLab registry, and `modal deploy .modal/web/container.py` serves that exact33+`registry.rickub.com` (`.rickub/workflows/web.yml`), and
34-tag at a URL, building nothing. So `just build web` on a laptop and the thing34+`modal deploy .modal/web/container.py` serves that exact tag at a URL,
35+building nothing. So `just build web` on a laptop and the thing
35 on the internet come from the same two commands, run in different places —36 on the internet come from the same two commands, run in different places —
36 and a deploy is a pull rather than a compile.37 and a deploy is a pull rather than a compile.
37 38
modified justfile +5 -5
@@ -140,13 +140,13 @@ modal container="dev" *args:
140140
141141 # Deploy, rather than run: a URL that stays up between pushes.
142142 #
143-# Nothing is built here. `.modal/web/` points at an image CI already made and
144-# pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same command
145-# the `deploy-web` job runs, with the tag named by hand instead of by the
146-# commit. Normally you want the job; this is for deploying an older tag, or a
143+# Nothing is built here. `.modal/web/` points at an image rickub already made
144+# and pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same
145+# command the `web` workflow's deploy job runs, with the tag named by hand
146+# instead of by the commit. Normally you want the job; this is for deploying an older tag, or a
147147 # first deploy before CI has one.
148148 #
149-# FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> just deploy web
149+# FRQ_WEB_IMAGE=registry.rickub.com/nandi/frq-web:<sha> just deploy web
150150 [doc('deploy a .modal/ container as a URL (needs FRQ_WEB_IMAGE)')]
151151 deploy container="web":
152152 #!/usr/bin/env bash
@@ -140,13 +140,13 @@ modal container="dev" *args:
140 140
141 # Deploy, rather than run: a URL that stays up between pushes.141 # Deploy, rather than run: a URL that stays up between pushes.
142 #142 #
143-# Nothing is built here. `.modal/web/` points at an image CI already made and143+# Nothing is built here. `.modal/web/` points at an image rickub already made
144-# pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same command144+# and pushed, and FRQ_WEB_IMAGE is which tag of it — so this is the same
145-# the `deploy-web` job runs, with the tag named by hand instead of by the145+# command the `web` workflow's deploy job runs, with the tag named by hand
146-# commit. Normally you want the job; this is for deploying an older tag, or a146+# instead of by the commit. Normally you want the job; this is for deploying an older tag, or a
147 # first deploy before CI has one.147 # first deploy before CI has one.
148 #148 #
149-# FRQ_WEB_IMAGE=registry.gitlab.com/<ns>/frq/web:<sha> just deploy web149+# FRQ_WEB_IMAGE=registry.rickub.com/nandi/frq-web:<sha> just deploy web
150 [doc('deploy a .modal/ container as a URL (needs FRQ_WEB_IMAGE)')]150 [doc('deploy a .modal/ container as a URL (needs FRQ_WEB_IMAGE)')]
151 deploy container="web":151 deploy container="web":
152 #!/usr/bin/env bash152 #!/usr/bin/env bash