From ba6f42c143e459f526ac1b2c7677fc744b8569c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:50:18 +0900 Subject: [PATCH 01/12] test(ci): require retained full-product smoke screenshots --- .../test_stacked_pr_workflow_triggers.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/tests/test_stacked_pr_workflow_triggers.py b/backend/tests/test_stacked_pr_workflow_triggers.py index 1a878356a..073ee4090 100644 --- a/backend/tests/test_stacked_pr_workflow_triggers.py +++ b/backend/tests/test_stacked_pr_workflow_triggers.py @@ -14,6 +14,7 @@ ".github/workflows/dependency-review.yml", ".github/workflows/docker-publish.yml", ) +UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" @pytest.mark.parametrize("workflow_path", PR_VALIDATION_WORKFLOWS) @@ -43,3 +44,25 @@ def test_stacked_trigger_guard_rejects_excluded_bases(tmp_path, monkeypatch, bra monkeypatch.setitem(globals(), "REPO_ROOT", tmp_path) with pytest.raises(AssertionError): test_repo_local_pr_validation_accepts_stacked_base_branches("workflow.yml") + + +def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None: + """A passing browser smoke must publish its PNG evidence instead of discarding it.""" + workflow_text = (REPO_ROOT / ".github/workflows/app-ci.yml").read_text(encoding="utf-8") + workflow = yaml.load(workflow_text, Loader=yaml.BaseLoader) + frontend_steps = workflow["jobs"]["frontend"]["steps"] + upload_steps = [ + step + for step in frontend_steps + if step.get("uses", "").startswith("actions/upload-artifact@") + ] + + assert len(upload_steps) == 1 + upload_step = upload_steps[0] + assert upload_step["uses"] == f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" + assert upload_step["with"] == { + "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }}", + "path": "/tmp/naruon-full-product-smoke/*.png", + "if-no-files-found": "error", + "retention-days": "14", + } From 722b4e38ffb4a45ffe5840ba277be13d669e6ce1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:54:01 +0900 Subject: [PATCH 02/12] test(ci): match private smoke artifact directories --- backend/tests/test_stacked_pr_workflow_triggers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_stacked_pr_workflow_triggers.py b/backend/tests/test_stacked_pr_workflow_triggers.py index 073ee4090..c767e9a63 100644 --- a/backend/tests/test_stacked_pr_workflow_triggers.py +++ b/backend/tests/test_stacked_pr_workflow_triggers.py @@ -62,7 +62,7 @@ def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None assert upload_step["uses"] == f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" assert upload_step["with"] == { "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }}", - "path": "/tmp/naruon-full-product-smoke/*.png", + "path": "/tmp/naruon-full-product-smoke-*/*.png", "if-no-files-found": "error", "retention-days": "14", } From 3eaefab7879fd0a3ba2544a6782a5bade059641e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:54:19 +0900 Subject: [PATCH 03/12] fix(ci): retain full-product smoke PNG evidence --- .github/workflows/app-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 0f17eea32..19e7fbe31 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -138,3 +138,11 @@ jobs: NARUON_FULL_PRODUCT_BASE_URL: "http://127.0.0.1:3001" NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke" run: cd frontend && pnpm run full:smoke + + - name: Retain full product smoke PNG evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }} + path: /tmp/naruon-full-product-smoke-*/*.png + if-no-files-found: error + retention-days: 14 From 05786f955b265b1cae388c19b10ee765e2d854d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:56:28 +0900 Subject: [PATCH 04/12] test(ci): separate smoke evidence retention contract --- backend/tests/test_application_ci_evidence.py | 43 +++++++++++++++++++ .../test_stacked_pr_workflow_triggers.py | 23 ---------- 2 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_application_ci_evidence.py diff --git a/backend/tests/test_application_ci_evidence.py b/backend/tests/test_application_ci_evidence.py new file mode 100644 index 000000000..f58c3541d --- /dev/null +++ b/backend/tests/test_application_ci_evidence.py @@ -0,0 +1,43 @@ +"""Guard hosted buyer-facing evidence retention in Application CI.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +APPLICATION_CI_PATH = REPO_ROOT / ".github/workflows/app-ci.yml" +UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + + +def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None: + """A passing browser smoke must publish exact-head PNG evidence instead of discarding it.""" + workflow_text = APPLICATION_CI_PATH.read_text(encoding="utf-8") + workflow = yaml.load(workflow_text, Loader=yaml.BaseLoader) + frontend_steps = workflow["jobs"]["frontend"]["steps"] + + smoke_indices = [ + index + for index, step in enumerate(frontend_steps) + if step.get("name") == "Run full product smoke" + ] + upload_indices = [ + index + for index, step in enumerate(frontend_steps) + if step.get("uses", "").startswith("actions/upload-artifact@") + ] + + assert len(smoke_indices) == 1 + assert len(upload_indices) == 1 + assert upload_indices[0] == smoke_indices[0] + 1 + + upload_step = frontend_steps[upload_indices[0]] + assert upload_step["uses"] == f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" + assert upload_step["with"] == { + "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }}", + "path": "/tmp/naruon-full-product-smoke-*/*.png", + "if-no-files-found": "error", + "retention-days": "14", + } diff --git a/backend/tests/test_stacked_pr_workflow_triggers.py b/backend/tests/test_stacked_pr_workflow_triggers.py index c767e9a63..1a878356a 100644 --- a/backend/tests/test_stacked_pr_workflow_triggers.py +++ b/backend/tests/test_stacked_pr_workflow_triggers.py @@ -14,7 +14,6 @@ ".github/workflows/dependency-review.yml", ".github/workflows/docker-publish.yml", ) -UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" @pytest.mark.parametrize("workflow_path", PR_VALIDATION_WORKFLOWS) @@ -44,25 +43,3 @@ def test_stacked_trigger_guard_rejects_excluded_bases(tmp_path, monkeypatch, bra monkeypatch.setitem(globals(), "REPO_ROOT", tmp_path) with pytest.raises(AssertionError): test_repo_local_pr_validation_accepts_stacked_base_branches("workflow.yml") - - -def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None: - """A passing browser smoke must publish its PNG evidence instead of discarding it.""" - workflow_text = (REPO_ROOT / ".github/workflows/app-ci.yml").read_text(encoding="utf-8") - workflow = yaml.load(workflow_text, Loader=yaml.BaseLoader) - frontend_steps = workflow["jobs"]["frontend"]["steps"] - upload_steps = [ - step - for step in frontend_steps - if step.get("uses", "").startswith("actions/upload-artifact@") - ] - - assert len(upload_steps) == 1 - upload_step = upload_steps[0] - assert upload_step["uses"] == f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" - assert upload_step["with"] == { - "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }}", - "path": "/tmp/naruon-full-product-smoke-*/*.png", - "if-no-files-found": "error", - "retention-days": "14", - } From b12a84f9b113fdeedd0044563e890146edb78753 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:58:47 +0900 Subject: [PATCH 05/12] test(ci): require repo-local validation to checkout PR head --- .../tests/test_repo_local_pr_exact_head.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/tests/test_repo_local_pr_exact_head.py diff --git a/backend/tests/test_repo_local_pr_exact_head.py b/backend/tests/test_repo_local_pr_exact_head.py new file mode 100644 index 000000000..ed2374df5 --- /dev/null +++ b/backend/tests/test_repo_local_pr_exact_head.py @@ -0,0 +1,35 @@ +"""Guard exact-head source checkout in repository-owned pull-request validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PR_VALIDATION_WORKFLOWS = ( + ".github/workflows/app-ci.yml", + ".github/workflows/bandit.yml", + ".github/workflows/dependency-review.yml", + ".github/workflows/docker-publish.yml", +) +EXPECTED_EXACT_REF = "${{ github.event.pull_request.head.sha || github.sha }}" + + +@pytest.mark.parametrize("workflow_path", PR_VALIDATION_WORKFLOWS) +def test_repo_local_validation_checks_out_exact_pr_head(workflow_path: str) -> None: + """Do not report pull-request merge-tree execution as exact-head validation.""" + workflow_text = (REPO_ROOT / workflow_path).read_text(encoding="utf-8") + workflow = yaml.load(workflow_text, Loader=yaml.BaseLoader) + checkout_steps = [ + step + for job in workflow["jobs"].values() + for step in job.get("steps", []) + if step.get("uses", "").startswith("actions/checkout@") + ] + + assert checkout_steps + for checkout_step in checkout_steps: + assert checkout_step.get("with", {}).get("ref") == EXPECTED_EXACT_REF From d3e1480b0a8ddc02dd45f0810cb90620359cd200 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:00:50 +0900 Subject: [PATCH 06/12] fix(ci): validate and label exact pull-request head --- .github/workflows/app-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 19e7fbe31..f7a4086a9 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -38,6 +38,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Set up Python @@ -89,6 +90,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install pnpm @@ -142,7 +144,7 @@ jobs: - name: Retain full product smoke PNG evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }} + name: naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.pull_request.head.sha || github.sha }} path: /tmp/naruon-full-product-smoke-*/*.png if-no-files-found: error retention-days: 14 From a4abdb11a9905997c4f811d423ba2cbd1de2cffb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:01:17 +0900 Subject: [PATCH 07/12] test(ci): bind smoke artifact identity to PR head --- backend/tests/test_application_ci_evidence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_application_ci_evidence.py b/backend/tests/test_application_ci_evidence.py index f58c3541d..7d60ce731 100644 --- a/backend/tests/test_application_ci_evidence.py +++ b/backend/tests/test_application_ci_evidence.py @@ -13,7 +13,7 @@ def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None: - """A passing browser smoke must publish exact-head PNG evidence instead of discarding it.""" + """A passing browser smoke must publish PR-head-bound PNG evidence instead of discarding it.""" workflow_text = APPLICATION_CI_PATH.read_text(encoding="utf-8") workflow = yaml.load(workflow_text, Loader=yaml.BaseLoader) frontend_steps = workflow["jobs"]["frontend"]["steps"] @@ -36,7 +36,7 @@ def test_application_ci_retains_full_product_smoke_screenshot_evidence() -> None upload_step = frontend_steps[upload_indices[0]] assert upload_step["uses"] == f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" assert upload_step["with"] == { - "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.sha }}", + "name": "naruon-full-product-smoke-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.pull_request.head.sha || github.sha }}", "path": "/tmp/naruon-full-product-smoke-*/*.png", "if-no-files-found": "error", "retention-days": "14", From 0c00fca0900395b6e15dbac800847b79b7f8bde1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:01:34 +0900 Subject: [PATCH 08/12] fix(ci): scan exact pull-request head --- .github/workflows/bandit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 9436ad570..3d1eb99d6 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -24,6 +24,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Set up Python From 19133ec7377eb1a6de80fce7b67d42ac86e0434b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:01:47 +0900 Subject: [PATCH 09/12] fix(ci): review dependencies at exact PR head --- .github/workflows/dependency-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 2b369940d..563c2c516 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -32,6 +32,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Log dependency review policy From d30f0b7c403bcf2b06d84b27f2744d5c17177294 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:02:59 +0900 Subject: [PATCH 10/12] fix(ci): build exact pull-request head --- .github/workflows/docker-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c3c33520a..b9bbee381 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -60,6 +60,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Set up QEMU @@ -213,6 +214,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Read release version From a10d180384133217ec5891802772ea5ac9233ead Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:42:59 +0900 Subject: [PATCH 11/12] fix(ci): cancel superseded image checks and queue releases Keep PR matrix siblings together under a workflow repository PR group. Retain release runs in the native max queue without cancelling active publication or deployment. Co-Authored-By: Codex Signed-off-by: Seongho Bae --- .github/workflows/docker-publish.yml | 5 +++++ backend/tests/test_release_governance.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b9bbee381..81382f12e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,6 +14,11 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'release-ghcr-aks' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index a23c70746..a31b10be5 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -670,6 +670,25 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe assert "release/**" not in push_block +def test_docker_publish_concurrency_isolates_prs_and_serializes_releases() -> None: + workflow = yaml.safe_load(read_repo_text(".github/workflows/docker-publish.yml")) + assert workflow.get("concurrency") == { + "group": "${{ github.workflow }}-${{ github.repository }}-" + "${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'release-ghcr-aks' }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + "queue": "${{ github.event_name == 'pull_request' && 'single' || 'max' }}", + } + jobs = workflow["jobs"] + assert all("concurrency" not in job for job in jobs.values()) + assert jobs["pull_request_image_validation"]["if"] == "github.event_name == 'pull_request'" + assert jobs["publish_images"]["if"] == ( + "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + ) + assert workflow["permissions"] == {"contents": "read"} + assert "permissions" not in jobs["pull_request_image_validation"] + assert len(jobs["pull_request_image_validation"]["strategy"]["matrix"]["include"]) == 3 + + def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_tags() -> ( None ): From 02dd5747ba4bf110a2a148deb532442ce5eb0694 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:46:29 +0900 Subject: [PATCH 12/12] fix(ci): keep Docker concurrency in canonical lane --- .github/workflows/docker-publish.yml | 5 ----- backend/tests/test_release_governance.py | 19 ------------------- 2 files changed, 24 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 81382f12e..b9bbee381 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,11 +14,6 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'release-ghcr-aks' }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} - env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index a31b10be5..a23c70746 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -670,25 +670,6 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe assert "release/**" not in push_block -def test_docker_publish_concurrency_isolates_prs_and_serializes_releases() -> None: - workflow = yaml.safe_load(read_repo_text(".github/workflows/docker-publish.yml")) - assert workflow.get("concurrency") == { - "group": "${{ github.workflow }}-${{ github.repository }}-" - "${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'release-ghcr-aks' }}", - "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", - "queue": "${{ github.event_name == 'pull_request' && 'single' || 'max' }}", - } - jobs = workflow["jobs"] - assert all("concurrency" not in job for job in jobs.values()) - assert jobs["pull_request_image_validation"]["if"] == "github.event_name == 'pull_request'" - assert jobs["publish_images"]["if"] == ( - "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" - ) - assert workflow["permissions"] == {"contents": "read"} - assert "permissions" not in jobs["pull_request_image_validation"] - assert len(jobs["pull_request_image_validation"]["strategy"]["matrix"]["include"]) == 3 - - def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_tags() -> ( None ):