From 514e790a78bb4c997554f40103c2d0e14cc5fda6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 31 Oct 2026 03:21:23 +0800 Subject: [PATCH] Bound review image retention and separate CI navigation budgets --- .gitea/workflows/creative-workspace.yml | 10 ++- .../tests/guidance-workspace-ui.ts | 7 +- scripts/cleanup-review-images.sh | 18 +++++ scripts/integration.sh | 10 ++- scripts/test-review-image-cleanup.py | 73 +++++++++++++++++++ 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 scripts/cleanup-review-images.sh create mode 100644 scripts/test-review-image-cleanup.py diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index 945d15b..4a87f9c 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -11,6 +11,8 @@ on: - 'scripts/integration.sh' - 'scripts/cleanup-integration.sh' - 'scripts/test-integration-cleanup.py' + - 'scripts/cleanup-review-images.sh' + - 'scripts/test-review-image-cleanup.py' concurrency: group: creative-workspace-${{ gitea.event.pull_request.number }} cancel-in-progress: true @@ -25,8 +27,9 @@ jobs: - name: Check patch formatting run: | git diff --check HEAD^ HEAD - bash -n scripts/integration.sh scripts/cleanup-integration.sh + bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/cleanup-review-images.sh python3 scripts/test-integration-cleanup.py + python3 scripts/test-review-image-cleanup.py - name: Verify Studio types, tests, build and smoke run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . - name: Build Wiki integration and runtime images @@ -45,3 +48,8 @@ jobs: env: GITEA_RUN_ID: ${{ gitea.run_id }} run: bash scripts/cleanup-integration.sh + - name: Remove this run disposable review image tags + if: always() + env: + REVIEW_SHA: ${{ gitea.sha }} + run: bash scripts/cleanup-review-images.sh diff --git a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts index 649d363..fe043fe 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts @@ -90,6 +90,9 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) ); const page = await context.newPage(); page.setDefaultTimeout(10000); + // Navigation includes document and script loading on the shared runner; + // keep action/readiness checks at 10s and the suite's overall bound unchanged. + page.setDefaultNavigationTimeout(30000); page.on("pageerror", (error) => errors.push(error.message)); page.on("crash", () => errors.push("Browser renderer crashed")); const pending = new Set(), network: string[] = []; @@ -97,12 +100,14 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) page.on("requestfinished", request => pending.delete(request.url())); page.on("requestfailed", request => {pending.delete(request.url());network.push(new URL(request.url()).pathname+": "+request.failure()?.errorText);}); async function navigate(url: string, reload = false) { - const previousSessionReplies = sessionReplies; + const previousSessionReplies = sessionReplies, started = Date.now(); try { const navigation = await (reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"})); assert.ok(navigation?.ok(), "Navigation returns a successful document"); + const documentMs = Date.now() - started; await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"}); assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state"); + console.log("Workspace UI navigation timing", JSON.stringify({path:new URL(url).pathname,documentMs,accountReadyMs:Date.now()-started})); } catch (error) { const probeStarted=Date.now(); const server=await fetch(url,{signal:AbortSignal.timeout(3000)}).then(async response=>{await response.body?.cancel();return {status:response.status,elapsedMs:Date.now()-probeStarted}}).catch(error=>({error:error instanceof Error?error.name:"unknown",elapsedMs:Date.now()-probeStarted})); diff --git a/scripts/cleanup-review-images.sh b/scripts/cleanup-review-images.sh new file mode 100644 index 0000000..db4b1ad --- /dev/null +++ b/scripts/cleanup-review-images.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +: "${REVIEW_SHA:?The exact review commit SHA is required}" +[[ "$REVIEW_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Refusing invalid review SHA' >&2; exit 2; } +# Only this workflow's disposable tags; production tags, volumes and other projects stay out of scope. +for repository in creative-studio-review creative-wiki-tests creative-wiki-review; do + tag="$repository:$REVIEW_SHA" + referenced=$(docker ps -aq | xargs -r docker inspect --format '{{.Image}}') + if ! image=$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null); then + echo "No review image: $tag" + continue + fi + if printf '%s\n' "$referenced" | grep -Fxq "$image"; then + echo "Keeping container-referenced review image: $tag" + continue + fi + docker image rm "$tag" +done diff --git a/scripts/integration.sh b/scripts/integration.sh index feab31e..919f839 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -17,7 +17,15 @@ compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${C cleanup(){ code=$? trap - EXIT INT TERM - if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true; fi + if [ "$code" -ne 0 ]; then + echo 'Runner pressure at integration failure (before disposable stack cleanup)' + uptime || true + free -m || true + df -h / || true + for resource in cpu memory io; do cat "/proc/pressure/$resource" || true; done + timeout 10s docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} io={{.BlockIO}} pids={{.PIDs}}' || true + compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true + fi cleanup_code=0 bash scripts/cleanup-integration.sh || cleanup_code=$? rm -f "$TEST_ENV" diff --git a/scripts/test-review-image-cleanup.py b/scripts/test-review-image-cleanup.py new file mode 100644 index 0000000..8e91729 --- /dev/null +++ b/scripts/test-review-image-cleanup.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Check review cleanup scope without Docker or build output.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('cleanup-review-images.sh') +SHA = 'a' * 40 +FAKE_DOCKER = '''#!/usr/bin/env python3 +import json, os, sys +args=sys.argv[1:] +with open(os.environ['DOCKER_CALLS'], 'a') as output: + output.write(json.dumps(args)+'\\n') +if args[0]=='ps': + if os.environ.get('FAIL_INVENTORY'): sys.exit(1) + if os.environ.get('REFERENCED'): print('container-id') +elif args[0]=='inspect': + print('sha256:review-image') +elif args[:2]==['image','inspect']: + if os.environ.get('MISSING_IMAGE'): sys.exit(1) + print('sha256:review-image') +''' + + +class ReviewCleanupTests(unittest.TestCase): + def run_cleanup(self, sha=SHA, **extra): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + docker = root / 'docker' + docker.write_text(FAKE_DOCKER) + docker.chmod(0o700) + calls = root / 'calls' + result = subprocess.run(['bash', str(SCRIPT)], capture_output=True, env={ + **os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'], + 'DOCKER_CALLS': str(calls), 'REVIEW_SHA': sha, **extra}) + return result.returncode, [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else [] + + def test_removes_only_exact_review_tags_without_force(self): + code, calls = self.run_cleanup() + self.assertEqual(code, 0) + self.assertEqual([args for args in calls if args[:2] == ['image', 'rm']], [ + ['image', 'rm', name + ':' + SHA] for name in + ['creative-studio-review', 'creative-wiki-tests', 'creative-wiki-review']]) + self.assertFalse(any('-f' in args or 'prune' in args or 'volume' in args for args in calls)) + + def test_invalid_sha_never_contacts_docker(self): + for value in ['', 'main', 'a' * 39, 'a' * 41, 'A' * 40, '*' * 40]: + with self.subTest(value=value): + code, calls = self.run_cleanup(value) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + + def test_container_referenced_images_are_kept(self): + code, calls = self.run_cleanup(REFERENCED='1') + self.assertEqual(code, 0) + self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls)) + + def test_missing_tags_are_a_successful_noop(self): + code, calls = self.run_cleanup(MISSING_IMAGE='1') + self.assertEqual(code, 0) + self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls)) + + def test_inventory_failure_prevents_removal(self): + code, calls = self.run_cleanup(FAIL_INVENTORY='1') + self.assertNotEqual(code, 0) + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main() -- 2.52.0