From 74a5aa12377d4a62cdebe1dcd6e8a8e1443b33fb Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Wed, 28 Oct 2026 17:07:23 +0100 Subject: [PATCH] Add Gitea build, container tests and registry publication --- .dockerignore | 13 + .gitea/workflows/build.yaml | 41 +++ .gitignore | 11 +- Dockerfile | 11 +- README.md | 8 + agiledancers-fe/.dockerignore | 7 + agiledancers-fe/Dockerfile | 18 ++ agiledancers-fe/e2e/school-management.spec.ts | 2 +- agiledancers-fe/nginx/default.conf.template | 18 ++ agiledancers-fe/playwright.config.ts | 26 +- ci/cleanup.sh | 29 +++ ci/common.sh | 40 +++ ci/configure-gitea.py | 67 +++++ ci/main-head.py | 16 ++ ci/publish.sh | 76 ++++++ ci/runner/agiledancers-runner.service | 17 ++ ci/runner/config.yaml | 11 + ci/runner/configure-docker.py | 32 +++ ci/runner/docker-daemon.json | 3 + ci/runner/install.sh | 52 ++++ ci/tests/test_docker_configuration.py | 29 +++ ci/tests/test_publication.py | 115 +++++++++ ci/verify.sh | 58 +++++ docs/gitea-ci.md | 236 ++++++++++++++++++ docs/s4-validation.md | 75 ++++++ .../S4_create-build-pipeline-at-gitea | 181 ++++++++++++++ 26 files changed, 1180 insertions(+), 12 deletions(-) create mode 100644 .gitea/workflows/build.yaml create mode 100644 agiledancers-fe/.dockerignore create mode 100644 agiledancers-fe/Dockerfile create mode 100644 agiledancers-fe/nginx/default.conf.template create mode 100644 ci/cleanup.sh create mode 100644 ci/common.sh create mode 100644 ci/configure-gitea.py create mode 100644 ci/main-head.py create mode 100644 ci/publish.sh create mode 100644 ci/runner/agiledancers-runner.service create mode 100644 ci/runner/config.yaml create mode 100644 ci/runner/configure-docker.py create mode 100644 ci/runner/docker-daemon.json create mode 100644 ci/runner/install.sh create mode 100644 ci/tests/test_docker_configuration.py create mode 100644 ci/tests/test_publication.py create mode 100644 ci/verify.sh create mode 100644 docs/gitea-ci.md create mode 100644 docs/s4-validation.md create mode 100644 specs/in_progress/S4_create-build-pipeline-at-gitea diff --git a/.dockerignore b/.dockerignore index 85df4cf..d5c390e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,16 @@ .idea .mvn *.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 diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..6061887 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore index c2e74c4..0505069 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ *.iml .idea -target \ No newline at end of file +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__/ diff --git a/Dockerfile b/Dockerfile index b348173..9f31df8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,16 @@ COPY agiledancers-be/pom.xml agiledancers-be/pom.xml COPY agiledancers-be/src agiledancers-be/src RUN mvn -B -ntp clean verify -FROM eclipse-temurin:25-jre +FROM eclipse-temurin:25-jre AS runtime WORKDIR /app -COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./ USER 10001:10001 EXPOSE 8080 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/ ./ diff --git a/README.md b/README.md index 941b1f0..bc53d4c 100644 --- a/README.md +++ b/README.md @@ -168,3 +168,11 @@ Open . The development proxy forwards API requests to the 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 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. diff --git a/agiledancers-fe/.dockerignore b/agiledancers-fe/.dockerignore new file mode 100644 index 0000000..811e4ff --- /dev/null +++ b/agiledancers-fe/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.angular +.git +test-results +playwright-report + +unit-results diff --git a/agiledancers-fe/Dockerfile b/agiledancers-fe/Dockerfile new file mode 100644 index 0000000..d3a0391 --- /dev/null +++ b/agiledancers-fe/Dockerfile @@ -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/ diff --git a/agiledancers-fe/e2e/school-management.spec.ts b/agiledancers-fe/e2e/school-management.spec.ts index 9b84e9d..2405a05 100644 --- a/agiledancers-fe/e2e/school-management.spec.ts +++ b/agiledancers-fe/e2e/school-management.spec.ts @@ -1,6 +1,6 @@ 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; test.afterEach(async ({ request }) => { diff --git a/agiledancers-fe/nginx/default.conf.template b/agiledancers-fe/nginx/default.conf.template new file mode 100644 index 0000000..f616435 --- /dev/null +++ b/agiledancers-fe/nginx/default.conf.template @@ -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; + } +} diff --git a/agiledancers-fe/playwright.config.ts b/agiledancers-fe/playwright.config.ts index 282a1db..2870f01 100644 --- a/agiledancers-fe/playwright.config.ts +++ b/agiledancers-fe/playwright.config.ts @@ -1,15 +1,23 @@ import { defineConfig, devices } from '@playwright/test'; +const externalURL = process.env['PLAYWRIGHT_BASE_URL']; + export default defineConfig({ testDir: './e2e', fullyParallel: false, workers: 1, retries: 0, timeout: 60_000, - reporter: 'list', + reporter: process.env['CI'] + ? [ + ['list'], + ['junit', { outputFile: 'test-results/playwright.xml' }], + ['html', { open: 'never' }], + ] + : 'list', use: { ...devices['Desktop Chrome'], - baseURL: 'http://127.0.0.1:4200', + baseURL: externalURL || 'http://127.0.0.1:4200', trace: 'retain-on-failure', actionTimeout: 15_000, screenshot: 'only-on-failure', @@ -17,10 +25,12 @@ export default defineConfig({ ? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] } : {}, }, - webServer: { - command: 'npm start -- --host 127.0.0.1 --port 4200', - url: 'http://127.0.0.1:4200', - reuseExistingServer: !process.env['CI'], - timeout: 60_000, - }, + webServer: externalURL + ? undefined + : { + command: 'npm start -- --host 127.0.0.1 --port 4200', + url: 'http://127.0.0.1:4200', + reuseExistingServer: !process.env['CI'], + timeout: 60_000, + }, }); diff --git a/ci/cleanup.sh b/ci/cleanup.sh new file mode 100644 index 0000000..78d6f92 --- /dev/null +++ b/ci/cleanup.sh @@ -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" diff --git a/ci/common.sh b/ci/common.sh new file mode 100644 index 0000000..db7e0c9 --- /dev/null +++ b/ci/common.sh @@ -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 +} diff --git a/ci/configure-gitea.py b/ci/configure-gitea.py new file mode 100644 index 0000000..76873bb --- /dev/null +++ b/ci/configure-gitea.py @@ -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) diff --git a/ci/main-head.py b/ci/main-head.py new file mode 100644 index 0000000..b994bf0 --- /dev/null +++ b/ci/main-head.py @@ -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) diff --git a/ci/publish.sh b/ci/publish.sh new file mode 100644 index 0000000..9fff953 --- /dev/null +++ b/ci/publish.sh @@ -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 diff --git a/ci/runner/agiledancers-runner.service b/ci/runner/agiledancers-runner.service new file mode 100644 index 0000000..29d4dd6 --- /dev/null +++ b/ci/runner/agiledancers-runner.service @@ -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 diff --git a/ci/runner/config.yaml b/ci/runner/config.yaml new file mode 100644 index 0000000..8654efa --- /dev/null +++ b/ci/runner/config.yaml @@ -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 diff --git a/ci/runner/configure-docker.py b/ci/runner/configure-docker.py new file mode 100644 index 0000000..ff26e71 --- /dev/null +++ b/ci/runner/configure-docker.py @@ -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 ') +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') diff --git a/ci/runner/docker-daemon.json b/ci/runner/docker-daemon.json new file mode 100644 index 0000000..5b900c3 --- /dev/null +++ b/ci/runner/docker-daemon.json @@ -0,0 +1,3 @@ +{ + "insecure-registries": ["192.168.2.28:3000"] +} diff --git a/ci/runner/install.sh b/ci/runner/install.sh new file mode 100644 index 0000000..1894392 --- /dev/null +++ b/ci/runner/install.sh @@ -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.' diff --git a/ci/tests/test_docker_configuration.py b/ci/tests/test_docker_configuration.py new file mode 100644 index 0000000..6f5f40a --- /dev/null +++ b/ci/tests/test_docker_configuration.py @@ -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()) diff --git a/ci/tests/test_publication.py b/ci/tests/test_publication.py new file mode 100644 index 0000000..1cc8726 --- /dev/null +++ b/ci/tests/test_publication.py @@ -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() diff --git a/ci/verify.sh b/ci/verify.sh new file mode 100644 index 0000000..58f962b --- /dev/null +++ b/ci/verify.sh @@ -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" diff --git a/docs/gitea-ci.md b/docs/gitea-ci.md new file mode 100644 index 0000000..9f9bc23 --- /dev/null +++ b/docs/gitea-ci.md @@ -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= +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--` 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/). diff --git a/docs/s4-validation.md b/docs/s4-validation.md new file mode 100644 index 0000000..0fdac28 --- /dev/null +++ b/docs/s4-validation.md @@ -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. diff --git a/specs/in_progress/S4_create-build-pipeline-at-gitea b/specs/in_progress/S4_create-build-pipeline-at-gitea new file mode 100644 index 0000000..1d82c45 --- /dev/null +++ b/specs/in_progress/S4_create-build-pipeline-at-gitea @@ -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.