Add Gitea build, container tests and registry publication
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s

This commit is contained in:
2026-10-28 17:07:23 +01:00
parent e3e362c2ac
commit 74a5aa1237
26 changed files with 1180 additions and 12 deletions

View File

@@ -3,3 +3,16 @@
.idea .idea
.mvn .mvn
*.iml *.iml
**/node_modules
**/.angular
**/dist
**/test-results
**/playwright-report
ci/artifacts
# Allow only the verified runtime output for the CI prebuilt target.
!agiledancers-be/target
agiledancers-be/target/*
!agiledancers-be/target/quarkus-app
**/unit-results

View File

@@ -0,0 +1,41 @@
name: Build, test and publish
on:
push:
branches: ['**']
jobs:
verify:
runs-on: agiledancers-mini-pc
env:
CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_RUN_ID: ${{ gitea.run_id }}
CI_RUN_ATTEMPT: ${{ gitea.run_attempt }}
steps:
- name: Check out pushed commit
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Build and test both images
run: timeout --signal=TERM --kill-after=60s 75m bash ci/verify.sh
- name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main'
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: timeout --signal=TERM --kill-after=60s 15m bash ci/publish.sh
- name: Collect diagnostics and clean up test containers
if: always()
run: bash ci/cleanup.sh
- name: Retain diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: diagnostics-${{ gitea.run_id }}-${{ gitea.run_attempt }}
path: ci/artifacts/
if-no-files-found: error
retention-days: 14

11
.gitignore vendored
View File

@@ -1,3 +1,12 @@
*.iml *.iml
.idea .idea
target target
# Local CI diagnostics and runner credentials/state
/ci/artifacts/
/ci/runner/.env
/ci/runner/.runner
/ci/runner/data/
/ci/runner/local/
**/unit-results/
**/__pycache__/

View File

@@ -5,9 +5,16 @@ COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src COPY agiledancers-be/src agiledancers-be/src
RUN mvn -B -ntp clean verify RUN mvn -B -ntp clean verify
FROM eclipse-temurin:25-jre FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./
USER 10001:10001 USER 10001:10001
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"] ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"]
# CI packages the exact output that already passed mvn clean verify.
FROM runtime AS prebuilt
COPY --chown=10001:10001 agiledancers-be/target/quarkus-app/ ./
# Default standalone build still compiles and tests from source.
FROM runtime AS standalone
COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./

View File

@@ -168,3 +168,11 @@ Open <http://localhost:4200>. The development proxy forwards API requests to the
backend on port 8080. Frontend builds and tests run independently of Maven: backend on port 8080. Frontend builds and tests run independently of Maven:
`npm run build` and `npm test`. See the frontend README for browser tests and `npm run build` and `npm test`. See the frontend README for browser tests and
production hosting requirements. production hosting requirements.
## Gitea CI
Every branch push builds and tests both applications and their Docker images.
Only successful `main` runs publish images to the Synology registry. See
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.

View File

@@ -0,0 +1,7 @@
node_modules
.angular
.git
test-results
playwright-report
unit-results

View File

@@ -0,0 +1,18 @@
FROM node:24.15.0-bookworm AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test && npm run build
FROM nginx:1.28-alpine AS runtime
ENV BACKEND_UPSTREAM=backend:8080
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 8080
# CI supplies the production output it has already built and tested.
FROM runtime AS prebuilt
COPY dist/agiledancers-fe/browser/ /usr/share/nginx/html/
FROM runtime AS standalone
COPY --from=build /workspace/dist/agiledancers-fe/browser/ /usr/share/nginx/html/

View File

@@ -1,6 +1,6 @@
import { test, expect, Page } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
const backend = 'http://127.0.0.1:8080'; const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
let schoolToDelete: number | undefined; let schoolToDelete: number | undefined;
test.afterEach(async ({ request }) => { test.afterEach(async ({ request }) => {

View File

@@ -0,0 +1,18 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://${BACKEND_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,15 +1,23 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
const externalURL = process.env['PLAYWRIGHT_BASE_URL'];
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
timeout: 60_000, timeout: 60_000,
reporter: 'list', reporter: process.env['CI']
? [
['list'],
['junit', { outputFile: 'test-results/playwright.xml' }],
['html', { open: 'never' }],
]
: 'list',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:4200', baseURL: externalURL || 'http://127.0.0.1:4200',
trace: 'retain-on-failure', trace: 'retain-on-failure',
actionTimeout: 15_000, actionTimeout: 15_000,
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
@@ -17,10 +25,12 @@ export default defineConfig({
? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] } ? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] }
: {}, : {},
}, },
webServer: { webServer: externalURL
command: 'npm start -- --host 127.0.0.1 --port 4200', ? undefined
url: 'http://127.0.0.1:4200', : {
reuseExistingServer: !process.env['CI'], command: 'npm start -- --host 127.0.0.1 --port 4200',
timeout: 60_000, url: 'http://127.0.0.1:4200',
}, reuseExistingServer: !process.env['CI'],
timeout: 60_000,
},
}); });

29
ci/cleanup.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
status=0
# Copy only test diagnostics, never environment dumps, Docker inspect or credentials.
for report in agiledancers-be/target/surefire-reports agiledancers-be/target/failsafe-reports \
agiledancers-fe/unit-results agiledancers-fe/test-results agiledancers-fe/playwright-report; do
if [[ -d "$report" ]]; then
destination="$ARTIFACTS/$report"
mkdir -p "$destination"
cp -R "$report/." "$destination/" || status=1
fi
done
if docker info >/dev/null 2>&1; then
for role in backend frontend browser; do
container="$RUN_NAME-$role"
if docker container inspect "$container" >/dev/null 2>&1; then
docker logs "$container" > "$ARTIFACTS/$role.log" 2>&1 || status=1
docker rm -f "$container" >/dev/null || status=1
fi
done
if docker network inspect "$RUN_NAME" >/dev/null 2>&1; then
docker network rm "$RUN_NAME" >/dev/null || status=1
fi
rm -f "$ARTIFACTS/containers-started"
elif [[ -f "$ARTIFACTS/containers-started" ]]; then
echo "Docker is unavailable; cleanup could not be confirmed" >&2
status=1
fi
exit "$status"

