From a6a8199d3555335c195d76f996b655c1df954545 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:28:27 +0900 Subject: [PATCH 01/10] fix(actions): scope pull request concurrency Signed-off-by: Seongho Bae --- .github/workflows/app-ci.yml | 2 +- .github/workflows/bandit.yml | 4 ++++ .github/workflows/dependency-review.yml | 2 +- .github/workflows/docker-publish.yml | 4 ++++ .github/workflows/pr-governance.yml | 4 ++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index e8f445748..f87ba149e 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: application-ci-${{ github.event.pull_request.number || github.ref }} + group: application-ci-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index c5c613c08..a662d2d35 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -7,6 +7,10 @@ on: branches: [ develop, master ] workflow_dispatch: +concurrency: + group: bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c303d1e61..ab4cd4e2f 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,7 +13,7 @@ permissions: pull-requests: read concurrency: - group: dependency-review-${{ github.event.pull_request.number || github.ref }} + group: dependency-review-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 879b906ec..1f44adcb5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,6 +13,10 @@ on: permissions: contents: read +concurrency: + group: build-and-publish-docker-images-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 7dcea3104..e04f467b2 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -31,8 +31,8 @@ permissions: issues: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || github.run_id }} - cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || github.run_id }} + cancel-in-progress: true jobs: governance: From 5091a1ead3a467e4928de34aa3535ba90070ea0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:42:43 +0900 Subject: [PATCH 02/10] fix(actions): isolate Bandit PR cancellation Keep manual and push Bandit scans independent, cancel only superseded pull-request scans, and remove overlapping/no-op concurrency edits from unrelated workflows. Signed-off-by: Seongho Bae --- .github/workflows/app-ci.yml | 2 +- .github/workflows/bandit.yml | 4 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/docker-publish.yml | 64 ++++++++++++++++------ .github/workflows/pr-governance.yml | 4 +- backend/tests/test_workflow_concurrency.py | 30 ++++++++++ 6 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_workflow_concurrency.py diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index f87ba149e..e8f445748 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: application-ci-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + group: application-ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index a662d2d35..411a5d2a1 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -8,8 +8,8 @@ on: workflow_dispatch: concurrency: - group: bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: bandit-security-scan-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ab4cd4e2f..c303d1e61 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,7 +13,7 @@ permissions: pull-requests: read concurrency: - group: dependency-review-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + group: dependency-review-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1f44adcb5..fc7058413 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,10 +13,6 @@ on: permissions: contents: read -concurrency: - group: build-and-publish-docker-images-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal @@ -36,18 +32,21 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile + base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -68,9 +67,28 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Resolve pinned Ollama base manifest + if: matrix.component == 'naruon' + run: | + base_image="$(awk 'toupper($1) == "FROM" { print $2; exit }' Dockerfile.ollama)" + if ! printf '%s\n' "$base_image" | grep -Eq '^ollama/ollama@sha256:[0-9a-f]{64}$'; then + printf '::error file=Dockerfile.ollama,line=1::Expected an exact ollama/ollama sha256 base pin; found %s\n' "$base_image" + exit 1 + fi + printf 'Resolving pinned Ollama base manifest: %s\n' "$base_image" + manifest_output="$(docker buildx imagetools inspect "$base_image")" + printf '%s\n' "$manifest_output" + for platform in linux/amd64 linux/arm64; do + if ! printf '%s\n' "$manifest_output" | grep -Eq "^[[:space:]]*Platform:[[:space:]]+${platform}[[:space:]]*$"; then + printf '::error file=Dockerfile.ollama,line=1::Pinned Ollama manifest is missing %s\n' "$platform" + exit 1 + fi + done + - name: Prepare OCI annotation values id: oci env: + BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -80,24 +98,29 @@ jobs: version="$(cat VERSION)" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" + base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" + if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then + printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" + exit 1 + fi + base_digest="${base_reference##*@}" + base_repository="${base_reference%@*}" + case "$base_repository" in + */*) base_name="$base_reference" ;; + *) base_name="docker.io/library/$base_reference" ;; + esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" - base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" - base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { @@ -162,18 +185,21 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile + base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -208,6 +234,7 @@ jobs: - name: Prepare OCI annotation values id: oci env: + BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -218,24 +245,29 @@ jobs: version="${VERSION_VALUE:-$(cat VERSION)}" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" + base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" + if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then + printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" + exit 1 + fi + base_digest="${base_reference##*@}" + base_repository="${base_reference%@*}" + case "$base_repository" in + */*) base_name="$base_reference" ;; + *) base_name="docker.io/library/$base_reference" ;; + esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" - base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" - base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index e04f467b2..7dcea3104 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -31,8 +31,8 @@ permissions: issues: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || github.run_id }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || github.run_id }} + cancel-in-progress: false jobs: governance: diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py new file mode 100644 index 000000000..8f9f17849 --- /dev/null +++ b/backend/tests/test_workflow_concurrency.py @@ -0,0 +1,30 @@ +"""Regression tests for GitHub Actions concurrency boundaries.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _workflow_text(name: str) -> str: + path = REPO_ROOT / ".github" / "workflows" / name + assert path.exists(), f"workflow is missing: {name}" + return path.read_text(encoding="utf-8") + + +def test_bandit_cancels_only_superseded_pull_request_runs() -> None: + """Keep manual and push scans independent while deduplicating PR scans.""" + workflow = _workflow_text("bandit.yml") + concurrency = workflow.split("concurrency:\n", 1)[1].split("\npermissions:", 1)[0] + + assert ( + "group: bandit-security-scan-${{ github.event.pull_request.number || github.run_id }}" + in concurrency + ) + assert ( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in concurrency + ) + assert "github.ref" not in concurrency + assert "github.repository" not in concurrency From a701d2f5feda80634ca0ae3e46d63f513c19eae6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:47:22 +0900 Subject: [PATCH 03/10] test(actions): parse Bandit concurrency contract Validate the workflow as YAML instead of relying on source-substring boundaries. Signed-off-by: Seongho Bae --- backend/tests/test_workflow_concurrency.py | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 8f9f17849..1d38749a5 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -4,27 +4,26 @@ from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).resolve().parents[2] -def _workflow_text(name: str) -> str: +def _workflow(name: str) -> dict[str, object]: path = REPO_ROOT / ".github" / "workflows" / name assert path.exists(), f"workflow is missing: {name}" - return path.read_text(encoding="utf-8") + parsed = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(parsed, dict), f"workflow must parse as a mapping: {name}" + return parsed def test_bandit_cancels_only_superseded_pull_request_runs() -> None: """Keep manual and push scans independent while deduplicating PR scans.""" - workflow = _workflow_text("bandit.yml") - concurrency = workflow.split("concurrency:\n", 1)[1].split("\npermissions:", 1)[0] - - assert ( - "group: bandit-security-scan-${{ github.event.pull_request.number || github.run_id }}" - in concurrency - ) - assert ( - "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in concurrency - ) - assert "github.ref" not in concurrency - assert "github.repository" not in concurrency + concurrency = _workflow("bandit.yml").get("concurrency") + + assert isinstance(concurrency, dict) + assert concurrency == { + "group": "bandit-security-scan-${{ github.event.pull_request.number || github.run_id }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + } From 2585126b40570c912399ca3fe49d4d803fed0ebc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 19:56:04 +0900 Subject: [PATCH 04/10] fix(actions): include repository in Bandit group Signed-off-by: Seongho Bae --- .github/workflows/bandit.yml | 2 +- backend/tests/test_workflow_concurrency.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 411a5d2a1..f0cccc02c 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: concurrency: - group: bandit-security-scan-${{ github.event.pull_request.number || github.run_id }} + group: bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 1d38749a5..56fd995c6 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -24,6 +24,6 @@ def test_bandit_cancels_only_superseded_pull_request_runs() -> None: assert isinstance(concurrency, dict) assert concurrency == { - "group": "bandit-security-scan-${{ github.event.pull_request.number || github.run_id }}", + "group": "bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", } From 9d7d16d8e90edc9f08193718434cb9255d744250 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:14:51 +0900 Subject: [PATCH 05/10] test(actions): clarify workflow loader naming Assisted-by: OpenAI Codex Signed-off-by: Seongho Bae --- backend/tests/test_workflow_concurrency.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 56fd995c6..9b3469f45 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -10,17 +10,19 @@ REPO_ROOT = Path(__file__).resolve().parents[2] -def _workflow(name: str) -> dict[str, object]: - path = REPO_ROOT / ".github" / "workflows" / name - assert path.exists(), f"workflow is missing: {name}" +def _load_workflow(workflow_name: str) -> dict[str, object]: + path = REPO_ROOT / ".github" / "workflows" / workflow_name + assert path.exists(), f"workflow is missing: {workflow_name}" parsed = yaml.safe_load(path.read_text(encoding="utf-8")) - assert isinstance(parsed, dict), f"workflow must parse as a mapping: {name}" + assert isinstance(parsed, dict), ( + f"workflow must parse as a mapping: {workflow_name}" + ) return parsed def test_bandit_cancels_only_superseded_pull_request_runs() -> None: """Keep manual and push scans independent while deduplicating PR scans.""" - concurrency = _workflow("bandit.yml").get("concurrency") + concurrency = _load_workflow("bandit.yml").get("concurrency") assert isinstance(concurrency, dict) assert concurrency == { From 8e7c670100c7018905d21a0c5cd6d55033333fbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:28:17 +0900 Subject: [PATCH 06/10] ci(actions): cancel inactive PR workloads Co-Authored-By: Codex Signed-off-by: Seongho Bae --- .github/workflows/app-ci.yml | 7 ++++-- .github/workflows/docker-publish.yml | 7 +++++- backend/tests/test_workflow_concurrency.py | 27 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index e8f445748..23f508718 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -2,6 +2,7 @@ name: Application CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: - develop - master @@ -15,11 +16,12 @@ permissions: contents: read concurrency: - group: application-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: backend: + if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.action != 'closed') }} name: backend (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: @@ -77,6 +79,7 @@ jobs: fi frontend: + if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.action != 'closed') }} name: frontend runs-on: ubuntu-latest steps: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fc7058413..68a35e38d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,6 +5,7 @@ on: tags: - "v*" pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: - develop - master @@ -13,6 +14,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal @@ -23,7 +28,7 @@ env: jobs: pull_request_image_validation: name: validate ${{ matrix.component }} image - if: github.event_name == 'pull_request' + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.action != 'closed' }} runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 9b3469f45..0b5ce6656 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -29,3 +29,30 @@ def test_bandit_cancels_only_superseded_pull_request_runs() -> None: "group": "bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", } + + +def test_expensive_pr_workflows_cancel_only_active_pr_predecessors() -> None: + """Draft and closed events cancel stale work without starting heavy jobs.""" + for workflow_name, job_names in { + "app-ci.yml": ("backend", "frontend"), + "docker-publish.yml": ("pull_request_image_validation",), + }.items(): + workflow = _load_workflow(workflow_name) + pull_request = workflow[True]["pull_request"] + assert pull_request["types"] == [ + "opened", + "synchronize", + "reopened", + "ready_for_review", + "converted_to_draft", + "closed", + ] + assert workflow["concurrency"] == { + "group": "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + } + jobs = workflow["jobs"] + for job_name in job_names: + condition = jobs[job_name]["if"] + assert "!github.event.pull_request.draft" in condition + assert "github.event.action != 'closed'" in condition From b70c62f5ff4d24fabe13dcd9ede1dfac1889bfe7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:28:53 +0900 Subject: [PATCH 07/10] ci(actions): preserve non-PR workflow runs Co-Authored-By: Codex Signed-off-by: Seongho Bae --- .github/workflows/app-ci.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- backend/tests/test_workflow_concurrency.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 23f508718..8355e5956 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -16,7 +16,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 68a35e38d..ae4be822e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 0b5ce6656..022af34b6 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -48,7 +48,7 @@ def test_expensive_pr_workflows_cancel_only_active_pr_predecessors() -> None: "closed", ] assert workflow["concurrency"] == { - "group": "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}", + "group": "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", } jobs = workflow["jobs"] From 7f00cd0e56c4061b339e9d64bf44426f39d4120d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:15:50 +0900 Subject: [PATCH 08/10] fix(actions): isolate Bandit rerun concurrency --- .github/workflows/app-ci.yml | 7 ++---- .github/workflows/bandit.yml | 2 +- .github/workflows/docker-publish.yml | 7 +----- backend/tests/test_workflow_concurrency.py | 29 +--------------------- 4 files changed, 5 insertions(+), 40 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 8355e5956..e8f445748 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -2,7 +2,6 @@ name: Application CI on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: - develop - master @@ -16,12 +15,11 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: application-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: backend: - if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.action != 'closed') }} name: backend (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: @@ -79,7 +77,6 @@ jobs: fi frontend: - if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.action != 'closed') }} name: frontend runs-on: ubuntu-latest steps: diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index f0cccc02c..9c443dec6 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -9,7 +9,7 @@ on: concurrency: group: bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 }} permissions: contents: read diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ae4be822e..fc7058413 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,7 +5,6 @@ on: tags: - "v*" pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: - develop - master @@ -14,10 +13,6 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - env: REGISTRY: ghcr.io # Keep the explicit opt-in as belt-and-suspenders. The real warning removal @@ -28,7 +23,7 @@ env: jobs: pull_request_image_validation: name: validate ${{ matrix.component }} image - if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.action != 'closed' }} + if: github.event_name == 'pull_request' runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 022af34b6..6b9b71635 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -27,32 +27,5 @@ def test_bandit_cancels_only_superseded_pull_request_runs() -> None: assert isinstance(concurrency, dict) assert concurrency == { "group": "bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", - "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request' && github.run_attempt == 1 }}", } - - -def test_expensive_pr_workflows_cancel_only_active_pr_predecessors() -> None: - """Draft and closed events cancel stale work without starting heavy jobs.""" - for workflow_name, job_names in { - "app-ci.yml": ("backend", "frontend"), - "docker-publish.yml": ("pull_request_image_validation",), - }.items(): - workflow = _load_workflow(workflow_name) - pull_request = workflow[True]["pull_request"] - assert pull_request["types"] == [ - "opened", - "synchronize", - "reopened", - "ready_for_review", - "converted_to_draft", - "closed", - ] - assert workflow["concurrency"] == { - "group": "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", - "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", - } - jobs = workflow["jobs"] - for job_name in job_names: - condition = jobs[job_name]["if"] - assert "!github.event.pull_request.draft" in condition - assert "github.event.action != 'closed'" in condition From 6b7ad6ccb4a7a021d74381781f40af7cc97233f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:39:22 +0900 Subject: [PATCH 09/10] fix(actions): isolate Bandit rerun concurrency --- .github/workflows/bandit.yml | 2 +- backend/tests/test_workflow_concurrency.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 9c443dec6..93b0ef1e7 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: concurrency: - group: bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + group: bandit-security-scan-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 }} permissions: diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 6b9b71635..482f4d9a6 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -26,6 +26,6 @@ def test_bandit_cancels_only_superseded_pull_request_runs() -> None: assert isinstance(concurrency, dict) assert concurrency == { - "group": "bandit-security-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", + "group": "bandit-security-scan-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }}", "cancel-in-progress": "${{ github.event_name == 'pull_request' && github.run_attempt == 1 }}", } From 9964646e245a0930ddfaa9d1558bda8daf85c247 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 22:58:51 +0900 Subject: [PATCH 10/10] fix(actions): coalesce Bandit push scans per protected branch PR scans already shared a group. Push still used run_id so every develop/master commit kept its own queued slot. Match the remaining ceiling contract: one group per PR or protected branch, unique workflow_dispatch, cancel-in-progress on this security scan. Signed-off-by: Seongho Bae --- .github/workflows/bandit.yml | 13 +++++++++++-- backend/tests/test_workflow_concurrency.py | 16 ++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 93b0ef1e7..b1161aaaa 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -8,8 +8,17 @@ on: workflow_dispatch: concurrency: - group: bandit-security-scan-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 }} + # Workflow-level admission: a job-level group never coalesces while the run + # is queued behind the organization job ceiling. Push scans share one group + # per protected branch so a newer develop/master head retires the older + # scan. workflow_dispatch keeps github.run_id and is not cancelled by a + # sibling. This is a security scan, not merge/release/deploy. + group: >- + bandit-security-scan-${{ github.repository }}-${{ + github.event.pull_request.number || + (github.event_name == 'push' && format('push-{0}', github.ref_name)) || + github.run_id }} + cancel-in-progress: true permissions: contents: read diff --git a/backend/tests/test_workflow_concurrency.py b/backend/tests/test_workflow_concurrency.py index 482f4d9a6..e9b424a56 100644 --- a/backend/tests/test_workflow_concurrency.py +++ b/backend/tests/test_workflow_concurrency.py @@ -20,12 +20,16 @@ def _load_workflow(workflow_name: str) -> dict[str, object]: return parsed -def test_bandit_cancels_only_superseded_pull_request_runs() -> None: - """Keep manual and push scans independent while deduplicating PR scans.""" +def test_bandit_coalesces_pr_and_protected_branch_push_scans() -> None: + """Cancel superseded PR and push scans; keep manual dispatch unique.""" concurrency = _load_workflow("bandit.yml").get("concurrency") assert isinstance(concurrency, dict) - assert concurrency == { - "group": "bandit-security-scan-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }}", - "cancel-in-progress": "${{ github.event_name == 'pull_request' && github.run_attempt == 1 }}", - } + group = concurrency.get("group") + assert isinstance(group, str) + assert "bandit-security-scan-${{ github.repository }}-" in group + assert "github.event.pull_request.number" in group + assert "github.event_name == 'push' && format('push-{0}', github.ref_name)" in group + assert "github.run_id" in group + assert "github.run_attempt" not in group + assert concurrency.get("cancel-in-progress") is True