Add CI and deploy summaries
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / YAML (push) Skipped
ci / Kubernetes (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Compose (pull_request) Successful in 14s
ci / Workflows (pull_request) Successful in 7s
ci / Shell (pull_request) Successful in 23s
ci / Formatting (pull_request) Successful in 26s
ci / Python and tests (pull_request) Successful in 8s
ci / YAML (pull_request) Successful in 14s
ci / Dockerfiles (pull_request) Successful in 7s
ci / Kubernetes (pull_request) Successful in 10s
ci / build (pull_request) Skipped

This commit is contained in:
forust committed 2026-10-06 23:28:56 +02:00
1 parent b677d553b4
commit d74822cd27
4 files changed
+89 -3

No files matched your search

+3 -1
View File
@@ -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
+40 -1
View File
@@ -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)
+21
View File
@@ -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:
+25 -1
View File
@@ -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.