40
ci/common.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Shared by host-runner scripts. No credentials are persisted in the workspace.
set -Eeuo pipefail
CI_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$CI_ROOT"
ARTIFACTS="$CI_ROOT/ci/artifacts"
mkdir -p "$ARTIFACTS"
: "${CI_COMMIT:=$(git rev-parse HEAD)}"
: "${CI_RUN_ID:=local}"
: "${CI_RUN_ATTEMPT:=1}"
[[ "$CI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid commit SHA' >&2; exit 1; }
[[ "$CI_RUN_ID-$CI_RUN_ATTEMPT" =~ ^[a-zA-Z0-9-]+$ ]] || { echo 'Invalid run identifier' >&2; exit 1; }
RUN_NAME="agiledancers-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
BACKEND_IMAGE="agiledancers-ci-backend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
FRONTEND_IMAGE="agiledancers-ci-frontend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
require_platform() {
: "${TARGET_PLATFORM:?Set TARGET_PLATFORM to the verified Synology platform (for example linux/amd64)}"
local arch native
arch=$(docker info --format '{{.Architecture}}')
case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
native="$(docker info --format '{{.OSType}}')/$arch"
[[ "$TARGET_PLATFORM" == "$native" ]] || {
echo "Target $TARGET_PLATFORM differs from Docker host $native. Resolve and test the architecture before running CI." >&2
return 1
}
}
# Anonymous build pulls must not invoke the developer's desktop credential helper.
# Publication uses the same isolation and logs in only to our registry.
isolate_docker_config() {
if [[ -z "${DOCKER_HOST:-}" ]]; then
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
export DOCKER_HOST
fi
unset DOCKER_CONTEXT
CI_DOCKER_CONFIG=$(mktemp -d)
DOCKER_CONFIG=$CI_DOCKER_CONFIG
export DOCKER_CONFIG
}

67
ci/configure-gitea.py Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Configure repository Actions through the Gitea API; read tokens from local files."""
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
BASE = 'http://192.168.2.28:3000/api/v1/repos/wanjakrah/agiledancers'
def read_secret(variable):
path = Path(os.environ[variable]).expanduser()
if path.stat().st_mode & 0o077:
raise ValueError(f'{variable} must refer to a file readable only by its owner (chmod 600)')
value = path.read_text().strip()
if not value:
raise ValueError(f'{variable} is empty')
return value
def main():
admin = read_secret('GITEA_ADMIN_TOKEN_FILE')
registry = read_secret('REGISTRY_TOKEN_FILE')
user = os.environ['REGISTRY_USER']
platform = os.environ['TARGET_PLATFORM']
if platform not in ('linux/amd64', 'linux/arm64'):
raise ValueError('Set TARGET_PLATFORM from the verified Synology architecture')
def api(method, path, body):
request = urllib.request.Request(BASE + path, method=method,
data=json.dumps(body).encode() if body is not None else None, headers={
'Authorization': 'token ' + admin, 'Content-Type': 'application/json'})
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
return json.loads(data) if data else None
api('PATCH', '', {'has_actions': True})
for name, value in [('REGISTRY_USER', user), ('REGISTRY_TOKEN', registry)]:
api('PUT', '/actions/secrets/' + name, {'data': value})
try:
api('GET', '/actions/variables/TARGET_PLATFORM', None)
except urllib.error.HTTPError as error:
if error.code != 404:
raise
api('POST', '/actions/variables/TARGET_PLATFORM', {'value': platform})
else:
api('PUT', '/actions/variables/TARGET_PLATFORM', {'value': platform})
# Store registration credentials outside Git; never print API responses/tokens.
result = api('POST', '/actions/runners/registration-token', None)
token_path = Path.home() / '.config/agiledancers-runner/registration-token'
token_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
with os.fdopen(fd, 'w') as output:
output.write(result['token'])
print('Actions, registry secrets and target platform configured; runner token saved outside Git.')
if __name__ == '__main__':
try:
main()
except urllib.error.HTTPError as error:
print(f'Gitea configuration failed: HTTP {error.code}', file=sys.stderr)
sys.exit(1)
except (OSError, ValueError, KeyError, urllib.error.URLError) as error:
# Do not include request bodies, headers, response bodies or token values.
print(f'Gitea configuration failed ({type(error).__name__}); check local files and connection.', file=sys.stderr)
sys.exit(1)

16
ci/main-head.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Read the current main head without placing an access token in arguments/logs."""
import json
import os
import sys
import urllib.error
import urllib.request
url = os.environ['GITEA_SERVER_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['CI_REPOSITORY'] + '/branches/main'
request = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['REGISTRY_TOKEN']})
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(json.load(response)['commit']['id'])
except (urllib.error.URLError, KeyError, ValueError) as error:
print(f'Cannot verify current main head ({type(error).__name__})', file=sys.stderr)
sys.exit(1)

76
ci/publish.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
# Also enforce this inside the script, independently of the workflow expression.
[[ "${CI_REF:-}" == refs/heads/main ]] || { echo 'Branch is not main; no publication'; exit 0; }
[[ -f "$ARTIFACTS/verified-commit" && "$(cat "$ARTIFACTS/verified-commit")" == "$CI_COMMIT" ]] || {
echo 'This commit has not passed all verification steps' >&2; exit 1;
}
: "${REGISTRY_USER:?Missing REGISTRY_USER secret}"
: "${REGISTRY_TOKEN:?Missing REGISTRY_TOKEN secret}"
: "${GITEA_SERVER_URL:?Missing Gitea server URL}"
: "${CI_REPOSITORY:?Missing repository}"
require_platform
exec > >(tee "$ARTIFACTS/publish.log") 2>&1
REGISTRY=${GITEA_SERVER_URL#http://}
REGISTRY=${REGISTRY#https://}
REGISTRY=${REGISTRY%/}
[[ "$REGISTRY" != */* ]] || { echo 'Registry must be hosted at the Gitea server root'; exit 1; }
OWNER=${CI_REPOSITORY%%/*}
OWNER=${OWNER,,}
[[ "$OWNER" =~ ^[a-z0-9][a-z0-9_.-]*$ ]] || { echo 'Invalid registry namespace'; exit 1; }
# Gitea 1.25 ignores workflow concurrency. All publications on the sole runner
# share this lock, outside checkout directories and across workflow attempts.
lock_dir=${CI_PUBLISH_LOCK_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/agiledancers-ci}
mkdir -p "$lock_dir"
exec 9>"$lock_dir/publish.lock"
flock -w 600 9
# Preserve the selected daemon and isolate credentials from the developer's config.
isolate_docker_config
trap 'rm -rf -- "$DOCKER_CONFIG"' EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
ensure_commit_image() {
local role=$1 source=$2 repository="$REGISTRY/$OWNER/agiledancers-$1" ref error
ref="$repository:$CI_COMMIT"
error=$(mktemp)
if docker manifest inspect --insecure "$ref" > /dev/null 2>"$error"; then
echo "Preserving existing $ref"
elif grep -Eqi 'manifest unknown|no such manifest' "$error"; then
docker tag "$source" "$ref"
docker push "$ref"
else
# Authentication/network/server errors are never treated as missing tags.
cat "$error" >&2
rm -f "$error"
return 1
fi
rm -f "$error"
docker pull --platform "$TARGET_PLATFORM" "$ref"
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$ref")" == "$CI_COMMIT" ]] || {
echo "Existing $role image has an unexpected source revision" >&2; return 1;
}
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$ref" \
> "$ARTIFACTS/$role-digests.txt"
grep -Fq "$repository@sha256:" "$ARTIFACTS/$role-digests.txt" || { echo 'Missing registry digest'; return 1; }
}
ensure_commit_image backend "$BACKEND_IMAGE"
ensure_commit_image frontend "$FRONTEND_IMAGE"
# Both immutable images are now pull-verified. Only current main may move aliases.
head=$(python3 ci/main-head.py)
if [[ "$head" != "$CI_COMMIT" ]]; then
echo 'A newer main commit exists; leaving moving tags unchanged.'
exit 0
fi
for role in backend frontend; do
repository="$REGISTRY/$OWNER/agiledancers-$role"
# Recheck under the publication lock before each moving-tag write.
[[ "$(python3 ci/main-head.py)" == "$CI_COMMIT" ]] || {
echo 'Main advanced during publication; leaving remaining moving tags unchanged.'
exit 0
}
docker tag "$repository:$CI_COMMIT" "$repository:main"
docker push "$repository:main"
docker pull --platform "$TARGET_PLATFORM" "$repository:main"
done

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Agiledancers Gitea Actions runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/.local/share/agiledancers-runner
EnvironmentFile=%h/.config/agiledancers-runner/environment
ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/agiledancers-runner/config.yaml
Restart=on-failure
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=150
[Install]
WantedBy=default.target

11
ci/runner/config.yaml Normal file
View File

@@ -0,0 +1,11 @@
log:
level: info
runner:
file: .runner
capacity: 1
timeout: 2h
shutdown_timeout: 2m
labels:
- agiledancers-mini-pc:host
cache:
enabled: false

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Merge this project's HTTP registry into a selected daemon JSON file."""
import json
from pathlib import Path
import shutil
import sys
if len(sys.argv) != 2:
sys.exit('Usage: python3 ci/runner/configure-docker.py <daemon.json>')
path = Path(sys.argv[1]).expanduser()
data = json.loads(path.read_text()) if path.exists() else {}
registries = data.setdefault('insecure-registries', [])
if not isinstance(registries, list):
sys.exit('Existing insecure-registries must be an array; configuration left unchanged')
if '192.168.2.28:3000' in registries:
print('Internal registry already configured; no change')
sys.exit(0)
registries.append('192.168.2.28:3000')
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
backup = path.with_name(path.name + '.before-agiledancers')
if not backup.exists():
shutil.copy2(path, backup)
# Write in the same directory, then replace atomically.
temporary = path.with_name(path.name + '.agiledancers.tmp')
with temporary.open('x') as output:
json.dump(data, output, indent=2)
output.write('\n')
if path.exists():
shutil.copymode(path, temporary)
temporary.replace(path)
print('Internal registry added; restart/apply Docker Engine configuration to activate')

View File

@@ -0,0 +1,3 @@
{
"insecure-registries": ["192.168.2.28:3000"]
}

52
ci/runner/install.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Run as the development user, not root. Configuration stays outside the checkout.
set -Eeuo pipefail
script_dir=$(cd -- "$(dirname -- "$0")" && pwd)
runner_dir="$HOME/.local/share/agiledancers-runner"
config_dir="$HOME/.config/agiledancers-runner"
install -d -m 700 "$runner_dir" "$config_dir" "$HOME/.local/bin" "$HOME/.config/systemd/user"
[[ "$(uname -s)/$(uname -m)" == Linux/x86_64 ]] || { echo 'This installer targets the existing Linux x86_64 mini-PC'; exit 1; }
for tool in node java mvn docker curl python3 flock timeout; do command -v "$tool" >/dev/null; done
: "${JAVA_HOME:?Set JAVA_HOME to your JDK 25 directory}"
"$JAVA_HOME/bin/java" -version 2>&1 | grep -Eq 'version "25[.\"]' || { echo 'JDK 25 required'; exit 1; }
node -e 'const [a,b,c]=process.versions.node.split(".").map(Number); if (!((a===22&&(b>22||(b===22&&c>=3)))||(a===24&&(b>15||(b===15&&c>=0)))||a>=26)) process.exit(1)'
if [[ ! -x "$HOME/.local/bin/act_runner" ]]; then
download_dir=$(mktemp -d)
trap 'rm -rf -- "$download_dir"' EXIT
curl --fail --location --retry 3 \
https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64 -o "$download_dir/act_runner"
printf '%s %s\n' 3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2 "$download_dir/act_runner" | sha256sum --check
install -m 755 "$download_dir/act_runner" "$HOME/.local/bin/act_runner"
fi
"$HOME/.local/bin/act_runner" --version | grep -F 'v0.2.13'
install -m 600 "$script_dir/config.yaml" "$runner_dir/config.yaml"
install -m 644 "$script_dir/agiledancers-runner.service" "$HOME/.config/systemd/user/agiledancers-runner.service"
# Resolve paths now: user services do not inherit an interactive shell's PATH/context.
node_dir=$(dirname -- "$(command -v node)")
maven_dir=$(dirname -- "$(command -v mvn)")
docker_host=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}
umask 077
printf 'JAVA_HOME=%s\nPATH=%s/bin:%s:%s:/usr/local/bin:/usr/bin:/bin\nDOCKER_HOST=%s\n' \
"$JAVA_HOME" "$JAVA_HOME" "$node_dir" "$maven_dir" "$docker_host" > "$config_dir/environment"
systemctl --user daemon-reload
if [[ "${1:-}" == --prepare-only ]]; then
echo 'Runner installed; registration and startup still required.'
exit 0
fi
docker info >/dev/null
if [[ ! -f "$runner_dir/.runner" ]]; then
token_file=${RUNNER_REGISTRATION_TOKEN_FILE:-$config_dir/registration-token}
[[ -f "$token_file" ]] || { echo "Place the repository runner registration token in $token_file (mode 600), then rerun."; exit 1; }
registration_token=$(cat "$token_file")
[[ -n "$registration_token" ]] || { echo 'Empty registration token'; exit 1; }
(
cd "$runner_dir"
"$HOME/.local/bin/act_runner" register --no-interactive \
--instance http://192.168.2.28:3000 --name agiledancers-mini-pc \
--labels agiledancers-mini-pc:host --token "$registration_token"
chmod 600 .runner
)
unset registration_token
fi
systemctl --user enable --now agiledancers-runner.service
echo 'Runner registered and started. The mini-PC and Docker must remain available.'

View File

@@ -0,0 +1,29 @@
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / 'runner/configure-docker.py'
class DockerConfigurationTest(unittest.TestCase):
def test_merge_preserves_existing_settings_and_is_idempotent(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'daemon.json'
original = {'features': {'buildkit': True}, 'insecure-registries': ['existing:5000']}
path.write_text(json.dumps(original))
for _ in range(2):
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
actual = json.loads(path.read_text())
self.assertEqual(original['features'], actual['features'])
self.assertEqual(['existing:5000', '192.168.2.28:3000'], actual['insecure-registries'])
self.assertEqual(original, json.loads(path.with_name('daemon.json.before-agiledancers').read_text()))
def test_invalid_configuration_is_left_unchanged(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'daemon.json'
original = '{"insecure-registries": "invalid"}'
path.write_text(original)
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
self.assertNotEqual(0, result.returncode)
self.assertEqual(original, path.read_text())

View File

@@ -0,0 +1,115 @@
"""Exercise real shell control flow against a stateful registry/daemon double."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
SHA = 'a' * 40
REGISTRY = '192.168.2.28:3000/wanjakrah/agiledancers-'
DOCKER = r'''#!/usr/bin/env python3
import json, os, pathlib, sys
p = pathlib.Path(os.environ['FAKE_STATE'])
s = json.loads(p.read_text())
a = sys.argv[1:]
s['calls'].append(a)
p.write_text(json.dumps(s))
if a[0] == 'info':
print('amd64' if 'Architecture' in ' '.join(a) else 'linux')
elif a[0] == 'context': print('unix:///tmp/test-docker.sock')
elif a[0] == 'login': sys.stdin.read()
elif a[:2] == ['manifest', 'inspect']:
if os.environ.get('FAKE_MANIFEST_ERROR'):
print('unauthorized: authentication required', file=sys.stderr); sys.exit(1)
if a[-1] not in s['images']:
print('manifest unknown', file=sys.stderr); sys.exit(1)
elif a[0] == 'push':
if os.environ.get('FAKE_PUSH_FAILURE', '') in a[-1] and os.environ.get('FAKE_PUSH_FAILURE'):
sys.exit(1)
s['images'][a[-1]] = os.environ['CI_COMMIT']; p.write_text(json.dumps(s))
elif a[0] == 'pull':
if os.environ.get('FAKE_PULL_FAILURE', '') in a[-1] and os.environ.get('FAKE_PULL_FAILURE'):
sys.exit(1)
elif a[:2] == ['image', 'inspect']:
if 'RepoDigests' in ' '.join(a): print(a[-1].rsplit(':', 1)[0] + '@sha256:' + 'b' * 64)
else: print(s['images'].get(a[-1], 'missing'))
elif a[:2] == ['container', 'inspect'] or a[:2] == ['network', 'inspect']: sys.exit(1)
'''
class PublicationTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
shutil.copytree(ROOT / 'ci', self.root / 'ci', ignore=shutil.ignore_patterns('artifacts', '__pycache__'))
(self.root / 'ci/main-head.py').write_text("import os; print(os.environ.get('FAKE_MAIN_HEAD', os.environ['CI_COMMIT']))\n")
self.bin = self.root / 'bin'; self.bin.mkdir()
(self.bin / 'docker').write_text(DOCKER); (self.bin / 'docker').chmod(0o755)
self.state = self.root / 'state.json'
self.state.write_text(json.dumps({'images': {}, 'calls': []}))
artifacts = self.root / 'ci/artifacts'; artifacts.mkdir()
(artifacts / 'verified-commit').write_text(SHA + '\n')
self.env = dict(os.environ, PATH=str(self.bin) + ':' + os.environ['PATH'],
CI_COMMIT=SHA, CI_REF='refs/heads/main', CI_REPOSITORY='wanjakrah/agiledancers',
CI_RUN_ID='test', CI_RUN_ATTEMPT='1', TARGET_PLATFORM='linux/amd64',
GITEA_SERVER_URL='http://192.168.2.28:3000', REGISTRY_USER='test', REGISTRY_TOKEN='not-a-secret',
CI_PUBLISH_LOCK_DIR=str(self.root / 'lock'), FAKE_STATE=str(self.state))
def run_script(self, script='publish.sh', **extra):
return subprocess.run(['bash', str(self.root / 'ci' / script)], cwd=self.root,
env=dict(self.env, **extra), capture_output=True, text=True, timeout=20)
def calls(self, command):
return [a for a in json.loads(self.state.read_text())['calls'] if a[0] == command]
def test_feature_branch_does_not_contact_registry(self):
result = self.run_script(CI_REF='refs/heads/feature/test')
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([], self.calls('login'))
self.assertEqual([], self.calls('push'))
def test_unverified_commit_cannot_publish(self):
(self.root / 'ci/artifacts/verified-commit').unlink()
self.assertNotEqual(0, self.run_script().returncode)
self.assertEqual([], self.calls('push'))
def test_publish_pull_and_rerun_preserves_commit_tags(self):
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(4, len(self.calls('push')))
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(6, len(self.calls('push'))) # only main aliases on rerun
self.assertEqual(8, len(self.calls('pull')))
def test_partial_publication_is_completed_without_overwriting_backend(self):
self.assertNotEqual(0, self.run_script(FAKE_PUSH_FAILURE='frontend:' + SHA).returncode)
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
backend_pushes = [a for a in self.calls('push') if a[-1] == REGISTRY + 'backend:' + SHA]
self.assertEqual(1, len(backend_pushes))
def test_registry_auth_failure_is_not_treated_as_missing_image(self):
self.assertNotEqual(0, self.run_script(FAKE_MANIFEST_ERROR='1').returncode)
self.assertEqual([], self.calls('push'))
def test_pull_failure_blocks_main_aliases(self):
self.assertNotEqual(0, self.run_script(FAKE_PULL_FAILURE='backend:' + SHA).returncode)
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
def test_old_commit_does_not_move_main(self):
result = self.run_script(FAKE_MAIN_HEAD='c' * 40)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(2, len(self.calls('push')))
self.assertTrue(all(a[-1].endswith(':' + SHA) for a in self.calls('push')))
def test_architecture_mismatch_fails_before_publication(self):
self.assertNotEqual(0, self.run_script(TARGET_PLATFORM='linux/arm64').returncode)
self.assertEqual([], self.calls('push'))
def test_failed_verification_removes_stale_success_marker_and_never_builds_images(self):
for command, content in [('java', 'exit 0'), ('node', 'exit 0'), ('mvn', 'exit 42'),
('git', 'echo ' + SHA)]:
path = self.bin / command; path.write_text('#!/bin/sh\n' + content + '\n'); path.chmod(0o755)
result = self.run_script('verify.sh')
self.assertEqual(42, result.returncode, result.stdout + result.stderr)
self.assertFalse((self.root / 'ci/artifacts/verified-commit').exists())
self.assertEqual([], self.calls('build'))
self.assertEqual([], self.calls('push'))
if __name__ == '__main__':
unittest.main()

58
ci/verify.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
export CI=true
# Start fresh: an earlier attempt's success marker must never authorize publishing.
rm -f "$ARTIFACTS/verified-commit"
exec > >(tee "$ARTIFACTS/verify.log") 2>&1
finish() {
local status=$?
trap - EXIT
bash "$CI_ROOT/ci/cleanup.sh" || { (( status != 0 )) || status=1; }
if [[ -n "${CI_DOCKER_CONFIG:-}" ]]; then rm -rf -- "$CI_DOCKER_CONFIG"; fi
exit "$status"
}
trap finish EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
require_platform
isolate_docker_config
[[ "$(git rev-parse HEAD)" == "$CI_COMMIT" ]] || { echo 'Checkout does not match pushed commit'; exit 1; }
java -version
node --version
mvn -B -ntp clean verify
python3 -m unittest discover -s ci/tests -v
(
cd agiledancers-fe
npm ci
npm test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml
npm run build
)
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$BACKEND_IMAGE" .
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$FRONTEND_IMAGE" agiledancers-fe
touch "$ARTIFACTS/containers-started"
docker network create --label "agiledancers.ci.run=$RUN_NAME" "$RUN_NAME"
docker run -d --name "$RUN_NAME-backend" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias backend \
--memory 1g --cpus 2 "$BACKEND_IMAGE"
docker run -d --name "$RUN_NAME-frontend" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias frontend \
--memory 256m --cpus 1 "$FRONTEND_IMAGE"
# The browser, tests, readiness checks and API cleanup all use the frontend proxy.
# Install this test container's browser dependencies for the exact lockfile version.
docker run --name "$RUN_NAME-browser" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --memory 2g --cpus 2 --shm-size=1g \
--user "$(id -u):$(id -g)" --env HOME=/tmp --env CI=true \
--env PLAYWRIGHT_BASE_URL=http://frontend:8080 \
--volume "$CI_ROOT/agiledancers-fe:/workspace" --workdir /workspace \
mcr.microsoft.com/playwright:v1.64.1-noble bash -euc '
for attempt in $(seq 1 60); do
if curl --fail --silent http://frontend:8080/api/dance-schools >/dev/null; then break; fi
if [ "$attempt" = 60 ]; then echo "Application readiness timed out" >&2; exit 1; fi
sleep 2
done
npm run e2e
'
printf '%s\n' "$CI_COMMIT" > "$ARTIFACTS/verified-commit"

236
docs/gitea-ci.md Normal file
View File

@@ -0,0 +1,236 @@
# Gitea build pipeline beheren
De workflow staat in `.gitea/workflows/build.yaml`. Elke branchpush bouwt en test
backend en frontend, bouwt twee images en draait Playwright tegen die containers.
Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats.
Tagpushes en aparte pull-requestevents starten geen extra workflow.
## Machines en voorwaarden
Gitea 1.25.4 en de registry staan op de bestaande Synology,
`http://192.168.2.28:3000`. De runner draait als gebruikersservice op de bestaande
Linux x86_64 mini-pc. Er is geen extra hardware nodig. De mini-pc moet aanstaan en
wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
runner alleen voor deze vertrouwde repository.
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm,
Python 3, curl, flock (util-linux), timeout (coreutils) en een werkende Docker-daemon
nodig. De huidige mini-pc heeft de ontwikkeltools en Docker Desktop al geïnstalleerd.
De workflow gebruikt één job tegelijk (`capacity: 1`), één Playwright-worker en
CPU/geheugenlimieten voor de tijdelijke applicatie- en browsercontainers.
### Doelarchitectuur eerst vaststellen
Controleer op de Synology via een bestaande beheerverbinding:
```sh
uname -m
docker info --format '{{.OSType}}/{{.Architecture}}'
```
`x86_64`/`amd64` betekent `linux/amd64`; `aarch64`/`arm64` betekent `linux/arm64`.
Leg de werkelijke uitvoer, datum en het Synology-model vast bij de oplevering en
stel repositoryvariabele `TARGET_PLATFORM` daarop in. De workflow weigert een
ontbrekende waarde en een verschil met de Docker-daemon van de runner; hij doet
dus geen stilzwijgende aanname over de NAS en gebruikt geen ongeteste emulatie.
Een mismatch vereist een geteste oplossing op de bestaande machines voordat S4
afgerond kan worden; er worden geen multi-architecture images gebouwd.
### Docker op de mini-pc
Start de al aanwezige Docker Desktop via de desktop of:
```sh
systemctl --user start docker-desktop
docker --context desktop-linux info
```
Bij Docker Desktop configureer je onder **Settings → Docker Engine** de inhoud
van `ci/runner/docker-daemon.json`, samengevoegd met bestaande instellingen.
Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en
backup via:
```sh
python3 ci/runner/configure-docker.py "$HOME/.docker/daemon.json"
```
Voor Docker Engine op Linux is dat dezelfde toevoeging aan
`/etc/docker/daemon.json` (met de daarvoor benodigde beheerrechten). Overschrijf geen andere daemoninstellingen. Pas de
wijziging toe/herstart de daemon en controleer met `docker info` dat
`192.168.2.28:3000` onder de insecure registries staat. Configureer ook andere
Docker-clients die images van deze interne HTTP-registry moeten ophalen.
Gebruik de Docker Desktop-context of zet `DOCKER_HOST` expliciet. De installer
neemt de geselecteerde socket over in de gebruikersservice. Als de socket later
wijzigt, pas dan `~/.config/agiledancers-runner/environment` aan en herstart de
runner. Bij Docker Desktop blijft de Desktop-VM eveneens nodig voor builds.
Runner en containers moeten de Synology en internetdownloads kunnen bereiken.
## Gitea-instellingen en credentials
Repository: `wanjakrah/agiledancers`, hoofdbranch: `main`.
Actions staat voor deze repository al aan. Controleer zo nodig op de Synology
`app.ini` onder `[actions]` de instelling `ENABLED = true` en de Actions-feature
onder repositoryinstellingen. Stel `SKIP_WORKFLOW_STRINGS` leeg in als ook commits met teksten als `[skip ci]`
verplicht moeten draaien, zoals de afspraak elke branchpush vereist.
Wijzig serverconfiguratie alleen indien nodig.
Stel bij repository **Settings → Actions** in:
| Soort | Naam | Waarde/doel |
| --- | --- | --- |
| Variabele | `TARGET_PLATFORM` | Vastgestelde Synology-platformwaarde |
| Secret | `REGISTRY_USER` | Gitea-gebruiker die in namespace `wanjakrah` mag publiceren |
| Secret | `REGISTRY_TOKEN` | PAT met `write:package` en `read:repository` voor imagepublicatie en controle van de huidige hoofdbranch |
Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT.
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie.
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
Voor reproduceerbare configuratie bestaat `ci/configure-gitea.py`. Maak twee
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie),
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
bewaard. Vul die bestanden via een lokale editor of verborgen terminalinvoer.
Voer daarna uit met de werkelijke platformwaarde:
```sh
export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/agiledancers-runner/admin-token"
export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
export REGISTRY_USER=wanjakrah
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
python3 ci/configure-gitea.py
```
Het script stelt de repository-instellingen in via de API en schrijft het
runnerregistratietoken naar
`~/.config/agiledancers-runner/registration-token` met mode `600`. Het toont geen
tokenwaarden. Voor handmatige inrichting kun je dat registratietoken uit de
repository-instellingen halen en in hetzelfde bestand plaatsen.
Controleer de artifactretentie op de server: een workflow vraagt veertien dagen
via `retention-days: 14`. Een kortere serverlimiet of het vroegtijdig verwijderen
van workflowruns mag die periode niet inkorten. Artifactretentie geldt voor
testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijderd.
## Runner installeren en starten
```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
bash ci/runner/install.sh --prepare-only
# Na Dockerconfiguratie en het opslaan van het registratietoken:
bash ci/runner/install.sh
systemctl --user status agiledancers-runner
journalctl --user -u agiledancers-runner -f
```
De installer controleert tools, installeert checksum-gecontroleerde runner
`v0.2.13`, neemt label `agiledancers-mini-pc:host` over en registreert de runner.
Configuratie en `.runner`-credentials staan in
`~/.local/share/agiledancers-runner`, de service in
`~/.config/systemd/user/agiledancers-runner.service` en de expliciete Java-, PATH-
en Docker-socketinstellingen in `~/.config/agiledancers-runner/environment`.
Een bestaande registratie wordt hergebruikt. Er worden geen secrets in de
repository geschreven. Bewaar het tokenbestand alleen zolang registratie/herstel
dat nodig heeft en behandel `.runner` als credential.
De gebruikersservice start bij een gebruikerssessie. Automatisch doorlopen zonder
inloggen vereist daarnaast een bewuste keuze voor user lingering en een Docker-
daemon die zonder desktopsessie beschikbaar is. S4 belooft geen beschikbaarheid
wanneer de mini-pc uitstaat. Herstarten/stoppen:
```sh
systemctl --user restart agiledancers-runner
systemctl --user stop agiledancers-runner
```
## Images en publicatie
| Component | Image |
| --- | --- |
| Backend | `192.168.2.28:3000/wanjakrah/agiledancers-backend` |
| Frontend | `192.168.2.28:3000/wanjakrah/agiledancers-frontend` |
De volledige commit-SHA is de versie van beide images. Een `main`-tag wijst naar
de nieuwste met succes gepubliceerde actuele hoofdbranchbuild. Gebruik voor een
exact passend paar op beide images dezelfde SHA:
```sh
# Gebruik bij private packages eerst docker login met interactieve invoer.
commit_sha=<volledige-commit-SHA>
docker pull "192.168.2.28:3000/wanjakrah/agiledancers-backend:$commit_sha"
docker pull "192.168.2.28:3000/wanjakrah/agiledancers-frontend:$commit_sha"
```
De frontend luistert op containerpoort 8080, ondersteunt SPA-routes en proxy't
`/api/` naar `BACKEND_UPSTREAM` (standaard `backend:8080`). De backend luistert op
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
de pipeline start geen blijvende applicatie op de NAS.
`ci/verify.sh` maakt een succesmarker pas na alle builds en tests.
`ci/publish.sh` vereist die marker en hoofdbranchref. Onder een gedeeld `flock`
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
niet als een ontbrekende tag.
Pas na beide images worden `main`-tags bijgewerkt, met een actuele hoofdbranch-
controle voor iedere wijziging. De gedeelde lock voorkomt dat een oudere pipeline
een door een nieuwere pipeline bijgewerkte tag terugzet. `capacity: 1` ondersteunt
dat op de enige runner. Vertrouw niet op workflow-`concurrency`: Gitea 1.25 negeert
die instelling. De twee bewegende tags zijn geen atomisch paar.
## Diagnostiek en herstellen
Iedere workflowpoging bewaart beschikbare diagnostiek als
`diagnostics-<run-id>-<attempt>` gedurende veertien dagen: Maven-rapporten, JUnit-
rapporten van frontend/Playwright, het Playwright HTML-rapport, traces/screenshots
en containerlogs. Open de artifactdownload op de Gitea Actions-runpagina.
Build- en publicatielogs staan er eveneens in. Geen environmentdump of Docker
inspect met mogelijke credentials wordt opgenomen.
Bij mislukking blijven de oorspronkelijke exitstatus en rapporten behouden.
Een trap én een `always()`-stap verzamelen diagnostiek en verwijderen de tijdelijke
containers en het netwerk met hun unieke runnaam. Een harde machine-uitval kan
opruimen onderbreken: controleer daarna uitsluitend achtergebleven resources met
label `agiledancers.ci.run`; gebruik geen algemene Docker-prune op de ontwikkel-PC.
Herstart een mislukte run vanuit Gitea. Als slechts één commitimage gepubliceerd
was, bewaart de herhaling die en vult de ontbrekende image aan. Een fout bij
publiceren, terughalen of bijwerken van `main` laat de pipeline falen. Bestaande
commitimages worden niet overschreven. Bij een inmiddels nieuwere hoofdbranch
blijven de committags bruikbaar en worden de oude `main`-updates overgeslagen.
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
```sh
python3 -m unittest discover -s ci/tests -v
```
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
bash ci/verify.sh
```
## Operationele oplevering
Leg links/run-ID's en beide image-digests vast in `docs/s4-validation.md` voor:
1. Een geslaagde feature-branchpush, inclusief twee builds en container-E2E,
zonder imagepublicatie.
2. Een expres mislukte build/test op een tijdelijke branch die publicatie blokkeert,
met downloadbare foutdiagnostiek. Herstel die testwijziging daarna.
3. Een geslaagde hoofdbranchrun met beide teruggehaalde commitimages.
4. Een herhaling/partiële-publicatieherstel en bewijs dat bestaande committags
behouden blijven en oudere builds `main` niet terugzetten.
Zonder werkende runner, vastgestelde NAS-architectuur, registrytoegang en echte
pipelinebewijzen is S4 niet volledig opgeleverd.
Bronnen: [runner](https://docs.gitea.com/1.25/usage/actions/act-runner/),
[Gitea-verschillen](https://docs.gitea.com/1.25/usage/actions/comparison/),
[containerregistry](https://docs.gitea.com/1.25/usage/packages/container/).

75
docs/s4-validation.md Normal file
View File

@@ -0,0 +1,75 @@
# S4 verification evidence
Date: 2026-10-28. Status: repository implementation and local container verification
passed; operational Gitea acceptance remains pending. S4 is **not complete**.
## Verified locally
- Gitea reports version 1.25.4; repository `wanjakrah/agiledancers` has Actions
enabled and uses `main` as its default branch.
- Docker Desktop was started on the existing Linux x86_64 mini-PC. Its Docker
daemon reports `linux/x86_64` (`linux/amd64`). This is not evidence of the
Synology's architecture.
- `mvn clean verify` passed, including all 20 backend tests and architecture tests.
- `npm ci`, all 14 frontend unit tests with JUnit output, and the production build
passed in the full verification script.
- Both runtime images built successfully with the `prebuilt` targets. The images
package the same outputs that passed verification.
- Both existing Playwright tests passed (2/2) using the built frontend and backend
containers, the frontend `/api/` proxy and Chromium in the version-matched
Playwright container. No Angular development server was used.
- The full `ci/verify.sh` execution exited successfully; its trap removed all three
temporary test containers and the run network.
- Eleven isolated CI tests pass: failed verification removes its success marker,
feature branches do not publish, unverified commits cannot publish, reruns
preserve commit tags, partial publication is recoverable, authentication and
pull failures block publication, older commits do not move `main`, platform
mismatches fail, and daemon configuration preserves existing settings.
- Shell syntax, Python compilation, workflow YAML parsing and the changed files'
whitespace checks passed.
- Runner v0.2.13 was downloaded from the official source, SHA-256 checked, and
installed with its user-service configuration using `--prepare-only`.
The runner was subsequently registered and its user service started using the
supplied repository administration credentials.
- The internal registry was merged into Docker Desktop's daemon configuration,
preserving existing settings and a backup. Docker Desktop was restarted and
the running daemon confirmed that `192.168.2.28:3000` is an active HTTP registry.
Local outputs are in `ci/artifacts/` (ignored by Git): verify log, backend test
reports, frontend unit JUnit report, Playwright JUnit/HTML report and container
logs. These are local files, not yet artifacts uploaded by a real Gitea run.
The unit JUnit report uses a separate directory so Playwright output cleanup
cannot delete it.
The local test used working-tree changes on base commit
`e3e362c2ac30d6b365f752f3da6c8b0250c019db`, with run ID `s4local`. These local
images are test outputs, not published release images for that commit.
Local image IDs (not registry digests):
- Backend: `sha256:bd77a46e3dbdcaea9662d21c608098999fe17dfdf241cd28a24a935f47401f61`.
- Frontend: `sha256:ce8e6ab21fb8f9cbee280ac2c4d7b4ca24a30e899a457382f1e45c6baf99dfb6`.
## Remaining operational acceptance
- [x] Synology architecture confirmed by the maintainer: `x86_64`,
`linux/amd64`; repository variable `TARGET_PLATFORM=linux/amd64` configured.
- [x] Activate and verify the Docker Desktop HTTP-registry setting.
- [x] Repository `REGISTRY_USER`/`REGISTRY_TOKEN` secrets configured using the
supplied token files. Registry access is verified by actual publication below.
- [ ] Obtain a repository runner registration token and register/start the
installed mini-PC runner; verify it is online in Gitea.
- [ ] Confirm server settings permit every branchpush (including messages with
skip strings) and retain uploaded diagnostic artifacts for fourteen days.
- [ ] Real feature-branch run: record URL/run ID, successful container-E2E and
absence of publication.
- [ ] Real intentionally failing test/build run: record URL/run ID, blocked
publication and downloadable failure artifacts.
- [ ] Real main run: record URL/run ID, both full-SHA images, successful pullback,
registry digests and the two `main` aliases.
- [ ] Real rerun/partial-publication recovery and stale-main evidence: record
run IDs and unchanged existing commit digests.
The isolated publication tests provide automated control-flow coverage; they do
not substitute for real authenticated registry/Gitea acceptance runs. No application
images have been published and no application has been deployed by this work.

View File

@@ -0,0 +1,181 @@
# S4 — Build and publish pipelines in Gitea
Status: IN_PROGRESS
Agreed after the grill-me review; shared understanding confirmed.
## User story
As a maintainer, I want every branch push to build and test the application,
so that broken changes are detected and successful main-branch builds provide
identifiable Docker images in our own registry.
## Agreed environment and feasibility
- Gitea and its container registry run on the existing Synology.
- The existing development mini-PC runs the Gitea Actions runner, separately from
Gitea. No additional hardware is available or required.
- Gitea supports push-triggered Actions workflows and a runner on a separate
machine. Codex can implement the repository configuration and assist with the
installation and configuration required on the existing machines.
- Gitea was inspected as version 1.25.4. Match the runner and workflow setup to
the installed version rather than assuming newer documentation applies.
- The mini-PC was inspected as Linux x86_64 with a Docker client installed, but
without a reachable Docker daemon. Configuring a working daemon is part of S4.
- The current internal Gitea/registry endpoint is `http://192.168.2.28:3000`.
Use that endpoint over the trusted internal network and explicitly configure
Docker clients that need registry access to trust this particular HTTP registry.
Introducing HTTPS is outside S4.
- The runner and job containers need access to Gitea, the registry and dependency
download sources. The runner connects to Gitea; it needs no public incoming port.
- The mini-PC must be on, awake and running the runner. Jobs wait while it is
unavailable; S4 does not promise unattended availability when the PC is off.
References:
- [Gitea 1.25 Actions quickstart](https://docs.gitea.com/1.25/usage/actions/quickstart/)
- [Gitea 1.25 Actions design](https://docs.gitea.com/1.25/usage/actions/design/)
- [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
## Scope
### In scope
- Version-controlled Gitea Actions workflows and supporting build configuration.
- Enable Actions as needed at instance and repository level, configure Docker on
the mini-PC, and install, register and configure its runner.
- Build and test both backend and frontend for every push to every branch,
including newly created branches. Separate tag and pull-request event workflows
are not required; branch pushes also check changes used in pull requests.
- Build two runnable Docker images: backend and frontend.
- Run end-to-end tests against those built containers before publication.
- Publish both images only from successful builds of the `main` branch to the
container registry on the Synology.
- Configure registry authentication and the necessary secrets outside Git.
- Preserve failure diagnostics and document operation and recovery.
- Demonstrate a real successful pipeline and images that can be pulled back.
### Out of scope
- Automatic deployment, starting application containers on the Synology, and OTAP.
Starting temporary containers for pipeline tests is in scope.
- New linting or static-analysis gates, such as ESLint or Sonar. Existing tests,
including architecture tests, remain mandatory.
- Merge or release policies, separate tag/release workflows, and automatic image
deletion or retention cleanup. Published images remain available for now.
- Multi-architecture images, HTTPS setup, new hardware or provisioning additional
infrastructure.
## Build and verification behavior
1. Every branch push runs backend and frontend builds and their existing tests.
2. Backend verification includes the existing unit, integration/system and
architecture tests. Current entry point: `mvn clean verify` using Java 25.
3. Frontend verification uses the lockfile (`npm ci`), unit tests (`npm test`) and
production build (`npm run build`) with a supported Node version.
4. Build both Docker images on every branch, including branches that cannot
publish. The current Dockerfile only covers the backend; frontend packaging
must be added as part of implementation.
5. The frontend image serves the production Angular build, supports direct SPA
route navigation, and forwards `/api` requests to the backend container.
Container addressing/configuration is an implementation choice.
6. Run the existing Playwright end-to-end suite with Chromium against the built
frontend/backend containers, through the frontend's API forwarding. Adapt the
existing development-server test configuration as necessary: the acceptance
run must exercise the actual images, not development servers.
7. Determine the intended server's CPU architecture during implementation. Build
for that single target architecture and run the end-to-end tests on that same
architecture. Do not infer the Synology's architecture from the mini-PC.
Any architecture mismatch must be resolved and tested on the existing hardware
before claiming acceptance.
8. A failed build or test fails the pipeline and prevents publication. Test
containers are temporary and are cleaned up after the run, including failure.
## Publication and image identity
- Publish separate backend and frontend images under the repository owner's
namespace in the existing Gitea container registry. Document the exact names.
- Feature branches must not publish application images.
- Both images from a main-branch commit use the full Git commit SHA as their tag.
This identifies the matching pair independently of moving tags.
- Once both commit images have been successfully published, reruns verify/reuse
them without overwriting either commit tag.
- Publication succeeds only when both images are published and can be pulled
back from the registry. A partial publication fails the pipeline; one image may
already exist. A rerun preserves that image and may publish the missing image.
- After both commit images are available, update the moving `main` tags only if
the commit is still the current main-branch head. Serialize/coordinate that
operation so an older, slower pipeline cannot move `main` backwards.
- The two moving tags are not an atomic pair. Use the same commit SHA on both
images when an exact matching pair is needed. A failure updating a moving tag
must be visible as a pipeline failure and recoverable through a rerun.
- No automatic deletion of published images is introduced in S4.
## Failure diagnostics and operation
- Failed pipelines retain available test reports, container logs, and Playwright
traces and screenshots for fourteen days. Collect available diagnostics even
when a preceding step fails; do not hide the original failure.
- Provide a short management guide covering:
- Docker/runner installation, registration, startup and required access.
- Gitea instance/repository Actions settings and registry configuration.
- Required credentials, permissions and secret names, without secret values.
- Which settings exist outside Git and how to reproduce them.
- Trigger behavior, mini-PC availability and queued jobs.
- Image names, tags and commands to pull a matching image pair.
- Finding diagnostics, rerunning failed jobs and completing partial publication.
- The target architecture and how it was established.
- Keep registry credentials and registration tokens out of repository files and
logs; use the relevant secret settings.
## Acceptance criteria
- [ ] A push to any branch, including a new branch, starts a pipeline.
- [ ] Backend and frontend build and all existing tests, including architecture
and Playwright end-to-end tests, pass before publication is permitted.
- [ ] Every branch builds both Docker images; end-to-end tests exercise those
images through the frontend and its `/api` forwarding.
- [ ] A failing build or test fails the pipeline without publishing images.
- [ ] A successful feature-branch pipeline publishes no application images.
- [ ] A successful `main` pipeline publishes both images to the Synology registry
with the full commit SHA; both images can be pulled back.
- [ ] Published commit images are preserved on rerun; partial publication can be
completed without overwriting an existing commit image.
- [ ] Moving `main` tags are updated only after both commit images are available,
and an older pipeline cannot move them backwards.
- [ ] Publication or pull-verification failure makes the pipeline fail, even if
some images have already been uploaded.
- [ ] Available failure diagnostics are retained for fourteen days, and temporary
test containers are cleaned up on success and failure.
- [ ] The runner operates on the existing mini-PC, with Gitea and the registry on
the existing Synology; no additional hardware is needed.
- [ ] The single target architecture is established and the images are tested on
that architecture using the existing hardware.
- [ ] A management guide records setup, external settings, secrets, image usage
and recovery, including the need to keep the mini-PC/runner available.
- [ ] Actual pipeline runs demonstrate successful main-branch publication,
feature-branch checks without publication, and failure blocking publication.
## Implementation handoff
This specification captures the agreed design. It does not install the runner,
change server settings or implement the pipeline. S4 is complete only when the
configuration, documentation and operational acceptance evidence are delivered;
workflow files alone are insufficient.
## Implementation progress
Repository implementation and local Docker verification are available. See
[management instructions](../../docs/gitea-ci.md) and
[validation evidence](../../docs/s4-validation.md).
Backend verification (20 tests), frontend verification (14 tests), both runtime
image builds and both Playwright tests against the containers passed. Eleven
isolated CI publication/configuration tests pass. The runner binary and user-service
configuration are installed on the mini-PC.
Operational acceptance is still pending: authenticated Synology architecture
verification, Gitea credentials/secrets, runner registration/startup and real
feature/failure/main pipeline runs with registry pullback evidence. The spec stays
IN_PROGRESS until those requirements are met.