diff --git a/.github/workflows/qa-auth-realtime.yml b/.github/workflows/qa-auth-realtime.yml index 630251a7..f565dcb4 100644 --- a/.github/workflows/qa-auth-realtime.yml +++ b/.github/workflows/qa-auth-realtime.yml @@ -1,6 +1,9 @@ name: QA on: + # the release workflow runs every gate on a tag and publishes only + # once they all pass, so each one has to be callable. + workflow_call: push: branches: - main diff --git a/.github/workflows/qa-datagen.yml b/.github/workflows/qa-datagen.yml index 6ece2e50..2bccb715 100644 --- a/.github/workflows/qa-datagen.yml +++ b/.github/workflows/qa-datagen.yml @@ -1,5 +1,8 @@ name: QA on: + # the release workflow runs every gate on a tag and publishes only + # once they all pass, so each one has to be callable. + workflow_call: push: branches: - main diff --git a/.github/workflows/qa-e2e-prod.yml b/.github/workflows/qa-e2e-prod.yml index eef2fba4..9ad287e3 100644 --- a/.github/workflows/qa-e2e-prod.yml +++ b/.github/workflows/qa-e2e-prod.yml @@ -4,22 +4,15 @@ name: QA # instead of the dev stack's four containers, plus the trust-boundary tests # that only mean something there. # -# **It runs on tags, not on branches.** The dev suite is the one that -# guards day-to-day work; this one answers a different question — does the -# artifact we are about to publish actually serve the product, and is its -# trust boundary intact — and that question is only worth its minutes when -# something is being released. Building the image means building nuxt, -# auth-realtime and the launcher from scratch. -# -# The release workflow itself does not exist yet. When it does, this should -# become a gate it depends on rather than a second run triggered by the same -# tag. +# **It has no trigger of its own beyond a manual run.** The dev suite is +# what guards day-to-day work; this one answers a different question — does +# the artifact about to be published actually serve the product, and is its +# trust boundary intact — which is only worth its minutes when something is +# being released. release.yml calls it on a tag and refuses to publish +# unless it passes, so a tag trigger here would only run it twice. on: - push: - tags: - - "v*" - # until there is a tag to push, and for re-verifying an image without - # cutting a release, this is the only way to reach the suite from CI. + workflow_call: + # for re-verifying an image without cutting a release. workflow_dispatch: # unlike the branch workflows, a superseded run here is not stale work: a diff --git a/.github/workflows/qa-prod-launcher.yml b/.github/workflows/qa-prod-launcher.yml index 5048db72..82e22bad 100644 --- a/.github/workflows/qa-prod-launcher.yml +++ b/.github/workflows/qa-prod-launcher.yml @@ -1,6 +1,9 @@ name: QA on: + # the release workflow runs every gate on a tag and publishes only + # once they all pass, so each one has to be callable. + workflow_call: push: branches: - main diff --git a/.github/workflows/qa-server-core.yml b/.github/workflows/qa-server-core.yml index 8d71edb0..eaaf8ab3 100644 --- a/.github/workflows/qa-server-core.yml +++ b/.github/workflows/qa-server-core.yml @@ -1,5 +1,8 @@ name: QA on: + # the release workflow runs every gate on a tag and publishes only + # once they all pass, so each one has to be callable. + workflow_call: push: branches: - main diff --git a/.github/workflows/qa-web.yml b/.github/workflows/qa-web.yml index e3e3c555..d0897b79 100644 --- a/.github/workflows/qa-web.yml +++ b/.github/workflows/qa-web.yml @@ -1,6 +1,9 @@ name: QA on: + # the release workflow runs every gate on a tag and publishes only + # once they all pass, so each one has to be callable. + workflow_call: push: branches: - main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..452dd48f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +name: Release + +# publishes the all-in-one image for a tag, and only once every gate has +# passed. The gates are the QA workflows themselves, called rather than +# duplicated — a job cannot depend on a separate workflow's result, so the +# release is the one place that runs them all and holds the publish behind +# `needs`. Their path filters do not apply to a called workflow, so each one +# runs in full here regardless of what the tag touched, which is what a +# release wants. +# +# The prod e2e suite has no trigger of its own any more, so this is what +# runs it: the image cannot be published without the suite that proves it +# serves the product and keeps its internal surfaces unreachable. +# +# The dev e2e suite is deliberately not a gate here. It runs the same flow +# tests against a different assembly of the same product, and the prod suite +# already runs them against the artifact being published — so it would cost +# a full nuxt image build and a browser run to re-answer a question the gate +# beside it has already answered about the thing that actually ships. +on: + push: + tags: + - "v*" + +# a tag is published once and the answer is wanted, so a later run must +# never cancel one in flight. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + web: + uses: ./.github/workflows/qa-web.yml + + core: + uses: ./.github/workflows/qa-server-core.yml + + auth-realtime: + uses: ./.github/workflows/qa-auth-realtime.yml + + datagen: + uses: ./.github/workflows/qa-datagen.yml + + launcher: + uses: ./.github/workflows/qa-prod-launcher.yml + + e2e-prod: + uses: ./.github/workflows/qa-e2e-prod.yml + + publish: + name: Publishing the image + needs: + - web + - core + - auth-realtime + - datagen + - launcher + - e2e-prod + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + # the only write this workflow needs: pushing to the repository's own + # package registry. + packages: write + steps: + - name: Check out the code + uses: actions/checkout@v7 + with: + # goreleaser derives the version from the tag, and refuses on a + # shallow clone that has no tags in it. + fetch-depth: 0 + + # the binary baked into the image must come from goreleaser and must + # not be a snapshot: a snapshot reports itself as version dev running + # in a dev environment, which is not what gets published. + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: server/core/go.mod + cache-dependency-path: server/core/go.sum + + - name: Set up goreleaser + uses: goreleaser/goreleaser-action@v7 + with: + install-only: true + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to the container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # the image is tagged latest and the bare semver, so v1.2.3 publishes + # ghcr.io/oxynote/oxynote:1.2.3 — the leading v belongs to the git tag + # and not to the image. + - name: Resolve the version + id: version + run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + # the e2e gate just built this same image to test it, and it runs + # before this job, so its layers are read as well as this job's own — + # the nuxt, auth-realtime and fetch stages all land ahead of the first + # SENTRY ARG and are identical between the two builds. Only this + # scope is written, so the e2e cache stays the e2e gate's to manage. + # + # the DSNs are baked in at build time and are write-only ingest + # addresses. An unset secret leaves its DSN empty, which disables that + # component's reporting rather than failing the build. + - name: Build and publish + run: | + make prod-publish \ + RELEASE_VERSION="${{ steps.version.outputs.value }}" \ + PROD_BUILD_EXTRA="--cache-from type=gha,scope=prod-release \ + --cache-from type=gha,scope=e2e-prod \ + --cache-to type=gha,mode=max,scope=prod-release \ + --build-arg SENTRY_WEB_DSN=${{ secrets.SENTRY_WEB_DSN }} \ + --build-arg SENTRY_CORE_DSN=${{ secrets.SENTRY_CORE_DSN }} \ + --build-arg SENTRY_AUTH_REALTIME_DSN=${{ secrets.SENTRY_AUTH_REALTIME_DSN }} \ + --build-arg SENTRY_LAUNCHER_DSN=${{ secrets.SENTRY_LAUNCHER_DSN }}" diff --git a/CLAUDE.md b/CLAUDE.md index a7a05ff0..4a2d7be6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,8 @@ make check-lint # the same gates, verification only make prod-build # goreleaser core binary + the all-in-one prod image make prod-run # run the example prod compose against the local image make prod-stop # stop it +# release-only, run by .github/workflows/release.yml on a tag: +# make prod-publish RELEASE_VERSION=1.2.3 -> pushes :latest and :1.2.3 make e2e-dev # one-shot: build, run playwright, tear the dev stack down make e2e-dev-stack-build # build that stack's images, then iterate with `pnpm test:dev` diff --git a/Makefile b/Makefile index b85e07ce..c04ef1ac 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ E2E_PROD_COMPOSE := docker compose -f e2e/docker-compose.prod.yaml CORE_IMAGE_TAG ?= dev PROD_IMAGE_TAG ?= prod +# the version a published image is tagged with, without a leading v. Set +# only by the release workflow; prod-publish refuses to run without it. +RELEASE_VERSION ?= + QUIET := scripts/run-quietly.sh # backend containers + web dev server on the host with hot reload. Ctrl-c @@ -139,11 +143,28 @@ build-go: # `docker build` is not supported. .PHONY: prod-build prod-build: - @$(QUIET) "building the core binary" sh -c 'cd server/core && make build' + @$(QUIET) "building the core binary" sh -c 'cd server/core && make build-binary-snapshot' mkdir -p docker/prod/.build cp server/core/bin/oxynote-core docker/prod/.build/oxynote-core docker build --platform linux/amd64 $(PROD_BUILD_EXTRA) -f docker/prod/Dockerfile -t ghcr.io/oxynote/oxynote:$(PROD_IMAGE_TAG) . +# the published image: the same Dockerfile as prod-build, but core's binary +# is built from the tag instead of as a snapshot — a snapshot would report +# itself as version dev in a dev environment — and the result is pushed as +# latest and the version rather than loaded locally. These are the only two +# tags the registry gets. +.PHONY: prod-publish +prod-publish: + @test -n "$(RELEASE_VERSION)" || { echo "RELEASE_VERSION is required, e.g. 1.2.3"; exit 1; } + @$(QUIET) "building the core binary" sh -c 'cd server/core && make build-binary' + mkdir -p docker/prod/.build + cp server/core/bin/oxynote-core docker/prod/.build/oxynote-core + docker buildx build --platform linux/amd64 $(PROD_BUILD_EXTRA) \ + -f docker/prod/Dockerfile \ + -t ghcr.io/oxynote/oxynote:latest \ + -t ghcr.io/oxynote/oxynote:$(RELEASE_VERSION) \ + --push . + # the example compose against the locally built image .PHONY: prod-run prod-run: diff --git a/docker/prod/CLAUDE.md b/docker/prod/CLAUDE.md index 4a8087c7..aa1bed9d 100644 --- a/docker/prod/CLAUDE.md +++ b/docker/prod/CLAUDE.md @@ -24,8 +24,19 @@ expects it. A bare `docker build` fails on that COPY by design. the trust-boundary tests that only mean something here. It is the only thing that exercises this image as an operator installs it — the dev stack shares none of its assembly — so run it locally for a change to the Dockerfile, the -Caddyfile or the launcher. CI only runs it on a tag, so a change here gets no -pre-merge signal from it. Details in [e2e/CLAUDE.md](../../e2e/CLAUDE.md). +Caddyfile or the launcher. CI runs it only as a release gate, so a change +here gets no pre-merge signal from it. Details in +[e2e/CLAUDE.md](../../e2e/CLAUDE.md). + +**`make prod-publish` is the release build**, run by +[release.yml](../../.github/workflows/release.yml) on a tag and by nothing +else. It differs from `prod-build` in two ways that matter: core's binary +comes from `goreleaser build` rather than a snapshot, so it reports the tag +as its version and `production` as its environment instead of `dev`; and the +result is pushed to `ghcr.io/oxynote/oxynote` as `latest` and the bare +semver — `v1.2.3` publishes `:1.2.3`. Those two tags are the only things the +registry gets. `goreleaser build` is deliberate: `goreleaser release` would +also publish `ghcr.io/oxynote/core` from the `dockers_v2` block. ## Invariants diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index 14cb1880..65248327 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -610,26 +610,36 @@ alone reuses the whole image instead of rebuilding it. **The prod suite is [.github/workflows/qa-e2e-prod.yml](../.github/workflows/qa-e2e-prod.yml), -and it runs on tags, not on branches.** No push and no pull request -triggers it; `workflow_dispatch` is there so the suite is reachable before -the first tag exists and for re-verifying an image without cutting a -release. The dev suite is what guards day-to-day work, and this one answers -a different question — does the artifact about to be published serve the -product, and is its trust boundary intact — which is only worth its minutes -when something is being released, because building the image means building -nuxt, auth-realtime and the launcher from scratch. -`cancel-in-progress` is **off** here, the opposite of every branch -workflow: a superseded branch run is answering about a commit that is no -longer the branch, but a tag is verified once and its answer is wanted. +and it has no trigger of its own.** No push, no pull request, no tag: +[release.yml](../.github/workflows/release.yml) calls it, and refuses to +publish the image unless it passes. `workflow_dispatch` remains for +re-verifying an image without cutting a release. Where the dev suite guards +day-to-day work, the prod suite answers a different question — does the +artifact about to be published serve the product, and is its trust boundary +intact — which is worth its minutes exactly when something is being +released. Its cache reaches the build through `PROD_BUILD_EXTRA` rather +than a compose override, because compose builds nothing in the prod stack: +the image comes from `docker build` in the Makefile. That is also why +[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) carries `dev` in +its name with no prod counterpart to pair with. -The release workflow does not exist yet. When it does, this should become a -gate it depends on rather than a second run triggered by the same tag. +**Being a release gate is what `workflow_call` is for.** A job cannot +depend on a separate workflow's result, so release.yml is the one place +that runs the gates and holds the publish behind `needs` — and every +workflow it calls declares `workflow_call` alongside its own triggers. Path +filters do not apply to a called workflow, so each one runs in full on a +tag regardless of what that tag touched, which is what a release wants. +Nothing is reused from an earlier run of the same commit either: a called +workflow always executes, so a tag re-runs every gate against the tagged +tree. -Its cache reaches the build through `PROD_BUILD_EXTRA` rather than a -compose override, because compose builds nothing in the prod stack — the -image comes from `docker build` in the Makefile. That is also why -[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) carries the `dev` -in its name with no prod counterpart to pair with. +**The dev suite is deliberately not a gate**, and `qa-e2e-dev.yml` is +therefore the one QA workflow that does not declare `workflow_call`. It +runs the same flow tests as the prod suite, against a different assembly of +the same product, while the prod suite runs them against the artifact +actually being published — so gating on both would spend a nuxt image build +and a browser run re-answering a question already answered about the thing +that ships. ## Not covered yet diff --git a/server/core/Makefile b/server/core/Makefile index eb877c6e..bee6eebf 100644 --- a/server/core/Makefile +++ b/server/core/Makefile @@ -15,6 +15,23 @@ build: CORE_SNAPSHOT_TAG=$(IMAGE_TAG) goreleaser release --snapshot --clean docker tag ghcr.io/oxynote/core:$(IMAGE_TAG)-amd64 ghcr.io/oxynote/core:$(IMAGE_TAG) +# The binary on its own, for the all-in-one image: its Dockerfile stages the +# binary and builds everything else itself, so core's own docker image is +# waste on this path. `goreleaser build` compiles and stops there, where +# `release` above also runs the dockers_v2 block — which on a real tag would +# publish ghcr.io/oxynote/core alongside the image being released. +# +# The snapshot variant is for local and e2e builds, where there may be no +# tag and the version does not matter. A published image must use the other +# one: a snapshot stamps the binary as version dev in a dev environment. +.PHONY: build-binary +build-binary: + goreleaser build --clean + +.PHONY: build-binary-snapshot +build-binary-snapshot: + goreleaser build --snapshot --clean + # Run all tests with the race detector. .PHONY: test test: