Skip to content

Commit 2f1afb6

Browse files
committed
add functional test + gate/modernize ci
1 parent b8738fc commit 2f1afb6

5 files changed

Lines changed: 177 additions & 11 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,21 @@ env:
2121
REGISTRY: ghcr.io
2222
# github.repository as <account>/<repo>
2323
IMAGE_NAME: ${{ github.repository }}
24+
IMAGE_PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
2425

2526

2627
jobs:
28+
# gate: don't build or publish anything unless the tests pass
29+
test:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Checkout repository
33+
uses: actions/checkout@v5
34+
- name: Run tests
35+
run: ./test.sh
36+
2737
build:
38+
needs: test
2839

2940
runs-on: ubuntu-latest
3041
permissions:
@@ -36,20 +47,25 @@ jobs:
3647

3748
steps:
3849
- name: Checkout repository
39-
uses: actions/checkout@v4
50+
uses: actions/checkout@v5
4051

4152
- name: Install Cosign
4253
uses: sigstore/cosign-installer@v3.5.0
4354

55+
- name: Set up QEMU
56+
uses: docker/setup-qemu-action@v4
57+
with:
58+
platforms: ${{ env.IMAGE_PLATFORMS }}
59+
4460
# Workaround: https://github.com/docker/build-push-action/issues/461
4561
- name: Setup Docker buildx
46-
uses: docker/setup-buildx-action@v3
62+
uses: docker/setup-buildx-action@v4
4763

4864
# Login against a Docker registry except on PR
4965
# https://github.com/docker/login-action
5066
- name: Log into registry ${{ env.REGISTRY }}
5167
if: github.event_name != 'pull_request'
52-
uses: docker/login-action@v3
68+
uses: docker/login-action@v4
5369
with:
5470
registry: ${{ env.REGISTRY }}
5571
username: ${{ github.actor }}
@@ -59,12 +75,16 @@ jobs:
5975
# https://github.com/docker/metadata-action
6076
- name: Extract Docker metadata
6177
id: meta
62-
uses: docker/metadata-action@v5
78+
uses: docker/metadata-action@v6
6379
with:
6480
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
6581

