ci(gitea): verify container images with isolated smoke tests
Development CI / check-and-package (push) Successful in 9m10s
Development CI / docker-smoke (push) Successful in 16s

This commit is contained in:
eezd committed 2026-10-27 16:02:50 +08:00
1 parent d99cdd75b6
commit 27e854159d
4 files changed
+180 -2

No files matched your search

+46
View File
@@ -130,3 +130,49 @@ jobs:
path: downloads/SHA256SUMS path: downloads/SHA256SUMS
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
docker-smoke:
needs: check-and-package
runs-on: docker
timeout-minutes: 15
defaults:
run:
shell: sh
steps:
- name: Install CI action and smoke-test runtimes
run: apk add --no-cache nodejs python3
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: https://github.com/actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: linux-amd64
path: downloads
- uses: https://github.com/actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: checksums
path: downloads
- name: Verify the tested binary archive
run: |
python3 - <<'PY'
import hashlib
from pathlib import Path
expected = {line.split()[1]: line.split()[0] for line in Path('downloads/SHA256SUMS').read_text().splitlines()}
archive = Path(f'downloads/rawmark_{Path("VERSION").read_text().strip()}_linux_amd64.tar.gz')
with archive.open('rb') as source:
assert hashlib.file_digest(source, 'sha256').hexdigest() == expected[archive.name]
PY
mkdir -p image/linux/amd64 image/data
tar -xzf "downloads/rawmark_$(cat VERSION)_linux_amd64.tar.gz" -C image/linux/amd64
- name: Build and test the isolated container image
env:
COMMIT_SHA: ${{ gitea.sha }}
RUN_ID: ${{ gitea.run_id }}
RUN_ATTEMPT: ${{ gitea.run_attempt }}
run: |
set -eu
image="rawmark-ci:$COMMIT_SHA-$RUN_ID-$RUN_ATTEMPT"
docker build --build-arg TARGETARCH=amd64 --tag "$image" -f deploy/Dockerfile.release image
trap 'docker image rm "$image"' EXIT
python3 scripts/smoke-docker.py "$image" --client-container "$(hostname)"
+1 -1
View File
@@ -112,7 +112,7 @@ Backend `internal/i18n/` preserves message IDs and parameters. Errors render at
Gitea is the development remote (`origin`); GitHub is the publication remote (`github`). Normal `git push origin main` runs `.gitea/workflows/ci.yml`. There is no automatic mirror to GitHub and no release or deployment step in development CI. The private runner executes one job at a time; keep its capacity at one (and never above two for this project). Gitea is the development remote (`origin`); GitHub is the publication remote (`github`). Normal `git push origin main` runs `.gitea/workflows/ci.yml`. There is no automatic mirror to GitHub and no release or deployment step in development CI. The private runner executes one job at a time; keep its capacity at one (and never above two for this project).
The Linux runner checks lint, types, frontend tests, production/demo builds, Go vet and tests, filesystem permissions as an unprivileged user, and the Linux amd64 binary lifecycle with disposable data. It cross-compiles and vets Linux amd64/arm64, Windows amd64, and macOS arm64/amd64. Cross-compilation does **not** validate native behavior on Windows, macOS, or Linux arm64; the GitHub release workflow retains native tests. The Linux runner checks lint, types, frontend tests, production/demo builds, Go vet and tests, filesystem permissions as an unprivileged user, and the Linux amd64 binary lifecycle with disposable data. It cross-compiles and vets Linux amd64/arm64, Windows amd64, and macOS arm64/amd64. Cross-compilation does **not** validate native behavior on Windows, macOS, or Linux arm64; the GitHub release workflow retains native tests. After these checks pass, a dependent Docker job verifies the same run’s Linux amd64 archive checksum and builds it with `deploy/Dockerfile.release`. It tests the non-root image, embedded frontend, health check, authentication, example notes, Unicode paths, edits, moves, recycle bin, persistence across restart and container replacement, and server-side logout. Tests use a fresh internal network and named volume, publish no host ports, and remove the temporary container, volume, network, and image on completion. No image is pushed to a registry.
Each platform archive is uploaded separately, with checksums and seven-day retention. CI rejects archives of 90,000,000 bytes or more before upload, leaving room below the Cloudflare 100 MB request limit. Do not combine all platforms into one artifact or commit build output. Git's HTTP limit applies to the complete push request too, not just individual files: keep pushes small and do not add large binaries to history. The self-hosted runner connects to Gitea over its configured private Docker network. Each platform archive is uploaded separately, with checksums and seven-day retention. CI rejects archives of 90,000,000 bytes or more before upload, leaving room below the Cloudflare 100 MB request limit. Do not combine all platforms into one artifact or commit build output. Git's HTTP limit applies to the complete push request too, not just individual files: keep pushes small and do not add large binaries to history. The self-hosted runner connects to Gitea over its configured private Docker network.
+1 -1
View File
@@ -112,7 +112,7 @@ python3 scripts/prepare-maple-font.py /path/to/MapleMonoNormalNL-NF-CN.zip
Gitea 为开发远端(`origin`),GitHub 为发布远端(`github`)。日常 `git push origin main` 触发 `.gitea/workflows/ci.yml`;不配置 GitHub 自动镜像,开发 CI 不发布版本或部署服务。私有 runner 每次执行一个 job,保持容量为一(本项目最多不能超过两个)。 Gitea 为开发远端(`origin`),GitHub 为发布远端(`github`)。日常 `git push origin main` 触发 `.gitea/workflows/ci.yml`;不配置 GitHub 自动镜像,开发 CI 不发布版本或部署服务。私有 runner 每次执行一个 job,保持容量为一(本项目最多不能超过两个)。
Linux runner 执行 lint、类型检查、前端测试、正式与 Demo 构建、Go vet 和测试、普通用户文件权限测试,以及使用临时数据的 Linux amd64 程序生命周期测试;交叉编译并 vet Linux amd64/arm64、Windows amd64、macOS arm64/amd64。交叉编译**不代表**已验证 Windows、macOS 或 Linux arm64 的原生行为,GitHub 发布流程仍保留原生测试。 Linux runner 执行 lint、类型检查、前端测试、正式与 Demo 构建、Go vet 和测试、普通用户文件权限测试,以及使用临时数据的 Linux amd64 程序生命周期测试;交叉编译并 vet Linux amd64/arm64、Windows amd64、macOS arm64/amd64。交叉编译**不代表**已验证 Windows、macOS 或 Linux arm64 的原生行为,GitHub 发布流程仍保留原生测试。 检查通过后,后续 Docker job 核对同一次运行的 Linux amd64 安装包校验和,使用 `deploy/Dockerfile.release` 构建镜像;验证非 root 运行、内嵌前端、健康检查、认证、示例笔记、中文路径、编辑、移动、回收站、重启与重建容器后的持久化和服务端注销。测试使用全新内部网络与命名卷,不发布宿主机端口,结束后移除临时容器、卷、网络及镜像,不推送镜像仓库。
每个平台独立上传压缩包,附校验和,保留七天。上传前拒绝大于等于 90,000,000 字节的包,为 Cloudflare 的 100 MB 请求限制预留余量;不将五平台合成一个产物,不将构建输出提交到 Git。Git HTTP 限制同样针对整个 push 请求,而非只针对单个文件:保持小批量推送,不将大二进制文件写入历史。自托管 runner 通过已配置的 Docker 私有网络连接 Gitea。 每个平台独立上传压缩包,附校验和,保留七天。上传前拒绝大于等于 90,000,000 字节的包,为 Cloudflare 的 100 MB 请求限制预留余量;不将五平台合成一个产物,不将构建输出提交到 Git。Git HTTP 限制同样针对整个 push 请求,而非只针对单个文件:保持小批量推送,不将大二进制文件写入历史。自托管 runner 通过已配置的 Docker 私有网络连接 Gitea。
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Verify a container image with disposable networking, storage and notes."""
import argparse
import contextlib
import json
from pathlib import Path
import secrets
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('image')
parser.add_argument('--client-container', help='CI job container to connect to the test network')
args = parser.parse_args()
version = Path('VERSION').read_text().strip()
name = 'rawmark-ci-' + secrets.token_hex(8)
def docker(*command):
return subprocess.check_output(['docker', *command], text=True).strip()
config = json.loads(docker('image', 'inspect', args.image))[0]['Config']
assert config['User'].split(':')[0] not in ('', '0', 'root'), 'Image must run as non-root'
assert config['Healthcheck']['Test'] == ['CMD', '/rawmark', 'healthcheck']
assert docker('run', '--rm', '--network', 'none', args.image, '--version') == f'RawMark {version}'
with contextlib.ExitStack() as cleanup:
docker('network', 'create', '--internal', name)
cleanup.callback(docker, 'network', 'rm', name)
docker('volume', 'create', name)
cleanup.callback(docker, 'volume', 'rm', name)
if args.client_container:
docker('network', 'connect', name, args.client_container)
cleanup.callback(docker, 'network', 'disconnect', name, args.client_container)
create = ('create', '--name', name, '--network', name,
'--mount', f'type=volume,source={name},target=/data',
'--cpus', '1', '--memory', '512m', '--health-interval', '1s',
'--health-start-period', '1s', args.image)
docker(*create)
cleanup.callback(docker, 'rm', '--force', '--volumes', name)
docker('start', name)
info = json.loads(docker('inspect', name))[0]
host = name if args.client_container else info['NetworkSettings']['Networks'][name]['IPAddress']
origin = f'http://{host}:8080'
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(path, *, method='GET', body=None, token=None, status=200, raw=False):
headers = {'Accept-Language': 'en'}
if token:
headers['Authorization'] = 'Bearer ' + token
if body is not None and not isinstance(body, bytes):
headers['Content-Type'] = 'application/json'
body = json.dumps(body).encode()
req = urllib.request.Request(origin + path, data=body, headers=headers, method=method)
try:
response = opener.open(req, timeout=5)
except urllib.error.HTTPError as error:
response = error
with response:
assert response.status == status, (path, response.status, response.read())
if raw:
return response.read()
result = json.load(response)
assert (result['code'] == 0) == (status < 400), result
return result['data']
def wait_healthy():
for _ in range(60):
state = json.loads(docker('inspect', name))[0]['State']
if state['Status'] != 'running' or state['Health']['Status'] == 'unhealthy':
raise RuntimeError(f'Container failed: {state["Status"]}, {state["Health"]["Status"]}')
if state['Health']['Status'] == 'healthy':
docker('exec', name, '/rawmark', 'healthcheck')
request('/api/v1/health')
return
time.sleep(1)
raise TimeoutError('Container did not become healthy')
try:
wait_healthy()
assert b'<html' in request('/', raw=True).lower(), 'Embedded frontend is missing'
assert request('/api/v1/auth/state')['setUp'] is False
request('/api/v1/tree', status=401)
token = request('/api/v1/auth/setup', method='POST',
body={'password': secrets.token_urlsafe(24)})['token']
assert token.startswith('rms_')
entries = request('/api/v1/tree', token=token)['entries']
assert len([e for e in entries if not e['dir'] and e['path'].endswith('.md')]) == 7
for note in Path('deploy/data/vault').rglob('*.md'):
path = urllib.parse.quote(note.relative_to('deploy/data/vault').as_posix())
assert request('/api/v1/files/' + path, token=token, raw=True) == note.read_bytes()
note = '/api/v1/files/' + urllib.parse.quote('中文 folder/笔记.md')
moved = '/api/v1/files/' + urllib.parse.quote('移动 folder/新笔记.md')
content = '# Container note\n\n中文 content.\n'.encode()
request(note, method='PUT', body=content, token=token)
content += b'Updated.\n'
request(note, method='PUT', body=content, token=token)
request('/api/v1/move', method='POST', token=token,
body={'from': '中文 folder/笔记.md', 'to': '移动 folder/新笔记.md'})
request(note, token=token, status=404)
item = request(moved, method='DELETE', token=token)
request(moved, token=token, status=404)
assert item['id'] in [e['id'] for e in request('/api/v1/trash', token=token)['items']]
request('/api/v1/trash/' + item['id'] + '/restore', method='POST', token=token)
assert request(moved, token=token, raw=True) == content
for replace in (False, True):
if replace:
docker('rm', '--force', '--volumes', name)
docker(*create)
docker('start', name)
if not args.client_container:
info = json.loads(docker('inspect', name))[0]
origin = f'http://{info["NetworkSettings"]["Networks"][name]["IPAddress"]}:8080'
else:
docker('restart', name)
wait_healthy()
assert request('/api/v1/auth/state')['setUp'] is True
request('/api/v1/auth/me', token=token)
assert request(moved, token=token, raw=True) == content
assert request('/api/v1/auth/logout', method='POST', token=token, status=204, raw=True) == b''
request('/api/v1/auth/me', token=token, status=401)
print(f'RawMark {version}: container non-root user, frontend, health, authentication, examples, '
'overwrite, Unicode paths, move, recycle bin, restart/replacement persistence and logout passed')
except BaseException:
subprocess.run(['docker', 'logs', name], check=False)
raise