ci: 加 -race、staticcheck、govulncheck、shellcheck 与真 PostgreSQL 集成测试门禁,release 复用 ci 门禁

This commit is contained in:
Lemon-miaow committed 2026-09-25 00:35:09 +08:00
1 parent 82545548e7
commit 1141ebcd49
8 files changed
+130 -58

No files matched your search

+69 -1
View File
@@ -14,10 +14,14 @@
# PR is what asks for the answer. # PR is what asks for the answer.
name: ci name: ci
#
# release.yml calls this workflow (workflow_call) before it builds anything, so a tag passes
# exactly these gates and there is one list of them.
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
workflow_call:
permissions: permissions:
contents: read contents: read
@@ -43,7 +47,61 @@ jobs:
echo "gofmt needed on:"; echo "$unformatted"; exit 1 echo "gofmt needed on:"; echo "$unformatted"; exit 1
fi fi
- run: go vet ./... - run: go vet ./...
- run: go test ./... # -race: felis-api and the operator are mostly goroutines (watchers, the
# registry pruner, the backup scheduler, the rate limiters).
- run: go test -race ./...
# The version is pinned here and bumped by hand; Dependabot does not read `go run`.
- name: staticcheck
run: go run honnef.co/go/tools/cmd/[email protected] ./...
# Separate from the go job so a newly published advisory reads as what it is. govulncheck
# exits non-zero only for vulnerable code this module can actually reach, standard
# library included: setup-go installs the newest patch of go.mod's Go line, so a finding
# there means the Dockerfile's golang digest (which ships the release) needs a bump too.
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
- run: go run golang.org/x/vuln/cmd/[email protected] ./...
# The business stores' SQL against a real PostgreSQL (internal/pgint): the unit suites run
# on fakes, and PGRepo drifted from them three times while those stayed green. 13 is the
# oldest server a supported distribution installs (EL9), 18 the newest (Arch).
pgint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
postgres: ['13', '18']
services:
postgres:
image: postgres:${{ matrix.postgres }}
env:
POSTGRES_USER: felis
POSTGRES_PASSWORD: pgint
POSTGRES_DB: felis_pgint
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U felis -d felis_pgint"
--health-interval 2s
--health-timeout 5s
--health-retries 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
- run: go test -race -tags pgint -count=1 ./internal/pgint/
env:
FELIS_TEST_PG_URL: postgres://felis:pgint@localhost:5432/felis_pgint?sslmode=disable
shell: shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -66,6 +124,16 @@ jobs:
esac esac
done done
# A pinned release rather than the runner image's copy, so a runner update cannot
# change what fails. Warnings and errors fail the job; style notes (info) do not.
- name: shellcheck
run: |
curl -fsSL -o shellcheck.tar.xz \
https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz
echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 shellcheck.tar.xz" | sha256sum -c
tar -xJf shellcheck.tar.xz
./shellcheck-v0.11.0/shellcheck -S warning $(git ls-files '*.sh')
- run: sh deploy/bootstrap_test.sh - run: sh deploy/bootstrap_test.sh
panel: panel:
+12 -36
View File
@@ -22,9 +22,9 @@
# hash is not listed there, BEFORE it runs it. A release without the file installs by source # hash is not listed there, BEFORE it runs it. A release without the file installs by source
# build instead. # build instead.
# #
# Two jobs, so the write token never meets the test suite: `build` runs the tests, Gradle and # The write token never meets the test suite: `gates` (ci.yml) and `build` run the tests,
# the Docker build (each of which executes third-party code) with a read-only token and hands # Gradle and the Docker build (each of which executes third-party code) with a read-only
# the binaries over as a workflow artifact; `publish` holds contents:write and runs only # token, and `build` hands the binaries over as a workflow artifact; `publish` holds contents:write and runs only
# pinned actions and gh. Every action is pinned to a commit SHA (the tag in the trailing # pinned actions and gh. Every action is pinned to a commit SHA (the tag in the trailing
# comment is for humans); .github/dependabot.yml proposes the bumps. # comment is for humans); .github/dependabot.yml proposes the bumps.
name: release name: release
@@ -37,44 +37,20 @@ permissions:
contents: read contents: read
jobs: jobs:
# A tag that ships red is worse than a tag that fails to ship. These are ci.yml's gates,
# called rather than copied: Go (race, vet, staticcheck), govulncheck, the PostgreSQL
# contract suite, shellcheck and the bootstrap tests, the panel, and the Java layer the
# binary EMBEDS (bootstrap_asset.go ships the plugin sources, so a tag whose plugins do
# not compile turns every install of that release into a failed bootstrap).
gates:
uses: ./.github/workflows/ci.yml
build: build:
needs: gates
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
# A tag that ships red is worse than a tag that fails to ship.
- run: go vet ./...
- run: go test ./...
# The same reason, for the Java layer the binary EMBEDS: the release asset is
# the tree's plugin sources (bootstrap_asset.go), and a tag whose plugins don't
# compile turns every install of that release into a failed bootstrap. JDK 21
# gates the install-time plugins + codec/invite tests; JDK 17 gates the loader
# mods (their vendored wrappers fetch their own Gradle).
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
gradle-version: '8.14'
- run: bash plugins/test.sh
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
- run: bash plugins/test-mods.sh
# Both architectures, because bootstrap's default release channel DOWNLOADS these # Both architectures, because bootstrap's default release channel DOWNLOADS these
# rather than compiling on the target host — an arm64 host with no asset silently # rather than compiling on the target host — an arm64 host with no asset silently
# falls back to a slow source build. Neither stage is emulated: the Dockerfile pins # falls back to a slow source build. Neither stage is emulated: the Dockerfile pins
+1 -1
View File
@@ -227,7 +227,7 @@ func extractTarGz(r io.Reader, root string) error {
if err := os.MkdirAll(target, 0o755); err != nil { if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("create %q: %w", name, err) return fmt.Errorf("create %q: %w", name, err)
} }
case tar.TypeReg, tar.TypeRegA: case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create parent of %q: %w", name, err) return fmt.Errorf("create parent of %q: %w", name, err)
} }
+8 -7
View File
@@ -54,7 +54,7 @@
# digests are pinned; a rerun moves an installer-managed JRE to the # digests are pinned; a rerun moves an installer-managed JRE to the
# pinned build). Another feature version is checked against the # pinned build). Another feature version is checked against the
# digest Adoptium's API publishes for it. # digest Adoptium's API publishes for it.
# FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4) # FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.8)
# FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture. # FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture.
# REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned. # REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned.
# FELIS_K3S_VERSION k3s release a fresh install gets (default: v1.36.4+k3s1). An # FELIS_K3S_VERSION k3s release a fresh install gets (default: v1.36.4+k3s1). An
@@ -214,9 +214,9 @@ FELIS_LEGACY_FORWARDING_SERVERS="${FELIS_LEGACY_FORWARDING_SERVERS:-legacy18}"
# The Go tarball is unpacked and run as root, so the default version is pinned by the sha256 # The Go tarball is unpacked and run as root, so the default version is pinned by the sha256
# go.dev/dl publishes for each architecture install_go_toolchain handles. Move all three # go.dev/dl publishes for each architecture install_go_toolchain handles. Move all three
# together; any other FELIS_GO_VERSION has to bring its own FELIS_GO_SHA256. # together; any other FELIS_GO_VERSION has to bring its own FELIS_GO_SHA256.
GO_PINNED_VERSION="1.26.4" GO_PINNED_VERSION="1.26.8"
GO_PINNED_SHA256_AMD64="1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" GO_PINNED_SHA256_AMD64="d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b"
GO_PINNED_SHA256_ARM64="ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" GO_PINNED_SHA256_ARM64="211ffced9dcb9633a55eac6364816ec0ddd951389a740e88fa8b3337971bdda0"
FELIS_GO_VERSION="${FELIS_GO_VERSION:-$GO_PINNED_VERSION}" FELIS_GO_VERSION="${FELIS_GO_VERSION:-$GO_PINNED_VERSION}"
FELIS_GO_SHA256="${FELIS_GO_SHA256:-}" FELIS_GO_SHA256="${FELIS_GO_SHA256:-}"
# cloudflared runs as root on the edge, so it gets the same treatment: a pinned release and # cloudflared runs as root on the edge, so it gets the same treatment: a pinned release and
@@ -1053,8 +1053,8 @@ install_k3s() {
# registry-mirror restart below: both restart the agent, and a bootstrap that # registry-mirror restart below: both restart the agent, and a bootstrap that
# proceeds early fails later with a misleading "not found"/timeout instead. # proceeds early fails later with a misleading "not found"/timeout instead.
wait_for_node_ready() { wait_for_node_ready() {
local i local _
for i in $(seq 1 60); do for _ in $(seq 1 60); do
if kube get nodes 2>/dev/null | grep -q ' Ready '; then if kube get nodes 2>/dev/null | grep -q ' Ready '; then
ok "k3s node Ready" ok "k3s node Ready"
return 0 return 0
@@ -3731,7 +3731,8 @@ write_nano_config() {
# own 0700: that directory holds secrets, and install_nano_service reports the lockout # own 0700: that directory holds secrets, and install_nano_service reports the lockout
# rather than this widening it. # rather than this widening it.
if [ ! -d "$STATE_DIR" ]; then if [ ! -d "$STATE_DIR" ]; then
mkdir -p -m 0755 "$STATE_DIR" mkdir -p "$STATE_DIR"
chmod 0755 "$STATE_DIR"
elif [ ! -e "$SECRETS_ENV" ] && [ ! -e "$BOOTSTRAP_DONE" ]; then elif [ ! -e "$SECRETS_ENV" ] && [ ! -e "$BOOTSTRAP_DONE" ]; then
chmod 0755 "$STATE_DIR" chmod 0755 "$STATE_DIR"
fi fi
+28 -6
View File
@@ -164,7 +164,7 @@ ivblock="$(awk '/^install_velocity\(\) \{/,/^}/' "$BS")"
run_velocity_choice() { # FELIS_GAME_STACK FELIS_VELOCITY_VERSION lock-version run_velocity_choice() { # FELIS_GAME_STACK FELIS_VELOCITY_VERSION lock-version
FELIS_GAME_STACK="$1" FELIS_VELOCITY_VERSION="$2" VELOCITY_VERSION="$3" VELOCITY_LATEST_MINOR=3.5.1 \ FELIS_GAME_STACK="$1" FELIS_VELOCITY_VERSION="$2" VELOCITY_VERSION="$3" VELOCITY_LATEST_MINOR=3.5.1 \
VELOCITY_JAR_URL=https://fill-data.papermc.io/v1/objects/aaa/velocity-3.5.1-615.jar VELOCITY_JAR_SHA256=aaa \ VELOCITY_JAR_URL=https://fill-data.papermc.io/v1/objects/aaa/velocity-3.5.1-615.jar VELOCITY_JAR_SHA256=aaa \
FELIS_VELOCITY_FORK_JAR= bash -c ' FELIS_VELOCITY_FORK_JAR='' bash -c '
set -Eeuo pipefail set -Eeuo pipefail
log() { :; } log() { :; }
die() { printf "DIE: %s\n" "$*"; exit 1; } die() { printf "DIE: %s\n" "$*"; exit 1; }
@@ -993,23 +993,30 @@ rm -f "$mfile"
pblock="$(awk '/^push_image_to_registry\(\) \{/,/^}/' "$BS")" pblock="$(awk '/^push_image_to_registry\(\) \{/,/^}/' "$BS")"
[ -n "$pblock" ] || { echo "FAIL: no push_image_to_registry found in $BS"; exit 1; } [ -n "$pblock" ] || { echo "FAIL: no push_image_to_registry found in $BS"; exit 1; }
run_push() { # ref [docker-push-exit] pushdir="$(mktemp -d)"
REF="$1" PUSH_EXIT="${2:-0}" \ run_push() { # ref [failed-pushes-before-success] [registry-read-only]
REF="$1" PUSH_FAILS="${2:-0}" READONLY="${3:-0}" COUNT="$pushdir/count" \
REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 REGISTRY_DOCKER_CONFIG=/cfg \ REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 REGISTRY_DOCKER_CONFIG=/cfg \
bash -c ' bash -c '
log() { printf "LOG: %s\n" "$*"; } log() { printf "LOG: %s\n" "$*"; }
warn() { printf "WARN: %s\n" "$*"; } warn() { printf "WARN: %s\n" "$*"; }
die() { printf "DIE: %s\n" "$*"; exit 1; } die() { printf "DIE: %s\n" "$*"; exit 1; }
ok() { :; } ok() { :; }
sleep() { :; }
systemctl() { :; } systemctl() { :; }
registry_read_only() { [ "$READONLY" = 1 ]; }
docker() { docker() {
printf "DOCKER %s\n" "$*" printf "DOCKER %s\n" "$*"
case " $* " in case " $* " in
*" push "*) return "$PUSH_EXIT" ;; *" push "*)
n="$(cat "$COUNT" 2>/dev/null || echo 0)"
echo $((n + 1)) > "$COUNT"
[ "$n" -ge "$PUSH_FAILS" ] ;;
esac esac
} }
'"$pblock"' '"$pblock"'
push_image_to_registry "$REF"' push_image_to_registry "$REF"'
rm -f "$pushdir/count"
} }
out="$(run_push registry.felis.svc:5000/felis/felis:demo)" out="$(run_push registry.felis.svc:5000/felis/felis:demo)"
@@ -1025,6 +1032,16 @@ esac
out="$(run_push registry.felis.svc:5000/felis/felis:demo 1)" out="$(run_push registry.felis.svc:5000/felis/felis:demo 1)"
expect "a failed push fails the install loudly" "DIE: could not mirror" "$out" expect "a failed push fails the install loudly" "DIE: could not mirror" "$out"
out="$(run_push registry.felis.svc:5000/felis/felis:demo 2 1)"
expect "a push refused during a GC window is retried" \
"WARN: the registry is read-only for garbage collection; retrying the push of 127.0.0.1:5000/felis/felis:demo in 30s (2/40)" "$out"
case "$out" in
*DIE:*) echo "FAIL a push that succeeds after the GC window must not fail the install"; fails=$((fails + 1)) ;;
*) echo "PASS a push that succeeds after the GC window completes" ;;
esac
expect "a GC window that never ends still fails the install" "DIE: could not mirror" \
"$(run_push registry.felis.svc:5000/felis/felis:demo 99 1)"
rm -rf "$pushdir"
# docker must be started ONCE for the whole batch: a start/stop pair per image trips # docker must be started ONCE for the whole batch: a start/stop pair per image trips
# systemd's start rate limit ("start-limit-hit" — observed live; the 4th image was never # systemd's start rate limit ("start-limit-hit" — observed live; the 4th image was never
@@ -1130,8 +1147,11 @@ expect "the running felis image is pinned" "CTR ctr images label registry.felis.
expect "the registry image is pinned by digest" "CTR ctr images label docker.io/library/registry@${regdigest} io.cri-containerd.pinned=pinned" "$out" expect "the registry image is pinned by digest" "CTR ctr images label docker.io/library/registry@${regdigest} io.cri-containerd.pinned=pinned" "$out"
expect "a previous felis tag is unpinned" "CTR ctr images label registry.felis.svc:5000/felis/felis:v1 io.cri-containerd.pinned=" "$out" expect "a previous felis tag is unpinned" "CTR ctr images label registry.felis.svc:5000/felis/felis:v1 io.cri-containerd.pinned=" "$out"
expect "the old registry:2 tag is unpinned" "CTR ctr images label docker.io/library/registry:2 io.cri-containerd.pinned=" "$out" expect "the old registry:2 tag is unpinned" "CTR ctr images label docker.io/library/registry:2 io.cri-containerd.pinned=" "$out"
# $'\n' is bash; this file runs under dash in CI.
nl='
'
case "$out" in case "$out" in
*"registry@${regdigest} io.cri-containerd.pinned="$'\n'*) echo "FAIL: the current registry image must not be unpinned"; fails=$((fails + 1)) ;; *"registry@${regdigest} io.cri-containerd.pinned=${nl}"*) echo "FAIL: the current registry image must not be unpinned"; fails=$((fails + 1)) ;;
esac esac
case "$out" in case "$out" in
*"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;; *"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;;
@@ -1698,7 +1718,7 @@ printf 'JAVA_VERSION="25"\n' > "$vdir/jre/release"
run_velocity_service() { # is-active(0|1) run_velocity_service() { # is-active(0|1)
ACTIVE="$1" VELOCITY_SERVICE="$vdir/unit" VELOCITY_DIR="$vdir/v" JRE_DIR="$vdir/jre" \ ACTIVE="$1" VELOCITY_SERVICE="$vdir/unit" VELOCITY_DIR="$vdir/v" JRE_DIR="$vdir/jre" \
VELOCITY_FINGERPRINT="$vdir/fp" VELOCITY_USER=felis-velocity FELIS_GAME_PORT=25565 \ VELOCITY_FINGERPRINT="$vdir/fp" VELOCITY_USER=felis-velocity FELIS_GAME_PORT=25565 \
FELIS_LEGACY_FORWARDING_SERVERS= bash -c ' FELIS_LEGACY_FORWARDING_SERVERS='' bash -c '
set -Eeuo pipefail set -Eeuo pipefail
ok() { printf "OK: %s\n" "$*"; } ok() { printf "OK: %s\n" "$*"; }
felis_internal_ip() { printf "10.43.0.9"; } felis_internal_ip() { printf "10.43.0.9"; }
@@ -1882,6 +1902,8 @@ expect "a v6 node address gets a v6 rule" "tcp dport 5432 ip6 saddr 2001:db8::7
rm -rf "$fwdir" rm -rf "$fwdir"
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
+3
View File
@@ -44,8 +44,11 @@ resolve_latest_game_jars
log "resolving the newest Velocity ${VELOCITY_LATEST_MINOR} build" log "resolving the newest Velocity ${VELOCITY_LATEST_MINOR} build"
velocity="$(papermc_latest_jar velocity "$VELOCITY_LATEST_MINOR")" \ velocity="$(papermc_latest_jar velocity "$VELOCITY_LATEST_MINOR")" \
|| die "no Velocity build for ${VELOCITY_LATEST_MINOR}" || die "no Velocity build for ${VELOCITY_LATEST_MINOR}"
# shellcheck disable=SC2034 # read back through ${!key} below
VELOCITY_VERSION="$VELOCITY_LATEST_MINOR" VELOCITY_VERSION="$VELOCITY_LATEST_MINOR"
# shellcheck disable=SC2034
VELOCITY_JAR_URL="${velocity% *}" VELOCITY_JAR_URL="${velocity% *}"
# shellcheck disable=SC2034
VELOCITY_JAR_SHA256="${velocity##* }" VELOCITY_JAR_SHA256="${velocity##* }"
tmp="$(mktemp)" tmp="$(mktemp)"
+1 -1
View File
@@ -505,7 +505,7 @@ func (o OffsiteConfig) validate() error {
return fmt.Errorf("config: [offsite] bucket %q must be a bare bucket name; put a key prefix in prefix", o.Bucket) return fmt.Errorf("config: [offsite] bucket %q must be a bare bucket name; put a key prefix in prefix", o.Bucket)
} }
if strings.Contains(o.Prefix, "..") { if strings.Contains(o.Prefix, "..") {
return fmt.Errorf("config: [offsite] prefix %q must not contain ..", o.Prefix) return fmt.Errorf("config: [offsite] prefix %q must not contain \"..\"", o.Prefix)
} }
if o.DBKeep < 1 { if o.DBKeep < 1 {
return fmt.Errorf("config: [offsite] db_keep %d must be at least 1", o.DBKeep) return fmt.Errorf("config: [offsite] db_keep %d must be at least 1", o.DBKeep)
+8 -6
View File
@@ -81,13 +81,15 @@ type Gate struct {
// New builds a Gate for upstream. // New builds a Gate for upstream.
func New(upstream *url.URL, tokens map[string]string, log *slog.Logger) *Gate { func New(upstream *url.URL, tokens map[string]string, log *slog.Logger) *Gate {
g := &Gate{Tokens: tokens, Upstream: upstream, Log: log} g := &Gate{Tokens: tokens, Upstream: upstream, Log: log}
rp := httputil.NewSingleHostReverseProxy(upstream) rp := &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
base := rp.Director pr.SetURL(upstream)
rp.Director = func(r *http.Request) { // The registry builds upload Location URLs from Host: keep the one the client
base(r) // dialled, not the loopback upstream.
pr.Out.Host = pr.In.Host
pr.SetXForwarded()
// The registry has no auth of its own; the credential stops here. // The registry has no auth of its own; the credential stops here.
r.Header.Del("Authorization") pr.Out.Header.Del("Authorization")
} }}
// Blob uploads and pulls are streamed; flush as bytes arrive so a large layer // Blob uploads and pulls are streamed; flush as bytes arrive so a large layer
// pull is not buffered in the gate. // pull is not buffered in the gate.
rp.FlushInterval = -1 rp.FlushInterval = -1