6682
- name: Extract Docker Version Tag
67-
id: docker_version_tag
83+
id: docker_version_tag
84+
env:
85+
# rebuild on code changes (push / manual run); only the nightly
86+
# schedule dedups on the version tag to skip identical rebuilds
87+
FORCE_REBUILD: ${{ github.event_name != 'schedule' && '1' || '' }}
6888
run: |
6989
./get-version.sh $(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:" | tr '[:upper:]' '[:lower:]') | sed 's/^/tag=/g' >> $GITHUB_OUTPUT
7090
@@ -77,12 +97,12 @@ jobs:
7797
- name: Build and push Docker image with tag
7898
if: ${{ !endsWith(steps.docker_version_tag.outputs.tag, 'latest') }}
7999
id: build-and-push-tagged
80-
uses: docker/build-push-action@v5
100+
uses: docker/build-push-action@v7
81101
with:
82102
context: .
83103
push: ${{ github.event_name != 'pull_request' }}
84104
tags: ${{ steps.docker_version_tag.outputs.tag }}
85-
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
105+
platforms: ${{ env.IMAGE_PLATFORMS }}
86106
labels: ${{ steps.meta.outputs.labels }}
87107
cache-from: type=gha
88108
cache-to: type=gha,mode=max
@@ -100,7 +120,7 @@ jobs:
100120

101121
- name: Build Docker Latest Tag
102122
if: ${{ !endsWith(steps.docker_version_tag.outputs.tag, 'latest') }}
103-
id: docker_latest_tag
123+
id: docker_latest_tag
104124
run: |
105125
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" | tr '[:upper:]' '[:lower:]' >> $GITHUB_OUTPUT
106126
@@ -109,12 +129,12 @@ jobs:
109129
- name: Build and push Docker image as latest
110130
if: ${{ !endsWith(steps.docker_version_tag.outputs.tag, 'latest') }}
111131
id: build-and-push-latest
112-
uses: docker/build-push-action@v5
132+
uses: docker/build-push-action@v7
113133
with:
114134
context: .
115135
push: ${{ github.event_name != 'pull_request' }}
116136
tags: ${{ steps.docker_latest_tag.outputs.tag }}
117-
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
137+
platforms: ${{ env.IMAGE_PLATFORMS }}
118138
labels: ${{ steps.meta.outputs.labels }}
119139
cache-from: type=gha
120140
cache-to: type=gha,mode=max

.github/workflows/test.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Checkout repository
12+
uses: actions/checkout@v5
13+
14+
- name: Run test suite
15+
run: ./test.sh

get-version.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export ALPINE_VERSION=$(docker run --rm -t get-version cat /etc/alpine-release |
66
[ -z "$ALPINE_VERSION" ] && exit 1
77

88
export IMGTAG=$(echo "$1""a$ALPINE_VERSION-r$RADICALE_VERSION")
9+
# FORCE_REBUILD (set by the workflow for push / manual runs) rebuilds even if
10+
# the versioned image already exists, so code/config changes get republished.
11+
# the nightly schedule leaves it unset and keeps deduping on the version tag.
12+
if [ -n "$FORCE_REBUILD" ]; then
13+
echo "$IMGTAG"
14+
exit 0
15+
fi
16+
917
export IMAGE_EXISTS=$(docker pull "$IMGTAG" 2>/dev/null >/dev/null; echo $?)
1018

1119
# return latest, if container is already available :)

scripts/command.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/sh
22
echo ">> start radicale server (:8000)"
3-
exec su -l -s /bin/sh -c "exec python3 -m radicale -H 0.0.0.0:8000 -D --storage-type multifilesystem --storage-filesystem-folder /data --no-storage-filesystem-locking --storage-hook 'cd /data && git add -A && (git diff --cached --quiet || git commit -m \"Changes by \"%(user)s)'" radicale
3+
exec su -l -s /bin/sh -c "exec python3 -m radicale -H 0.0.0.0:8000 -D --storage-type multifilesystem --storage-filesystem-folder /data --storage-hook 'cd /data && git add -A && (git diff --cached --quiet || git commit -m \"Changes by \"%(user)s)'" radicale

test.sh

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/bin/sh
2+
# automated smoke test for the radicale container
3+
# builds the image, starts it standalone and asserts radicale actually serves
4+
# CalDAV/CardDAV (real DAV replies, not just an open port)
5+
set -eu
6+
7+
IMAGE=radicale-test
8+
NAME=radicale-test-run
9+
PORT=8000
10+
11+
FAILED=0
12+
fail() {
13+
echo "FAIL: $*" >&2
14+
FAILED=1
15+
}
16+
17+
cleanup() {
18+
echo ">> cleanup: removing container $NAME"
19+
docker rm -f "$NAME" >/dev/null 2>&1 || true
20+
}
21+
trap cleanup EXIT INT TERM
22+
23+
# send a raw HTTP request to radicale (inside the container, via busybox nc)
24+
# and print the response. usage: http_req <method> [extra header lines...]
25+
http_req() {
26+
_method="$1"
27+
docker exec "$NAME" sh -c "printf '$_method / HTTP/1.1\r\nHost: localhost\r\nDepth: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 $PORT" 2>/dev/null || true
28+
}
29+
30+
echo ">> building image $IMAGE"
31+
docker build -t "$IMAGE" .
32+
33+
echo ">> (re)starting container $NAME"
34+
docker rm -f "$NAME" >/dev/null 2>&1 || true
35+
# minimal env: radicale needs no config to start (anonymous, filesystem storage)
36+
docker run -d --name "$NAME" "$IMAGE"
37+
38+
echo ">> waiting for radicale to answer on :$PORT (up to ~40s)"
39+
READY=0
40+
i=0
41+
while [ "$i" -lt 20 ]; do
42+
if ! docker ps --format '{{.Names}}' | grep -q "^${NAME}$"; then
43+
echo "!! container is not running anymore, dumping logs:" >&2
44+
docker logs "$NAME" >&2 2>&1 || true
45+
fail "container exited during startup"
46+
break
47+
fi
48+
if http_req OPTIONS | grep -q '^HTTP/'; then
49+
READY=1
50+
break
51+
fi
52+
i=$((i + 1))
53+
sleep 2
54+
done
55+
56+
if [ "$READY" -ne 1 ] && [ "$FAILED" -eq 0 ]; then
57+
echo "!! radicale did not answer in time, dumping logs:" >&2
58+
docker logs "$NAME" >&2 2>&1 || true
59+
fail "timed out waiting for radicale to respond"
60+
fi
61+
62+
# only run the deeper assertions if the container is still up
63+
if docker ps --format '{{.Names}}' | grep -q "^${NAME}$"; then
64+
65+
echo ">> assert: container is running"
66+
docker ps --format '{{.Names}}' | grep -q "^${NAME}$" \
67+
&& echo "ok - container running" || fail "container not running"
68+
69+
echo ">> assert: radicale process present"
70+
if docker exec "$NAME" ps aux | grep -q '[r]adicale'; then
71+
echo "ok - radicale process running"
72+
else
73+
fail "radicale process not found"
74+
fi
75+
76+
# OPTIONS is the DAV capability probe. a plain port-open check would pass on
77+
# anything; here we require the DAV: header advertising calendar-access and
78+
# addressbook, which only a CalDAV/CardDAV server (radicale) sends back.
79+
echo ">> assert: OPTIONS returns 200 with CalDAV/CardDAV DAV header"
80+
OPTIONS_RESP=$(http_req OPTIONS)
81+
if echo "$OPTIONS_RESP" | grep -q '^HTTP/1.[01] 200'; then
82+
echo "ok - OPTIONS returned 200"
83+
else
84+
fail "OPTIONS did not return 200 (got: '$(echo "$OPTIONS_RESP" | head -n1)')"
85+
fi
86+
if echo "$OPTIONS_RESP" | grep -qi '^DAV:.*calendar-access' \
87+
&& echo "$OPTIONS_RESP" | grep -qi '^DAV:.*addressbook'; then
88+
echo "ok - DAV header advertises calendar-access + addressbook"
89+
else
90+
fail "OPTIONS response missing CalDAV/CardDAV DAV header"
91+
fi
92+
93+
echo ">> assert: Allow header lists DAV methods (PROPFIND, REPORT, MKCALENDAR)"
94+
if echo "$OPTIONS_RESP" | grep -qi '^Allow:.*PROPFIND' \
95+
&& echo "$OPTIONS_RESP" | grep -qi '^Allow:.*REPORT' \
96+
&& echo "$OPTIONS_RESP" | grep -qi '^Allow:.*MKCALENDAR'; then
97+
echo "ok - Allow header lists DAV methods"
98+
else
99+
fail "OPTIONS Allow header missing DAV methods"
100+
fi
101+
102+
# PROPFIND is a real DAV method; the default config requires auth for it, so
103+
# radicale answers with its own Basic realm. this proves it is actually
104+
# processing DAV requests (and is radicale), not merely accepting a socket.
105+
echo ">> assert: PROPFIND is handled and challenges with the Radicale realm"
106+
PROPFIND_RESP=$(http_req PROPFIND)
107+
if echo "$PROPFIND_RESP" | grep -q '^HTTP/1.[01] 401' \
108+
&& echo "$PROPFIND_RESP" | grep -qi 'WWW-Authenticate: Basic realm="Radicale'; then
109+
echo "ok - PROPFIND challenged with Radicale Basic realm"
110+
else
111+
fail "PROPFIND did not return a Radicale auth challenge (got: '$(echo "$PROPFIND_RESP" | head -n1)')"
112+
fi
113+
114+
fi
115+
116+
echo
117+
if [ "$FAILED" -eq 0 ]; then
118+
echo "ALL TESTS PASSED"
119+
exit 0
120+
else
121+
echo "SOME TESTS FAILED"
122+
exit 1
123+
fi

0 commit comments

Comments
 (0)