From d74822cd27f71f159836a7043ec34428797527b3 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 23:28:56 +0200 Subject: [PATCH] Add CI and deploy summaries --- .gitea/runner/README.md | 4 ++- .gitea/workflows/deploy-controller.py | 41 ++++++++++++++++++++++++++- .gitea/workflows/release.py | 21 ++++++++++++++ .gitea/workflows/ssh-run.sh | 26 ++++++++++++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.gitea/runner/README.md b/.gitea/runner/README.md index f5b06b1..91b9046 100644 --- a/.gitea/runner/README.md +++ b/.gitea/runner/README.md @@ -3,7 +3,9 @@ The native Gitea runner runs on **vps**; production runs on **workstation**. Compose, workflow, shell, Python, formatting, YAML, Dockerfile and Kubernetes checks appear as separate jobs. Jobs run on `homelab:host`, one at a time; the -build waits for every check to pass. No job images or Kubernetes credentials +build waits for every check to pass. CI and deploy runs also show a summary with +the release SHA, image build or reuse results, deploy mode, selected services, +and image digests. No job images or Kubernetes credentials are needed on the VPS. Builds use one pinned BuildKit helper container. CI and deploy are separate workflows. ## Runner installation diff --git a/.gitea/workflows/deploy-controller.py b/.gitea/workflows/deploy-controller.py index 432f672..4bf80f5 100644 --- a/.gitea/workflows/deploy-controller.py +++ b/.gitea/workflows/deploy-controller.py @@ -294,10 +294,47 @@ def follow(run_id, phase): time.sleep(3) +def summary(run_id): + directory = run_directory(run_id) + request = json.loads((directory / 'request.json').read_text()) + release = request['release'] + plan_file = directory / 'plan.json' + lines = [ + f'## Deploy `{release["sha"]}`', + '', + f'- Mode: `{request["mode"]}`', + f'- Refresh third-party images: `{request["refresh_images"]}`', + ] + if not plan_file.exists(): + lines.extend(['', 'Plan was not created. Check the controller log.']) + print('\n'.join(lines)) + return + plan = json.loads(plan_file.read_text()) + lines.extend(['', '### Selected services']) + count = 0 + for kind, services in plan['selected'].items(): + for service in services: + lines.append(f'- `{kind}`: `{service}`') + count += 1 + if not count: + lines.append('- None') + lines.extend(['', '### Selected Helm releases']) + lines.extend(f'- `{release}`' for release in plan.get('helm', [])) + if not plan.get('helm'): + lines.append('- None') + lines.extend(['', '### Images pinned in the checked release']) + lines.extend(f'- `{image}@{digest}`' for image, digest in sorted(release['images'].items())) + lines.extend(['', '### Removed resources requiring manual review']) + lines.extend(f'- `{item}`' for item in plan.get('removed', [])) + if not plan.get('removed'): + lines.append('- None') + print('\n'.join(lines)) + + def main(): os.umask(0o077) parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=('start', 'execute', 'recover', 'status', 'follow')) + parser.add_argument('action', choices=('start', 'execute', 'recover', 'status', 'follow', 'summary')) parser.add_argument('run_id') parser.add_argument('phase', nargs='?', choices=('apply', 'verify', 'smoke')) parser.add_argument('--retry', action='store_true', help='Retry failed recovery checks; never repeat apply') @@ -315,6 +352,8 @@ def main(): if (directory / 'plan.json').exists(): plan = json.loads((directory / 'plan.json').read_text()) print(json.dumps({k: plan[k] for k in ('sha', 'selected', 'helm', 'removed')}, indent=2)) + elif args.action == 'summary': + summary(args.run_id) elif not follow(args.run_id, args.phase): sys.exit(1) diff --git a/.gitea/workflows/release.py b/.gitea/workflows/release.py index daff5aa..cc9e9bd 100644 --- a/.gitea/workflows/release.py +++ b/.gitea/workflows/release.py @@ -231,6 +231,8 @@ def build(output): ) signature.write_text(image + '\n') release = {'version': 1, 'sha': sha, 'images': {}, 'inputs': {}} + built = [] + reused = [] for name, (context, dockerfile) in IMAGES.items(): image = f'gcr.forust.xyz/forust/{name}' inputs = fingerprint(context, dockerfile) @@ -255,8 +257,10 @@ def build(output): if exists: print(f'Reuse {name}: inputs unchanged') digest = old_digest + reused.append((name, image, digest)) else: print(f'Build {name}', flush=True) + built.append((name, image)) metadata = Path(docker_config) / 'metadata.json' command( 'docker', @@ -286,6 +290,23 @@ def build(output): release['inputs'][image] = inputs validate_release(release, sha) output.write_text(json.dumps(release, indent=2) + '\n') + summary = os.environ.get('GITHUB_STEP_SUMMARY') + if summary: + lines = [f'## Image release for `{sha}`', '', '### Built'] + lines.extend(f'- `{name}` — `{image}`' for name, image in built) + if not built: + lines.append('- None') + lines.extend(['', '### Reused from successful CI']) + lines.extend(f'- `{name}` — `{image}@{digest}`' for name, image, digest in reused) + if not reused: + lines.append('- None') + lines.extend(['', '### Release digests']) + lines.extend( + f'- `{name}` — `{image}@{release["images"][image]}`' + for name in IMAGES + for image in [f'gcr.forust.xyz/forust/{name}'] + ) + Path(summary).write_text('\n'.join(lines) + '\n') finally: # Cleanup errors must neither leak credentials nor mask the original build error. try: diff --git a/.gitea/workflows/ssh-run.sh b/.gitea/workflows/ssh-run.sh index cfc94dc..8caecfa 100755 --- a/.gitea/workflows/ssh-run.sh +++ b/.gitea/workflows/ssh-run.sh @@ -40,7 +40,31 @@ PY done exit "$rc" ;; - apply|verify|smoke) + apply) + result=0 + for attempt in 1 2 3; do + rc=0 + # shellcheck disable=SC2029 # The run ID and operation are validated local arguments, not remote variables. + ssh "${ssh_opts[@]}" "$DEPLOY_USER@$DEPLOY_HOST" python3 "$controller" follow "$DEPLOY_RUN_ID" apply || rc=$? + [ "$rc" -eq 0 ] && break + [ "$rc" -eq 255 ] || { result="$rc"; break; } + echo "SSH disconnected; reconnecting to the existing deploy ($attempt/3)" + if [ "$attempt" -eq 3 ]; then result=255; break; fi + sleep 5 + done + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + rc=0 + # shellcheck disable=SC2029 # The run ID is validated above. + ssh "${ssh_opts[@]}" "$DEPLOY_USER@$DEPLOY_HOST" python3 "$controller" summary "$DEPLOY_RUN_ID" >"$key_dir/deploy-summary.md" || rc=$? + if [ "$rc" -eq 0 ]; then + cat "$key_dir/deploy-summary.md" >>"$GITHUB_STEP_SUMMARY" + else + echo 'Deploy summary is unavailable. Check the controller log.' >>"$GITHUB_STEP_SUMMARY" + fi + fi + exit "$result" + ;; + verify|smoke) for attempt in 1 2 3; do rc=0 # shellcheck disable=SC2029 # The run ID and operation are validated local arguments, not remote variables.