add release workflow

This commit is contained in:
swithek 2026-08-30 12:00:39 +02:00
parent 52bb4f08ac
commit 98727c3408
12 changed files with 233 additions and 36 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

128
.github/workflows/release.yml vendored Normal file
View File

@ -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 }}"

View File

@ -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`

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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: