CI: build and publish the container image to GHCR
publish image / image (push) Canceled after 0s

The Dockerfile's builder stage already runs the unit suite, so the workflow
deliberately has no separate test job -- a red test cannot produce an image.

After pushing, the published artifact is smoke-tested by digest: `--version`
covers a runtime stage missing a shared library, and `--check` against a
throwaway credentials file covers the config baked into the image. Both were
failure modes a green build would not have caught. The `--check` invocation is
verified locally against docker/openvpngate.conf.

`latest` follows the newest v* tag rather than the branch head; master head is
published as `master`. Registry paths are lowercased explicitly rather than
relying on metadata-action, since the same value is reused for the smoke test.

GHCR_TOKEN / GHCR_USER / GHCR_IMAGE override the built-ins so this still works
from a mirror or a Gitea/Forgejo runner, where the ambient token authenticates
to the wrong registry. On github.com none of them need to be set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iceBear67
2026-07-28 06:04:51 +00:00
co-authored by Claude Opus 5
parent f37cd0a125
commit 40b47a8f66
5 changed files with 220 additions and 2 deletions
+4
View File
@@ -11,6 +11,10 @@ build*/
.gitignore .gitignore
.dockerignore .dockerignore
# CI definitions cannot affect the build, and leaving them in means editing a
# workflow invalidates the COPY layer and recompiles openvpn3 for nothing.
.github/
# Runtime state. The node cache and the outcome history belong to whichever # Runtime state. The node cache and the outcome history belong to whichever
# machine produced them; the container gets its own in a volume. # machine produced them; the container gets its own in a volume.
var/ var/
+149
View File
@@ -0,0 +1,149 @@
# Build the container image and publish it to GitHub Container Registry.
#
# The unit test suite runs *inside* the image build (the Dockerfile's builder
# stage ends with ./build/tests/ovg_tests), so a failing test fails the publish.
# There is deliberately no separate test job duplicating that.
#
# Tags produced:
# push to master -> master, sha-<short>
# push tag v1.2.3 -> 1.2.3, 1.2, latest, sha-<short>
# pull request -> built and smoke-tested, never pushed
#
# `latest` follows the newest release tag, not the branch head. Until the first
# v* tag exists, the tag to pull is `master`.
name: publish image
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Superseding a PR build is free; killing a release build half way through is
# not.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
REGISTRY: ghcr.io
jobs:
image:
runs-on: ubuntu-latest
# A cold build compiles the openvpn3 core and lwIP from source. Configure +
# compile alone measured 1m39s on 4 cores (same core count as a
# GitHub-hosted runner); on top of that the image build also does an apt
# install, two git clones, and the test suite. The ceiling is set well above
# any of that because the failure mode it guards against -- a cache miss on
# a runner that is also being slow -- is the one where a tight limit turns a
# slow build into a red one.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
# Registry paths must be lowercase and github.repository is not
# guaranteed to be. GHCR_IMAGE overrides the whole owner/name, which is
# what you need when this repository does not live on github.com -- see
# the login step.
- name: Resolve image name
id: img
run: |
printf 'name=%s\n' \
"$(printf '%s' "${{ vars.GHCR_IMAGE || github.repository }}" | tr '[:upper:]' '[:lower:]')" \
>> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
# Skipped on pull requests: a PR from a fork has no write credentials, and
# nothing is pushed from a PR anyway.
#
# On github.com the built-in GITHUB_TOKEN is enough. Running this from a
# mirror or a self-hosted Actions runner (Gitea/Forgejo) means that token
# authenticates to the wrong registry, so set GHCR_TOKEN to a GitHub PAT
# with write:packages, and GHCR_USER/GHCR_IMAGE if the account name there
# differs from the one here.
- name: Log in to ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ vars.GHCR_USER || github.actor }}
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
- name: Derive tags and labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
# The default flavor (latest=auto) adds `latest` on a semver tag push
# and nowhere else, which is the intent stated at the top of the file.
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
# amd64 only, matching the project's stated target. arm64 is not known
# to be broken -- it is untested, and cross-building the openvpn3 core
# under QEMU costs the better part of an hour per run. Enabling it
# means adding docker/setup-qemu-action above and verifying lwIP's
# unaligned-access assumptions, not just editing this line.
platforms: linux/amd64
build-args: |
OVG_WITH_TUNNEL=ON
OVG_RUN_TESTS=1
# Without this every run recompiles openvpn3 from scratch.
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: mode=max
sbom: true
# Verifies the artifact that was actually published, by digest rather than
# by tag. Cheap, and it covers the two things a green build still would
# not: that the runtime stage carries the shared libraries the binary
# needs, and that the config baked into the image parses.
#
# --check needs the credentials file, which is deliberately not in the
# image; a throwaway one is enough to get the config validated.
- name: Smoke test the published image
if: github.event_name != 'pull_request'
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}@${{ steps.build.outputs.digest }}
run: |
set -eux
docker run --rm "$IMAGE" --version
printf 'ci:changeme\n' > "$RUNNER_TEMP/socks5.auth"
docker run --rm \
-v "$RUNNER_TEMP/socks5.auth:/etc/openvpngate/socks5.auth:ro" \
"$IMAGE" -c /etc/openvpngate/openvpngate.conf --check
- name: Summary
if: github.event_name != 'pull_request'
run: |
{
echo "### Published"
echo
echo '```'
echo "${{ steps.meta.outputs.tags }}"
echo '```'
echo
echo "digest: \`${{ steps.build.outputs.digest }}\`"
} >> "$GITHUB_STEP_SUMMARY"
+4
View File
@@ -136,6 +136,10 @@ image a different VPN client every week; and `docker/openvpngate.conf` overrides
defaults that are silently wrong in a container — loopback listen addresses (a published port then defaults that are silently wrong in a container — loopback listen addresses (a published port then
reaches nothing) and relative state paths (the node history dies with the container). reaches nothing) and relative state paths (the node history dies with the container).
`.github/workflows/publish-image.yml` publishes that image to GHCR. It has no test job on purpose —
the builder stage runs `ovg_tests`, so a red test cannot produce an image. If you ever make the
Dockerfile skip the suite by default, CI stops testing anything and nothing will say so.
`etc/openvpngate.conf` documents every key with its default and the reasoning. Admin HTTP (default `etc/openvpngate.conf` documents every key with its default and the reasoning. Admin HTTP (default
`127.0.0.1:9080`, **no auth**) exposes `/status /nodes /sessions /health /metrics /healthz` and `127.0.0.1:9080`, **no auth**) exposes `/status /nodes /sessions /health /metrics /healthz` and
`POST /switch`; `/nodes` explains each node's score, which is the fastest way to understand a `POST /switch`; `/nodes` explains each node's score, which is the fastest way to understand a
+1 -1
View File
@@ -18,7 +18,7 @@ SOCKS5 客户端 ──► socks5::Server ──► egress::Egress ──► lwI
| [docs/FEASIBILITY.md](docs/FEASIBILITY.md) | 动手前的可行性结论。**需求中唯一不可能的部分在 §1**;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 | | [docs/FEASIBILITY.md](docs/FEASIBILITY.md) | 动手前的可行性结论。**需求中唯一不可能的部分在 §1**;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 |
| [etc/openvpngate.conf](etc/openvpngate.conf) | 全部配置项,每一项都带默认值和「为什么是这个默认值」 | | [etc/openvpngate.conf](etc/openvpngate.conf) | 全部配置项,每一项都带默认值和「为什么是这个默认值」 |
| [docs/DOCKER.md](docs/DOCKER.md) | 容器部署。**容器里有三处宿主机默认值是错的**(§3);`cap_drop: ALL` 为什么能成立(§5 | | [docs/DOCKER.md](docs/DOCKER.md) | 容器部署。**容器里有三处宿主机默认值是错的**(§3);`cap_drop: ALL` 为什么能成立(§5GHCR 发布与 CI(§9 |
--- ---
+62 -1
View File
@@ -38,6 +38,8 @@ curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me
`socks5h` 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);`socks5` 是本地解析后只把 `socks5h` 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);`socks5` 是本地解析后只把
IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。 IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。
不想在本机编译 openvpn3 的话,[§9](#9-ci-与-ghcr-发布) 说了怎么改成拉现成镜像。
--- ---
## 2. 镜像里有什么 ## 2. 镜像里有什么
@@ -212,7 +214,11 @@ uid 写死成 10001 而不是让发行版随便分配,就是为了这条命令
`sha256$salt$hash`,起 direct 模式代理,正确口令拿到 200,错误口令和不存在的用户都被 `sha256$salt$hash`,起 direct 模式代理,正确口令拿到 200,错误口令和不存在的用户都被
`auth rejected` 挡下; `auth rejected` 挡下;
- 管理路由清单来自 `src/app/admin_server.cpp` - 管理路由清单来自 `src/app/admin_server.cpp`
- `docker-compose.yml` 的 YAML 结构解析通过(挂载目标、profiles、tmpfs、healthcheck 覆盖)。 - **`docker/openvpngate.conf` 本身是实测能解析的**:拿它配一个临时凭据文件跑
`openvpngate -c docker/openvpngate.conf --check`,输出 `config ok` 且
`socks5 0.0.0.0:1080 auth=required users=1`——即 §3 那三处覆盖确实生效了。CI 里对镜像跑的
就是同一条命令;
- `docker-compose.yml` 与 `.github/workflows/publish-image.yml` 的 YAML 结构解析通过。
**没有验证** **没有验证**
@@ -222,6 +228,8 @@ uid 写死成 10001 而不是让发行版随便分配,就是为了这条命令
- `create_host_path: false` 的报错行为; - `create_host_path: false` 的报错行为;
- 容器里 `CapEff` 是否真是全零(按 Docker 语义应当如此,但没实跑)。 - 容器里 `CapEff` 是否真是全零(按 Docker 语义应当如此,但没实跑)。
上面前两条会在 CI 第一次跑通时自动补上(§9 的 smoke 步骤),剩下三条只能在真机上验。
接手后按顺序跑一遍就能补齐: 接手后按顺序跑一遍就能补齐:
```sh ```sh
@@ -235,3 +243,56 @@ docker compose stop # 应在一秒内退出,不是等
最后一条尤其值得看:本项目已经被「优雅退出日志打得漂漂亮亮然后永远不退出」这类 bug 咬过 最后一条尤其值得看:本项目已经被「优雅退出日志打得漂漂亮亮然后永远不退出」这类 bug 咬过
一次(README §5 末尾那段),`docker stop` 卡满宽限期然后被 SIGKILL,就是它在容器里的样子。 一次(README §5 末尾那段),`docker stop` 卡满宽限期然后被 SIGKILL,就是它在容器里的样子。
---
## 9. CI 与 GHCR 发布
`.github/workflows/publish-image.yml` 构建镜像并推到 GitHub Container Registry。
**它没有单独的测试 job,这是故意的**:Dockerfile 的构建阶段最后一步就是跑完整单元测试
`OVG_RUN_TESTS=1`),测试挂了镜像就构建不出来,也就发布不了。再加一个跑同一套测试的 job
只是把同样的编译做第二遍。
推完之后还有一步 smoke:按 **digest**(不是 tag)把刚发布的那个镜像拉回来,跑
`--version`,再挂一个临时凭据文件跑 `--check`。这两下覆盖的正是「构建绿了也不代表能跑」的
两件事——运行时阶段有没有漏装共享库,以及镜像里烤进去的那份配置能不能解析。
产出的 tag
| 触发 | tag |
|---|---|
| push 到 `master` | `master`、`sha-<短 hash>` |
| push tag `v1.2.3` | `1.2.3`、`1.2`、`latest`、`sha-<短 hash>` |
| pull request | 照常构建并跑测试,**不推** |
`latest` 跟的是最新的 release tag,不是分支头。在打出第一个 `v*` 之前,要拉的是 `master`。
拉现成镜像而不是本地编译,把 `docker-compose.yml` 里的 `build:` 整块删掉,`image:` 改成:
```yaml
image: ghcr.io/<owner>/<repo>:master
```
`docker compose pull && docker compose up -d` 即可。注意 `docker/openvpngate.conf` 和
`docker/socks5.auth` 仍然要从本仓库挂进去(§3),镜像里那份只是兜底默认值。
### 这个仓库的 remote 不是 GitHub
`origin` 指向 `git.sfclub.cc`。`.github/workflows/` 只有在下面两种情况下会跑:
1. **镜像到 github.com**:什么都不用配。内置的 `GITHUB_TOKEN` 配合 workflow 里的
`permissions: packages: write` 就能推 ghcr.io,包会挂在镜像仓库名下。
2. **在 Gitea/Forgejo Actions 上跑**:它们认这个 workflow 语法,但它们发的 `GITHUB_TOKEN`
认证的是**自己那个 registry**,推不了 ghcr.io。需要在仓库里配:
| 名字 | 类型 | 值 |
|---|---|---|
| `GHCR_TOKEN` | secret | GitHub PAT,勾 `write:packages` |
| `GHCR_USER` | variable | 那个 PAT 对应的 GitHub 用户名 |
| `GHCR_IMAGE` | variable | 目标镜像名,如 `icybear/openvpngate`。这里的仓库名和 GitHub 上想要的名字不一定一样 |
三个都是「有就用、没有就退回内置值」,所以在 github.com 上不配也不会碍事。
首次推送后包默认是私有的。要让别人 `docker pull` 得先在 GitHub 的 Package settings 里改成
public,或者让对方 `docker login ghcr.io`。