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

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"