mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add release workflow
This commit is contained in:
parent
52bb4f08ac
commit
98727c3408
3
.github/workflows/qa-auth-realtime.yml
vendored
3
.github/workflows/qa-auth-realtime.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/qa-datagen.yml
vendored
3
.github/workflows/qa-datagen.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
.github/workflows/qa-e2e-prod.yml
vendored
23
.github/workflows/qa-e2e-prod.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/qa-prod-launcher.yml
vendored
3
.github/workflows/qa-prod-launcher.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/qa-server-core.yml
vendored
3
.github/workflows/qa-server-core.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/qa-web.yml
vendored
3
.github/workflows/qa-web.yml
vendored
|
|
@ -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
128
.github/workflows/release.yml
vendored
Normal 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 }}"
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
23
Makefile
23
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user